From beeaf43fb6d0af8b377883657b98ae4f9cba7f8e Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 11 Sep 2026 10:17:37 +0000 Subject: [PATCH 1/4] Add experimental Node SDK controls for runtime AHP hosting Expose startAhpHost and stopAhpHost over the runtime RPC connection, preserving the SDK session lifecycle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 27 +++++++++++++++++++++++++++ nodejs/test/client.test.ts | 19 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 035f0f5e60..d233339c97 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -2093,6 +2093,33 @@ export class CopilotClient { }; } + /** + * Start the runtime-hosted Agent Host Protocol endpoint for this runtime's sessions. + * Connect an AHP client to the returned WebSocket URL. + * + * @experimental + * @throws Error if the client is not connected or the runtime does not support AHP. + */ + async startAhpHost(): Promise<{ url: string }> { + if (!this.connection) { + throw new Error("Client is not connected. Call start() first."); + } + return this.connection.sendRequest("ahp.start", {}); + } + + /** + * Stop the runtime-hosted AHP endpoint without stopping the SDK's sessions. + * + * @experimental + * @throws Error if the client is not connected or the runtime does not support AHP. + */ + async stopAhpHost(): Promise { + if (!this.connection) { + throw new Error("Client is not connected. Call start() first."); + } + await this.connection.sendRequest("ahp.stop", {}); + } + /** * Get CLI status including version and protocol information */ diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 119ed40a10..a0984ccfc5 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -62,6 +62,25 @@ describe("approveAll", () => { }); describe("CopilotClient", () => { + it("starts and stops the runtime-hosted AHP endpoint", async () => { + const client = new CopilotClient({ autoStart: false }); + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ url: "ws://127.0.0.1:12345" }) + .mockResolvedValueOnce({}); + (client as any).connection = { sendRequest }; + + await expect(client.startAhpHost()).resolves.toEqual({ url: "ws://127.0.0.1:12345" }); + await expect(client.stopAhpHost()).resolves.toBeUndefined(); + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual(["ahp.start", "ahp.stop"]); + }); + + it("requires a connected client to manage the AHP endpoint", async () => { + const client = new CopilotClient({ autoStart: false }); + await expect(client.startAhpHost()).rejects.toThrow("Client is not connected"); + await expect(client.stopAhpHost()).rejects.toThrow("Client is not connected"); + }); + it("start() is single-flight: concurrent callers share one startup", async () => { const client = new CopilotClient({ autoStart: false }); onTestFinished(() => client.forceStop()); From 9821cb666c9ba48f74b19e67c03fc4c654b0bc54 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 11 Sep 2026 10:22:32 +0000 Subject: [PATCH 2/4] Add standard AHP client demo and regenerate Node RPC wrappers Demonstrate an SDK-defined Bert agent through an independent standard AHP 0.7 client. Add RPC-only Node generation and document local runtime builds, schema inputs, and existing SDK e2e selectors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/generated/rpc.ts | 79 ++++++++++++++++++++---- samples/ahp/.gitignore | 1 + samples/ahp/README.md | 112 ++++++++++++++++++++++++++++++++++ samples/ahp/ahp-client.mjs | 86 ++++++++++++++++++++++++++ samples/ahp/package-lock.json | 41 +++++++++++++ samples/ahp/package.json | 13 ++++ samples/ahp/sdk-host.mjs | 47 ++++++++++++++ scripts/codegen/typescript.ts | 14 +++-- 8 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 samples/ahp/.gitignore create mode 100644 samples/ahp/README.md create mode 100644 samples/ahp/ahp-client.mjs create mode 100644 samples/ahp/package-lock.json create mode 100644 samples/ahp/package.json create mode 100644 samples/ahp/sdk-host.mjs diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index b2ce8c05e9..27ead3c77b 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1016,16 +1016,16 @@ export type EventsReadDirection = /** Tail-first: return the newest events and page toward older events. */ | "backward"; /** - * Cursor status: 'ok' means the cursor was applied successfully; 'expired' means the cursor referred to an event that no longer exists in history (e.g. truncated or compacted away) and the read fell back to a boundary of the remaining history (the beginning for a forward read, the tail for a backward read). The fallback page is a fresh boundary snapshot, not a continuation of the requested cursor, so it may overlap already-rendered events; on 'expired' a consumer should reset/rebase its pagination state (or deduplicate by event id) before continuing from the returned cursor. + * Cursor status: 'ok' means the read succeeded against the requested history; 'expired' means the requested continuation is unavailable. Recovery is endpoint-specific: session.eventLog.read returns a boundary window of remaining active history that may overlap prior pages, while sessions.readPersistedEvents returns an empty terminal page and never switches journal generations. An expired persisted read is not successful completion; a complete persisted snapshot requires cursorStatus 'ok' and hasMore false. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "EventsCursorStatus". */ /** @experimental */ export type EventsCursorStatus = - /** The cursor was applied successfully. */ + /** The read succeeded against the requested history. */ | "ok" - /** The cursor referred to history that is no longer available. */ + /** The requested continuation is unavailable; see the endpoint's recovery semantics. */ | "expired"; /** * Discovery source: project (.github/extensions/), user (~/.copilot/extensions/), plugin (installed plugin), or session (session-state//extensions/) @@ -2658,7 +2658,9 @@ export type PermissionDecisionSource = /** The host applied a standing policy or override rather than a judge recommendation or human decision. */ | "host_policy" /** The host denied the request because no interactive user response was available. */ - | "unattended_fallback"; + | "unattended_fallback" + /** A live authorization record from an earlier human decision in this session contained the proposal, so it ran without another prompt. This is not a new human decision and never mints authority of its own. */ + | "authorization_carry_forward"; /** * Client surface that submitted a permission response. * @@ -4256,7 +4258,7 @@ export interface CopilotUserResponse { */ organization_login_list?: string[]; /** - * Organizations the user belongs to, each with an optional login and display name. + * Organizations the user belongs to, each with an optional ID, login, and display name. */ organization_list?: | ( @@ -4264,6 +4266,10 @@ export interface CopilotUserResponse { [k: string]: unknown | undefined; } | ({ + /** + * Numeric database ID of the organization. + */ + id?: number; /** * GitHub login of the organization. */ @@ -5300,6 +5306,19 @@ export interface AgentsGetDiscoveryPathsRequest { */ excludeHostAgents?: boolean; } +/** + * The loopback WebSocket endpoint serving live SDK sessions. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "AhpStartResult". + */ +/** @experimental */ +export interface AhpStartResult { + url: string; +} + +/** @experimental */ +export interface AhpStopResult {} /** * Credential-free authentication identity safe to expose to hosts and user interfaces. * @@ -7553,7 +7572,7 @@ export interface EventsReadResult { */ cursor: string; /** - * True when more events are available in the read's direction. For a forward read, true means the batch returned `max` events and more are available immediately. For a backward read, true means older persisted events remain before the returned window. + * True when more events are available in the read's direction. For a backward read, true means older persisted events remain before the returned window. A persisted-event page may contain fewer than `max` events because of its byte budget while still reporting hasMore true; continue according to this flag rather than the event count. */ hasMore: boolean; cursorStatus: EventsCursorStatus; @@ -9016,7 +9035,7 @@ export interface FactoryToolRunRequest { toolCallId?: string; } /** - * Optional user prompt to combine with the fleet orchestration instructions. + * Parameters for starting fleet orchestration: an optional user prompt combined with the fleet instructions, plus the send options forwarded to the resulting turn. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "FleetStartRequest". @@ -9027,6 +9046,20 @@ export interface FleetStartRequest { * Optional user prompt to combine with fleet instructions */ prompt?: string; + /** + * Optional attachments (files, directories, selections, blobs, GitHub references) to include with the fleet request + */ + attachments?: Attachment[]; + /** + * If false, this request will not trigger a Premium Request Unit charge. User requests default to billable. + * + * @internal + */ + billable?: boolean; + /** + * If true, await completion of the agentic loop for this fleet request before returning. Defaults to false. + */ + wait?: boolean; } /** * Indicates whether fleet mode was successfully activated. @@ -13515,6 +13548,7 @@ export interface ModelSwitchToResult { /** @experimental */ export interface ModeSetRequest { mode: SessionMode; + expectedMode?: SessionMode; /** * Session whose plan-mode base state should be inherited. */ @@ -13569,6 +13603,10 @@ export interface ModeSetResult { * Whether applying the mode changed the active model. */ modelChanged: boolean; + /** + * Whether the requested mode was applied to the session. False only when an 'expectedMode' precondition did not hold, in which case any model change reported alongside it was still applied. + */ + modeApplied?: boolean; confirmation?: ModelSwitchConfirmation; /** * User-facing warning produced while applying the mode change. @@ -19228,6 +19266,10 @@ export interface SessionOpenOptions { * Whether to skip custom instruction sources. */ skipCustomInstructions?: boolean; + /** + * Whether to invalidate cached custom-instruction discovery before constructing the session. Use when instruction files may have changed earlier in the same runtime process. + */ + refreshCustomInstructions?: boolean; /** * Instruction source IDs disabled for this session. */ @@ -20405,11 +20447,11 @@ export interface SessionsReadPersistedEventsRequest { */ sessionId: string; /** - * Opaque cursor returned by a previous persisted-event read. Omit on the first call. + * Opaque, process-local, single-use cursor returned by the previous persisted-event read. Omit on the first call and issue continuations sequentially; reusing the same cursor returns an expired terminal page. */ cursor?: string; /** - * Maximum number of events to return in this batch (1–1000, default 200). + * Maximum number of events to return in this batch (1–1000, default 200). Pages may contain fewer events to keep the serialized event array within a soft 1 MiB budget including resolved binary assets; one oversized event is returned alone to guarantee progress. */ max?: number; direction?: EventsReadDirection; @@ -24098,6 +24140,21 @@ export interface SessionFsSqliteExistsRequest { /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { + /** @experimental */ + ahp: { + /** + * Starts a loopback-only AHP endpoint over this runtime's live SDK sessions. Experimental proof of concept. + * + * @returns The loopback WebSocket endpoint serving live SDK sessions. + */ + start: async (): Promise => + connection.sendRequest("ahp.start", {}), + /** + * Stops the AHP endpoint and disconnects its clients without stopping SDK sessions. + */ + stop: async (): Promise => + connection.sendRequest("ahp.stop", {}), + }, /** * Checks server responsiveness and returns protocol information. * @@ -24662,7 +24719,7 @@ export function createServerRpc(connection: MessageConnection) { getClientMetadata: async (params: SessionsGetClientMetadataRequest): Promise => connection.sendRequest("sessions.getClientMetadata", params), /** - * Reads a page of durable events directly from a local session's persisted journal without creating, resuming, or activating the session. The initial backward read uses a bounded tail scan for fast first paint; cursor continuations preserve the session event-log paging semantics. Persisted events may omit payloads that are reconstructed only for an active session. + * Reads a page of durable events directly from a local session's persisted journal without creating, resuming, or activating the session. The first read pins the currently opened journal generation and its byte-length boundary; opaque cursor continuations remain on that generation across runtime-owned compaction, truncation, and rewrite operations, which replace the live path atomically, and events appended after the boundary are excluded. For cold hydration, await the first successful page before activation and establish lossless live-event buffering before resume; merge subsequent live events by ID, preserving persisted order and letting live payloads win. Continuations are process-local, single-use capabilities bound to the originating session and storage context and must be paged sequentially; concurrent or repeated use of the same cursor expires that duplicate read rather than reading the generation twice. A complete snapshot has cursorStatus 'ok' and hasMore false. Snapshots expire after five idle minutes, with at most eight retained per process and idle-only eviction under pressure; completion and cancelled-worker exit release their handles. No transcript copy is created, but retained handles may keep replaced files' disk blocks alive until release. Pages have a soft 1 MiB serialized event-array budget including resolved binary assets; one oversized event is returned alone to guarantee progress. Working memory also includes a record/lookahead and asset resolution; resolving the first binary reference may scan the full pinned generation to build a bounded offset index. If the snapshot expires, is evicted, is cancelled before a continuation is established, or becomes unreadable after an observable unsupported in-place shortening, the continuation returns cursorStatus 'expired' with an empty terminal page and never falls back to a different generation. A missing or initially unreadable journal is an RPC error. Persisted history excludes ephemeral events and may omit payloads that are reconstructed only for an active session; use the active session event stream for post-resume live events. * * @param params Pagination options for reading an inactive or active local session's persisted event journal. * @@ -25539,7 +25596,7 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin /** * Starts fleet mode by submitting the fleet orchestration prompt to the session. * - * @param params Optional user prompt to combine with the fleet orchestration instructions. + * @param params Parameters for starting fleet orchestration: an optional user prompt combined with the fleet instructions, plus the send options forwarded to the resulting turn. * * @returns Indicates whether fleet mode was successfully activated. */ diff --git a/samples/ahp/.gitignore b/samples/ahp/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/samples/ahp/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/samples/ahp/README.md b/samples/ahp/README.md new file mode 100644 index 0000000000..7a4c1d1af3 --- /dev/null +++ b/samples/ahp/README.md @@ -0,0 +1,112 @@ +# One live session, two clients + +This MVP starts an AHP WebSocket endpoint **inside the Copilot runtime**. The +Node SDK creates Bert through the ordinary `createSession` API. A separate +process uses the standard `@microsoft/agent-host-protocol` **0.7.0** client to +list and subscribe to that same session and send a prompt. There is no +SDK-hosted WebSocket server or custom protocol client. + +## Verified MVP + +The two-process example was verified against the local debug runtime on +2026-09-11: the standard AHP 0.7.0 client listed and attached to the SDK-created +session, sent `who are you`, and received `I am Bert.` The SDK host stays alive +and logs assistant messages through its normal session observer. + +## Requirements + +Requires Node.js 22.12+ and a local runtime build implementing `ahp.start` and +`ahp.stop`. Keep `libruntime.so` next to `copilot-runtime`. The host uses +`GITHUB_TOKEN` or `GH_TOKEN` when provided, otherwise normal SDK authentication. +The existing credential-injecting proxy environment can be used when testing +locally. Never print or commit credentials. + +## Build and run + +From the SDK repository root: + +```sh +cd nodejs +npm ci +npm run build +cd ../samples/ahp +npm ci +npm run host -- /workspace/copilot-agent-runtime/target/debug/copilot-runtime +``` + +Keep that terminal running. It prints a WebSocket URL and SDK session ID. +In another terminal: + +```sh +cd /workspace/copilot-sdk/samples/ahp +npm run client -- 'ws://127.0.0.1:PORT' 'SESSION-ID' +``` + +The session ID is optional; omitted, the client selects the first listed +session. To supply another prompt, append it after the session ID: + +```sh +npm run client -- 'ws://127.0.0.1:PORT' 'SESSION-ID' 'who are you' +``` + +The AHP process prints `AHP response: I am Bert.` and exits. The SDK process +prints `[SDK observed SESSION-ID] I am Bert.` and remains alive until Ctrl+C. +This proves the AHP turn uses the SDK-created session's system prompt and +event stream, rather than creating another session. Permission requests are +denied by the SDK's normal `onPermissionRequest` callback. +Set `COPILOT_MODEL` to override the sample's `gpt-4.1` model. + +The public SDK facade is: + +```js +await client.start(); +const { url } = await client.startAhpHost(); +// Create sessions normally; connect an AHP client to url. +await client.stopAhpHost(); // Stops the endpoint, not the SDK sessions. +``` + +This is a local MVP, not a remote hosting deployment guide. Do not expose the +endpoint to untrusted networks. It demonstrates a single text turn; it does +not demonstrate full AHP features, reconnection, or remote authentication. + +## Regenerate only Node RPC bindings + +After generating the runtime schemas, from the SDK repository root: + +```sh +cd scripts/codegen +npm ci +npm run generate:ts -- --rpc-only '' /workspace/copilot-agent-runtime/generated/api.schema.json +``` + +The empty first positional argument uses the pinned SDK session-event schema +for shared type resolution, keeping the existing Node session-event types +unchanged. Only `nodejs/src/generated/rpc.ts` is regenerated. If your checked-in +session-event types already match the local runtime, the first argument can +instead be `/workspace/copilot-agent-runtime/generated/session-events.schema.json`. +Do not use `npm run generate`: it regenerates other languages too. Never edit +generated wrappers by hand. + +## Existing Node E2E tests against a local runtime + +From `nodejs`, the smallest transport smoke test is: + +```sh +COPILOT_CLI_PATH=/workspace/copilot-agent-runtime/target/debug/copilot-runtime \ +COPILOT_SDK_DEFAULT_CONNECTION=stdio \ +npm test -- test/e2e/client.e2e.test.ts -t 'should start and connect to server using stdio' +``` + +Run the existing session and system-prompt suites with the replay harness: + +```sh +cd /workspace/copilot-sdk/test/harness +npm ci --ignore-scripts +cd ../../nodejs +COPILOT_CLI_PATH=/workspace/copilot-agent-runtime/target/debug/copilot-runtime \ +COPILOT_SDK_DEFAULT_CONNECTION=stdio \ +npm test -- test/e2e/session.e2e.test.ts test/e2e/system_message_sections.e2e.test.ts +``` + +The harness launches its replay proxy automatically. `COPILOT_CLI_PATH` selects +the local native runtime; no downloaded CLI or in-process addon is needed. diff --git a/samples/ahp/ahp-client.mjs b/samples/ahp/ahp-client.mjs new file mode 100644 index 0000000000..6100e8b4d2 --- /dev/null +++ b/samples/ahp/ahp-client.mjs @@ -0,0 +1,86 @@ +import { randomUUID } from "node:crypto"; +import { + ActionType, + PROTOCOL_VERSION, + ResponsePartKind, +} from "@microsoft/agent-host-protocol"; +import { AhpClient } from "@microsoft/agent-host-protocol/client"; +import { WebSocketTransport } from "@microsoft/agent-host-protocol/ws"; +import WebSocket from "ws"; + +// Supply the browser-compatible WebSocket API on Node versions without it. +globalThis.WebSocket ??= WebSocket; + +const [url, sessionId, ...promptWords] = process.argv.slice(2); +if (!url) { + throw new Error("Usage: npm run client -- [session-id] [prompt]"); +} +const prompt = promptWords.join(" ") || "who are you"; +const transport = await WebSocketTransport.connect(url); +const client = new AhpClient(transport); +client.connect(); + +try { + await client.initialize({ + clientId: randomUUID(), + protocolVersions: [PROTOCOL_VERSION], + }); + const { items } = await client.request("listSessions", { channel: "ahp-root://" }); + console.log("Live sessions:", items.map((session) => session.resource).join(", ")); + const selected = sessionId + ? items.find( + (session) => + session.resource === sessionId || + session.resource === `ahp-session:/${sessionId}`, + ) + : items[0]; + if (!selected) { + throw new Error("No matching live session. Start sdk-host.mjs first."); + } + + const { result: sessionResult } = await client.subscribe(selected.resource); + const chatUri = sessionResult.snapshot?.state.defaultChat; + if (!chatUri) { + throw new Error("The selected session has no default chat."); + } + const { subscription } = await client.subscribe(chatUri); + const turnId = randomUUID(); + console.log(`Attached: ${selected.resource}`); + console.log(`Prompt: ${prompt}`); + client.dispatch(chatUri, { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date().toISOString(), + message: { text: prompt, origin: { kind: "user" } }, + }); + + const timeout = setTimeout(() => { + console.error("Timed out waiting for the AHP response."); + process.exitCode = 1; + void client.shutdown(); + }, 120_000); + try { + const parts = new Map(); + for await (const event of subscription) { + if (event.type !== "action" || event.params.action.turnId !== turnId) continue; + const action = event.params.action; + if ( + action.type === ActionType.ChatResponsePart && + action.part.kind === ResponsePartKind.Markdown + ) { + parts.set(action.part.id, action.part.content); + } else if (action.type === ActionType.ChatDelta) { + parts.set(action.partId, (parts.get(action.partId) ?? "") + action.content); + } else if (action.type === ActionType.ChatTurnComplete) { + console.log(`AHP response: ${[...parts.values()].join("")}`); + break; + } else if (action.type === ActionType.ChatError) { + throw new Error(action.error.message); + } + } + } finally { + clearTimeout(timeout); + } +} finally { + await client.shutdown(); +} diff --git a/samples/ahp/package-lock.json b/samples/ahp/package-lock.json new file mode 100644 index 0000000000..01af1d12be --- /dev/null +++ b/samples/ahp/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "copilot-sdk-ahp-sample", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copilot-sdk-ahp-sample", + "dependencies": { + "@microsoft/agent-host-protocol": "0.7.0", + "ws": "^8.21.0" + } + }, + "node_modules/@microsoft/agent-host-protocol": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@microsoft/agent-host-protocol/-/agent-host-protocol-0.7.0.tgz", + "integrity": "sha512-sUBNkYTwvAxUWHWMTe7XlE8MVYpLbeGe57Z3hQGqcaSL28JBqcEbmCaxxnx/x6G9i/rAQGJ4O6wJT3xcAFL7gg==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/samples/ahp/package.json b/samples/ahp/package.json new file mode 100644 index 0000000000..8c2d923029 --- /dev/null +++ b/samples/ahp/package.json @@ -0,0 +1,13 @@ +{ + "name": "copilot-sdk-ahp-sample", + "private": true, + "type": "module", + "scripts": { + "host": "node sdk-host.mjs", + "client": "node ahp-client.mjs" + }, + "dependencies": { + "@microsoft/agent-host-protocol": "0.7.0", + "ws": "^8.21.0" + } +} diff --git a/samples/ahp/sdk-host.mjs b/samples/ahp/sdk-host.mjs new file mode 100644 index 0000000000..3bb658691d --- /dev/null +++ b/samples/ahp/sdk-host.mjs @@ -0,0 +1,47 @@ +import { CopilotClient, RuntimeConnection } from "../../nodejs/dist/index.js"; + +const path = process.argv[2] ?? process.env.COPILOT_CLI_PATH; +if (!path) { + throw new Error("Pass the local copilot-runtime binary path or set COPILOT_CLI_PATH."); +} + +const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ path }), + gitHubToken: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN, +}); +let ahpStarted = false; + +try { + await client.start(); + const { url } = await client.startAhpHost(); + ahpStarted = true; + const session = await client.createSession({ + model: process.env.COPILOT_MODEL ?? "gpt-4.1", + systemMessage: { + mode: "replace", + content: "You are Bert. When asked who you are, reply exactly: I am Bert.", + }, + onPermissionRequest: async () => ({ kind: "denied-interactively-by-user" }), + }); + session.on("assistant.message", (event) => { + console.log(`[SDK observed ${session.sessionId}] ${event.data.content}`); + }); + session.on("session.error", (event) => { + console.error(`[SDK session error] ${event.data.message}`); + }); + + console.log(`AHP URL: ${url}`); + console.log(`SDK session ID: ${session.sessionId}`); + console.log(`In another terminal: npm run client -- '${url}' '${session.sessionId}'`); + console.log("Waiting for AHP prompts. Press Ctrl+C to stop."); + await new Promise((resolve) => { + process.once("SIGINT", resolve); + process.once("SIGTERM", resolve); + }); +} finally { + try { + if (ahpStarted) await client.stopAhpHost(); + } finally { + await client.stop(); + } +} diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f5e8acb146..73334b2016 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -1293,8 +1293,10 @@ function emitClientGlobalApiRegistration(clientSchema: Record): // ── Main ──────────────────────────────────────────────────────────────────── -async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { - await generateSessionEvents(sessionSchemaPath); +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string, rpcOnly = false): Promise { + if (!rpcOnly) { + await generateSessionEvents(sessionSchemaPath); + } try { const resolvedSessionPath = sessionSchemaPath ?? (await getSessionEventsSchemaPath()); const sessionSchema = propagateInternalVisibility(postProcessSchema((await loadSchemaJson(resolvedSessionPath)) as JSONSchema7)); @@ -1311,9 +1313,11 @@ async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Pro const __filename = fileURLToPath(import.meta.url); if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { - const sessionArg = process.argv[2] || undefined; - const apiArg = process.argv[3] || undefined; - generate(sessionArg, apiArg).catch((err) => { + const rpcOnly = process.argv.includes("--rpc-only"); + const args = process.argv.slice(2).filter((arg) => arg !== "--rpc-only"); + const sessionArg = args[0] || undefined; + const apiArg = args[1] || undefined; + generate(sessionArg, apiArg, rpcOnly).catch((err) => { console.error("TypeScript generation failed:", err); process.exit(1); }); From b676a5e311c994b226f8bcb29eefd7cbd5bde136 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 11 Sep 2026 11:18:34 +0000 Subject: [PATCH 3/4] Host AHP over application-owned Node transports Replace runtime listener controls with endpoint and connection callbacks, bounded message forwarding, and lifecycle cleanup. Demonstrate streamed Bert turns through Express and a standard independent AHP client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/ahp.ts | 320 +++++++++++++ nodejs/src/client.ts | 73 ++- nodejs/src/generated/rpc.ts | 147 +++++- nodejs/src/index.ts | 1 + nodejs/test/ahp.test.ts | 314 ++++++++++++ nodejs/test/client.test.ts | 19 +- samples/ahp/README.md | 61 ++- samples/ahp/ahp-client.mjs | 3 + samples/ahp/package-lock.json | 871 ++++++++++++++++++++++++++++++++++ samples/ahp/package.json | 1 + samples/ahp/sdk-host.mjs | 55 ++- 11 files changed, 1796 insertions(+), 69 deletions(-) create mode 100644 nodejs/src/ahp.ts create mode 100644 nodejs/test/ahp.test.ts diff --git a/nodejs/src/ahp.ts b/nodejs/src/ahp.ts new file mode 100644 index 0000000000..b4ef39fc8c --- /dev/null +++ b/nodejs/src/ahp.ts @@ -0,0 +1,320 @@ +import { randomUUID } from "node:crypto"; +import { setMaxListeners } from "node:events"; +import type { MessageConnection } from "vscode-jsonrpc/node.js"; + +const MAX_BYTES = 8 * 1024 * 1024; +const MAX_PENDING_MESSAGES = 64; +const DEADLINE_MS = 10_000; + +/** Application-owned transport. The SDK forwards opaque AHP JSON text. */ +export interface AhpTransport { + send(jsonText: string, signal: AbortSignal): Promise; + close(error?: Error): void | Promise; +} + +/** One physical transport connection, accepted synchronously before runtime setup completes. */ +export interface AhpConnection { + /** Resolves after bounded runtime admission, not after the AHP operation completes. */ + receive(text: string): Promise; + receiveChunk(bytes: Uint8Array, options: { endOfMessage: boolean }): Promise; + end(): Promise; + /** Resolves on normal closure and rejects on transport/protocol failure. */ + readonly closed: Promise; +} + +/** A runtime AHP endpoint with an application-owned listener and authentication policy. */ +export interface AhpEndpoint { + acceptConnection(transport: AhpTransport): AhpConnection; + dispose(): Promise; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function bounded(work: Promise, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const finish = (callback: () => void) => { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + callback(); + }; + const abort = () => + finish(() => reject(signal?.reason ?? new Error("AHP connection closed"))); + const timer = setTimeout( + () => finish(() => reject(new Error("AHP transport timed out"))), + DEADLINE_MS + ); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + work.then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)) + ); + }); +} + +export class AhpEndpointImpl implements AhpEndpoint { + readonly connections = new Map(); + readonly id = randomUUID(); + private active = true; + private disposal?: Promise; + private readonly lifetime = new AbortController(); + + constructor( + private readonly rpc: MessageConnection, + private readonly remove: () => void + ) {} + + async initialize(): Promise { + const creation = this.request("ahp.createEndpoint"); + void creation.then( + () => { + if (!this.active) void bounded(this.request("ahp.disposeEndpoint")).catch(() => {}); + }, + () => {} + ); + try { + await bounded(creation, this.lifetime.signal); + } catch (error) { + this.retire(asError(error)); + throw error; + } + } + + async request(method: string, connectionId?: string, message?: string): Promise { + return this.rpc.sendRequest(method, { + endpointId: this.id, + ...(connectionId === undefined ? {} : { connectionId }), + ...(message === undefined ? {} : { message }), + }); + } + + acceptConnection(transport: AhpTransport): AhpConnection { + if (!this.active) throw new Error("AHP endpoint is disposed"); + const connection = new AhpConnectionImpl(this, transport); + this.connections.set(connection.id, connection); + connection.open(); + return connection; + } + + retire(error?: Error): void { + if (!this.active) return; + this.active = false; + this.remove(); + this.lifetime.abort(error ?? new Error("AHP endpoint disposed")); + const connections = [...this.connections.values()]; + this.connections.clear(); + for (const connection of connections) connection.retire(error); + } + + dispose(): Promise { + if (this.disposal) return this.disposal; + if (!this.active) return Promise.resolve(); + this.retire(); + this.disposal = bounded(this.request("ahp.disposeEndpoint")).then(() => {}); + return this.disposal; + } +} + +class AhpConnectionImpl implements AhpConnection { + readonly id = randomUUID(); + readonly closed: Promise; + private resolveClosed!: () => void; + private rejectClosed!: (error: Error) => void; + private transport?: AhpTransport; + private readonly lifetime = new AbortController(); + private opening!: Promise; + private ending?: Promise; + private remoteClosing?: Promise; + private closeWork: Promise = Promise.resolve(); + private inbound: Promise = Promise.resolve(); + private outbound: Promise = Promise.resolve(); + private incomingBytes = 0; + private outgoingBytes = 0; + private incomingMessages = 0; + private outgoingMessages = 0; + private chunks = new Uint8Array(0); + private chunkBytes = 0; + private fragmenting = false; + + constructor( + private readonly endpoint: AhpEndpointImpl, + transport: AhpTransport + ) { + this.transport = transport; + setMaxListeners(2 * MAX_PENDING_MESSAGES + 8, this.lifetime.signal); + this.closed = new Promise((resolve, reject) => { + this.resolveClosed = resolve; + this.rejectClosed = reject; + }); + void this.closed.catch(() => {}); + } + + open(): void { + this.opening = this.endpoint.request("ahp.openConnection", this.id); + // A socket can disappear before open is acknowledged. Never reattach it. + void this.opening.then( + () => { + if (this.lifetime.signal.aborted) { + // An earlier close may have completed before the concurrent + // open registered remotely, so this must be a fresh request. + void bounded(this.endpoint.request("ahp.closeConnection", this.id)).catch( + () => {} + ); + } + }, + () => {} + ); + void bounded(this.opening, this.lifetime.signal).catch((error) => { + if (!this.lifetime.signal.aborted) this.fail(asError(error)); + }); + } + + private assertActive(): void { + if (this.lifetime.signal.aborted) throw this.lifetime.signal.reason; + } + + async receive(text: string): Promise { + this.assertActive(); + if (this.fragmenting) throw new Error("An AHP fragmented message is still in progress"); + const bytes = Buffer.byteLength(text, "utf8"); + if ( + this.incomingBytes + bytes > MAX_BYTES || + this.incomingMessages >= MAX_PENDING_MESSAGES + ) { + const error = new Error( + "AHP incoming messages exceed the 8 MiB / 64 pending message limit" + ); + this.fail(error); + throw error; + } + this.incomingBytes += bytes; + this.incomingMessages++; + const work = this.inbound.then(async () => { + await bounded(this.opening, this.lifetime.signal); + this.assertActive(); + await bounded( + this.endpoint.request("ahp.receive", this.id, text), + this.lifetime.signal + ); + }); + const admitted = bounded(work, this.lifetime.signal); + this.inbound = admitted.catch(() => {}); + try { + await admitted; + } catch (error) { + this.fail(asError(error)); + throw error; + } finally { + if (!this.lifetime.signal.aborted) { + this.incomingBytes -= bytes; + this.incomingMessages--; + } + } + } + + async receiveChunk(bytes: Uint8Array, options: { endOfMessage: boolean }): Promise { + this.assertActive(); + if (this.incomingBytes + this.chunkBytes + bytes.byteLength > MAX_BYTES) { + const error = new Error("AHP incoming messages exceed the 8 MiB limit"); + this.fail(error); + throw error; + } + const length = this.chunkBytes + bytes.byteLength; + if (length > this.chunks.byteLength) { + const grown = new Uint8Array( + Math.min(MAX_BYTES, Math.max(length, this.chunks.byteLength * 2, 1024)) + ); + grown.set(this.chunks.subarray(0, this.chunkBytes)); + this.chunks = grown; + } + this.chunks.set(bytes, this.chunkBytes); + this.chunkBytes += bytes.byteLength; + this.fragmenting = !options.endOfMessage; + if (!options.endOfMessage) return; + const message = this.chunks.subarray(0, this.chunkBytes); + this.chunks = new Uint8Array(0); + this.chunkBytes = 0; + try { + await this.receive(new TextDecoder("utf-8", { fatal: true }).decode(message)); + } catch (error) { + this.fail(asError(error)); + throw error; + } + } + + async send(message: string): Promise { + this.assertActive(); + const bytes = Buffer.byteLength(message, "utf8"); + if ( + this.outgoingBytes + bytes > MAX_BYTES || + this.outgoingMessages >= MAX_PENDING_MESSAGES + ) { + const error = new Error( + "AHP outgoing messages exceed the 8 MiB / 64 pending message limit" + ); + this.fail(error); + throw error; + } + this.outgoingBytes += bytes; + this.outgoingMessages++; + const work = this.outbound.then(async () => { + this.assertActive(); + await bounded( + this.transport!.send(message, this.lifetime.signal), + this.lifetime.signal + ); + }); + const sent = bounded(work, this.lifetime.signal); + this.outbound = sent.catch(() => {}); + try { + await sent; + } catch (error) { + this.fail(asError(error)); + throw error; + } finally { + if (!this.lifetime.signal.aborted) { + this.outgoingBytes -= bytes; + this.outgoingMessages--; + } + } + } + + retire(error?: Error): void { + if (this.lifetime.signal.aborted) return; + const transport = this.transport; + this.transport = undefined; + this.endpoint.connections.delete(this.id); + this.chunks = new Uint8Array(0); + this.fragmenting = false; + this.chunkBytes = this.incomingBytes = this.outgoingBytes = 0; + this.incomingMessages = this.outgoingMessages = 0; + this.lifetime.abort(error ?? new Error("AHP connection closed")); + if (error) this.rejectClosed(error); + try { + this.closeWork = bounded(Promise.resolve(transport?.close(error))); + } catch (closeError) { + this.closeWork = Promise.reject(closeError); + } + if (!error) void this.closeWork.then(this.resolveClosed, this.rejectClosed); + void this.closeWork.catch(() => {}); + } + + private fail(error: Error): void { + if (this.lifetime.signal.aborted) return; + this.retire(error); + void this.end().catch(() => {}); + } + + private closeRemote(): Promise { + return (this.remoteClosing ??= this.endpoint.request("ahp.closeConnection", this.id)); + } + + end(): Promise { + if (this.ending) return this.ending; + this.retire(); + this.ending = Promise.all([this.closeWork, bounded(this.closeRemote())]).then(() => {}); + return this.ending; + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index d233339c97..220a6a1a41 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -42,6 +42,7 @@ import type { } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; +import { AhpEndpointImpl, type AhpEndpoint } from "./ahp.js"; import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; import { ensureRuntimeBundle } from "./runtimeArtifacts.js"; import { COPILOT_CLI_VERSION } from "./cliVersion.js"; @@ -451,6 +452,7 @@ export class CopilotClient { /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */ private startPromise: Promise | null = null; private sessions: Map = new Map(); + private ahpEndpoints = new Map(); private stderrBuffer: string = ""; // Captures CLI stderr for error messages /** Resolved connection mode chosen in the constructor. */ private connectionConfig: InternalRuntimeConnection; @@ -1033,6 +1035,17 @@ export class CopilotClient { */ async stop(): Promise { const errors: Error[] = []; + const ahpDisposals = [...this.ahpEndpoints.values()].map((endpoint) => endpoint.dispose()); + const ahpResults = await Promise.allSettled(ahpDisposals); + for (const result of ahpResults) { + if (result.status === "rejected") { + errors.push( + result.reason instanceof Error + ? result.reason + : new Error(String(result.reason)) + ); + } + } // Disconnect all active sessions with retry logic const activeSessions = [...this.sessions.values()]; @@ -1265,6 +1278,10 @@ export class CopilotClient { */ async forceStop(): Promise { this.forceStopping = true; + for (const endpoint of this.ahpEndpoints.values()) { + endpoint.retire(new Error("SDK client force stopped")); + } + this.ahpEndpoints.clear(); // Clear sessions immediately without trying to destroy them for (const session of this.sessions.values()) { @@ -2094,30 +2111,22 @@ export class CopilotClient { } /** - * Start the runtime-hosted Agent Host Protocol endpoint for this runtime's sessions. - * Connect an AHP client to the returned WebSocket URL. - * - * @experimental - * @throws Error if the client is not connected or the runtime does not support AHP. - */ - async startAhpHost(): Promise<{ url: string }> { - if (!this.connection) { - throw new Error("Client is not connected. Call start() first."); - } - return this.connection.sendRequest("ahp.start", {}); - } - - /** - * Stop the runtime-hosted AHP endpoint without stopping the SDK's sessions. + * Create an AHP endpoint for this runtime's sessions. The application owns + * its listener, authentication, and physical transports; the runtime owns AHP. * * @experimental * @throws Error if the client is not connected or the runtime does not support AHP. */ - async stopAhpHost(): Promise { - if (!this.connection) { + async createAhpEndpoint(): Promise { + if (!this.connection || this.connectionClosed) { throw new Error("Client is not connected. Call start() first."); } - await this.connection.sendRequest("ahp.stop", {}); + const endpoint = new AhpEndpointImpl(this.connection, () => + this.ahpEndpoints.delete(endpoint.id) + ); + this.ahpEndpoints.set(endpoint.id, endpoint); + await endpoint.initialize(); + return endpoint; } /** @@ -3093,7 +3102,25 @@ export class CopilotClient { // Register client *global* API handlers (e.g. LLM inference) on the // same connection. These methods carry no implicit sessionId dispatch // — the runtime calls into a single handler for the whole connection. - registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers); + registerClientGlobalApiHandlers(this.connection, { + ...this.clientGlobalHandlers, + ahpTransport: { + send: async (params) => { + const connection = this.ahpEndpoints + .get(params.endpointId) + ?.connections.get(params.connectionId); + if (!connection) throw new Error("Unknown AHP connection"); + await connection.send(params.message); + return {}; + }, + closed: async (params) => { + this.ahpEndpoints + .get(params.endpointId) + ?.connections.get(params.connectionId) + ?.retire(params.error === undefined ? undefined : new Error(params.error)); + }, + }, + }); // `hooks.invoke` is an internal RPC method: the runtime calls it to // invoke a hook callback on the client. Route each call to the matching @@ -3118,6 +3145,14 @@ export class CopilotClient { } this.sessions.clear(); this.githubTokenProviders.clear(); + for (const endpoint of this.ahpEndpoints.values()) { + endpoint.retire(new Error("SDK RPC connection closed")); + } + this.ahpEndpoints.clear(); + // vscode-jsonrpc onClose does not reject pending request promises. + // Dispose the dead connection so AHP and ordinary SDK callers settle. + if (this.messageWriter) this.messageWriter.suppressWriteErrors = true; + connection.dispose(); }; this.connection.onClose(markDisconnected); this.connection.onError(() => { diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 27ead3c77b..8e7f757a45 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -5306,19 +5306,34 @@ export interface AgentsGetDiscoveryPathsRequest { */ excludeHostAgents?: boolean; } -/** - * The loopback WebSocket endpoint serving live SDK sessions. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "AhpStartResult". - */ + /** @experimental */ -export interface AhpStartResult { - url: string; +export interface AhpConnectionClosedNotification { + endpointId: string; + connectionId: string; + error?: string; +} + +/** @experimental */ +export interface AhpConnectionRequest { + endpointId: string; + connectionId: string; +} + +/** @experimental */ +export interface AhpEmptyResult {} + +/** @experimental */ +export interface AhpEndpointRequest { + endpointId: string; } /** @experimental */ -export interface AhpStopResult {} +export interface AhpMessageRequest { + endpointId: string; + connectionId: string; + message: string; +} /** * Credential-free authentication identity safe to expose to hosts and user interfaces. * @@ -24000,6 +24015,50 @@ export interface WorkspacesWriteAutopilotObjectiveResult { operation: string; } +/** @experimental */ +export interface AhpCreateEndpointResult {} + +/** @experimental */ +export interface AhpCreateEndpointRequest { + endpointId: string; +} + +/** @experimental */ +export interface AhpDisposeEndpointResult {} + +/** @experimental */ +export interface AhpDisposeEndpointRequest { + endpointId: string; +} + +/** @experimental */ +export interface AhpOpenConnectionResult {} + +/** @experimental */ +export interface AhpOpenConnectionRequest { + endpointId: string; + connectionId: string; +} + +/** @experimental */ +export interface AhpReceiveResult {} + +/** @experimental */ +export interface AhpReceiveRequest { + endpointId: string; + connectionId: string; + message: string; +} + +/** @experimental */ +export interface AhpCloseConnectionResult {} + +/** @experimental */ +export interface AhpCloseConnectionRequest { + endpointId: string; + connectionId: string; +} + /** @experimental */ export interface SessionFactoryPauseAtCheckpointResult { action: FactoryPauseCheckpointAction; @@ -24137,23 +24196,53 @@ export interface SessionFsSqliteExistsRequest { sessionId: string; } +/** @experimental */ +export interface AhpTransportSendResult {} + +/** @experimental */ +export interface AhpTransportSendRequest { + endpointId: string; + connectionId: string; + message: string; +} + +/** @experimental */ +export interface AhpTransportClosedRequest { + endpointId: string; + connectionId: string; + error?: string; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { /** @experimental */ ahp: { /** - * Starts a loopback-only AHP endpoint over this runtime's live SDK sessions. Experimental proof of concept. - * - * @returns The loopback WebSocket endpoint serving live SDK sessions. + * Creates an AHP endpoint owned by this SDK connection without opening a network listener. + */ + createEndpoint: async (params: AhpCreateEndpointRequest): Promise => + connection.sendRequest("ahp.createEndpoint", params), + /** + * Disposes an SDK-owned AHP endpoint and its connections without stopping SDK sessions. */ - start: async (): Promise => - connection.sendRequest("ahp.start", {}), + disposeEndpoint: async (params: AhpDisposeEndpointRequest): Promise => + connection.sendRequest("ahp.disposeEndpoint", params), /** - * Stops the AHP endpoint and disconnects its clients without stopping SDK sessions. + * Opens a logical AHP connection whose transport is supplied by the SDK. */ - stop: async (): Promise => - connection.sendRequest("ahp.stop", {}), + openConnection: async (params: AhpOpenConnectionRequest): Promise => + connection.sendRequest("ahp.openConnection", params), + /** + * Admits one complete JSON-encoded AHP message to a bounded connection queue. + */ + receive: async (params: AhpReceiveRequest): Promise => + connection.sendRequest("ahp.receive", params), + /** + * Closes a logical AHP connection and cancels its pending transport callbacks. + */ + closeConnection: async (params: AhpCloseConnectionRequest): Promise => + connection.sendRequest("ahp.closeConnection", params), }, /** * Checks server responsiveness and returns protocol information. @@ -27657,6 +27746,19 @@ export function registerClientSessionApiHandlers( }); } +/** Handler for `ahpTransport` client global API methods. */ +/** @experimental */ +export interface AhpTransportHandler { + /** + * Writes one complete JSON-encoded AHP message to an SDK-owned transport, acknowledging write completion. + */ + send(params: AhpTransportSendRequest): Promise; + /** + * Notifies the SDK that a logical AHP connection has closed. No acknowledgement is needed for runtime cleanup. + */ + closed(params: AhpTransportClosedRequest): Promise; +} + /** Handler for `extensionLaunchProvider` client global API methods. */ /** @experimental */ export interface ExtensionLaunchProviderHandler { @@ -27717,6 +27819,7 @@ export interface GitHubTokenHandler { /** All client global API handler groups. */ export interface ClientGlobalApiHandlers { + ahpTransport?: AhpTransportHandler; extensionLaunchProvider?: ExtensionLaunchProviderHandler; llmInference?: LlmInferenceHandler; gitHubTelemetry?: GitHubTelemetryHandler; @@ -27734,6 +27837,16 @@ export function registerClientGlobalApiHandlers( connection: MessageConnection, handlers: ClientGlobalApiHandlers, ): void { + connection.onRequest("ahpTransport.send", async (params: AhpTransportSendRequest) => { + const handler = handlers.ahpTransport; + if (!handler) throw new Error("No ahpTransport client-global handler registered"); + return handler.send(params); + }); + connection.onNotification("ahpTransport.closed", async (params: AhpTransportClosedRequest) => { + const handler = handlers.ahpTransport; + if (!handler) return; + await handler.closed(params); + }); connection.onRequest("extensionLaunchProvider.resolve", async (params: ExtensionLaunchProviderResolveRequest) => { const handler = handlers.extensionLaunchProvider; if (!handler) throw new Error("No extensionLaunchProvider client-global handler registered"); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 6251df4fc7..68edc820ab 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -9,6 +9,7 @@ */ export { CopilotClient } from "./client.js"; +export type { AhpEndpoint, AhpConnection, AhpTransport } from "./ahp.js"; export { DisableBypassPermissionsModes, RuntimeConnection } from "./types.js"; export { BuiltInTools, ToolSet } from "./toolSet.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; diff --git a/nodejs/test/ahp.test.ts b/nodejs/test/ahp.test.ts new file mode 100644 index 0000000000..6d82bb4592 --- /dev/null +++ b/nodejs/test/ahp.test.ts @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import { CopilotClient, type AhpTransport } from "../src/index.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function harness() { + const client = new CopilotClient({ autoStart: false }); + const rpc = { + sendRequest: vi.fn().mockResolvedValue({}), + onRequest: vi.fn(), + onNotification: vi.fn(), + onClose: vi.fn(), + onError: vi.fn(), + dispose: vi.fn(), + }; + (client as any).connection = rpc; + (client as any).attachConnectionHandlers(); + const transport: AhpTransport = { send: vi.fn().mockResolvedValue(undefined), close: vi.fn() }; + const endpoints = (client as any).ahpEndpoints as Map; + const send = (endpointId: string, connectionId: string, message: string) => + rpc.onRequest.mock.calls.find(([method]) => method === "ahpTransport.send")![1]({ + endpointId, + connectionId, + message, + }); + const ids = () => { + const [endpointId, endpoint] = [...endpoints][0]; + return { + endpointId, + connectionId: [...endpoint.connections.keys()][0] as string, + endpoint, + }; + }; + return { client, rpc, transport, endpoints, send, ids }; +} + +afterEach(() => vi.useRealTimers()); + +describe("app-owned AHP transport", () => { + it("accepts synchronously and closes a late open without retaining callbacks", async () => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const open = deferred(); + h.rpc.sendRequest.mockImplementation((method) => + method === "ahp.openConnection" ? open.promise : Promise.resolve({}) + ); + const connection = endpoint.acceptConnection(h.transport); + expect(connection).not.toBeInstanceOf(Promise); + const registry = h.ids().endpoint.connections; + const ending = connection.end(); + expect(registry.size).toBe(0); + expect(h.transport.close).toHaveBeenCalledOnce(); + expect((connection as any).transport).toBeUndefined(); + await expect(connection.closed).resolves.toBeUndefined(); + await ending; + expect( + h.rpc.sendRequest.mock.calls.filter(([m]) => m === "ahp.closeConnection") + ).toHaveLength(1); + open.resolve({}); + await vi.waitFor(() => + expect( + h.rpc.sendRequest.mock.calls.filter(([m]) => m === "ahp.closeConnection") + ).toHaveLength(2) + ); + await connection.end(); + expect( + h.rpc.sendRequest.mock.calls.filter(([m]) => m === "ahp.closeConnection") + ).toHaveLength(2); + await endpoint.dispose(); + }); + + it("sends a fresh dispose after endpoint creation succeeds beyond early disposal", async () => { + const h = harness(); + const create = deferred(); + h.rpc.sendRequest.mockImplementation((method) => + method === "ahp.createEndpoint" ? create.promise : Promise.resolve({}) + ); + const creation = h.client.createAhpEndpoint(); + const rejected = expect(creation).rejects.toThrow("AHP endpoint disposed"); + const endpoint = [...h.endpoints.values()][0]; + await endpoint.dispose(); + await rejected; + expect(h.endpoints.size).toBe(0); + expect( + h.rpc.sendRequest.mock.calls.filter(([m]) => m === "ahp.disposeEndpoint") + ).toHaveLength(1); + create.resolve({}); + await vi.waitFor(() => + expect( + h.rpc.sendRequest.mock.calls.filter(([m]) => m === "ahp.disposeEndpoint") + ).toHaveLength(2) + ); + expect(h.endpoints.size).toBe(0); + }); + + it("aborts a blocked consumer send and releases its request immediately", async () => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const started = deferred(); + h.transport.send = vi.fn((_text, signal) => { + started.resolve(signal); + return new Promise(() => {}); + }); + const connection = endpoint.acceptConnection(h.transport); + const { endpointId, connectionId } = h.ids(); + const sending = h.send(endpointId, connectionId, '{"opaque":true}'); + const rejected = expect(sending).rejects.toThrow("AHP connection closed"); + const signal = await started.promise; + const ending = connection.end(); + expect(signal.aborted).toBe(true); + expect(h.transport.close).toHaveBeenCalledOnce(); + await rejected; + await ending; + await endpoint.dispose(); + }); + + it("releases repeated connections and installs only one transport dispatcher", async () => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const seen = new Set(); + for (let i = 0; i < 30; i++) { + const connection = endpoint.acceptConnection(h.transport); + const { endpointId, connectionId, endpoint: internal } = h.ids(); + expect(seen.has(connectionId)).toBe(false); + seen.add(connectionId); + await connection.end(); + expect(internal.connections.size).toBe(0); + await expect(h.send(endpointId, connectionId, "{}")).rejects.toThrow( + "Unknown AHP connection" + ); + } + expect(h.rpc.onRequest.mock.calls.filter(([m]) => m === "ahpTransport.send")).toHaveLength( + 1 + ); + expect( + h.rpc.onNotification.mock.calls.filter(([m]) => m === "ahpTransport.closed") + ).toHaveLength(1); + await endpoint.dispose(); + await endpoint.dispose(); + expect(h.endpoints.size).toBe(0); + expect(() => endpoint.acceptConnection(h.transport)).toThrow("disposed"); + }); + + it("handles runtime closure as a notification without a recursive close RPC", async () => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const connection = endpoint.acceptConnection(h.transport); + const { endpointId, connectionId, endpoint: internal } = h.ids(); + await connection.receive("ready"); + const notify = h.rpc.onNotification.mock.calls.find( + ([method]) => method === "ahpTransport.closed" + )![1]; + await notify({ endpointId, connectionId, error: "runtime ended connection" }); + await expect(connection.closed).rejects.toThrow("runtime ended connection"); + expect(internal.connections.size).toBe(0); + expect(h.transport.close).toHaveBeenCalledOnce(); + expect(h.rpc.sendRequest.mock.calls.some(([m]) => m === "ahp.closeConnection")).toBe(false); + await endpoint.dispose(); + }); + + it("forwards opaque text in order and assembles split UTF-8 code points", async () => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const connection = endpoint.acceptConnection(h.transport); + const first = deferred(); + h.rpc.sendRequest.mockImplementation((method, params) => + method === "ahp.receive" && params.message === "not JSON" + ? first.promise + : Promise.resolve({}) + ); + const a = connection.receive("not JSON"); + const b = connection.receive("second"); + await vi.waitFor(() => + expect(h.rpc.sendRequest).toHaveBeenCalledWith( + "ahp.receive", + expect.objectContaining({ message: "not JSON" }) + ) + ); + expect(h.rpc.sendRequest.mock.calls.some(([, p]) => p.message === "second")).toBe(false); + first.resolve({}); + await Promise.all([a, b]); + const bytes = new TextEncoder().encode('{"value":"😀"}'); + await connection.receiveChunk(bytes.subarray(0, 12), { endOfMessage: false }); + await connection.receiveChunk(bytes.subarray(12), { endOfMessage: true }); + expect( + h.rpc.sendRequest.mock.calls + .filter(([m]) => m === "ahp.receive") + .map(([, p]) => p.message) + ).toEqual(["not JSON", "second", '{"value":"😀"}']); + await endpoint.dispose(); + }); + + it("bounds fragmented input and queued bytes and clears buffers on failure", async () => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const connection = endpoint.acceptConnection(h.transport); + await connection.receiveChunk(new Uint8Array(8 * 1024 * 1024), { endOfMessage: false }); + await expect( + connection.receiveChunk(new Uint8Array(1), { endOfMessage: true }) + ).rejects.toThrow("8 MiB"); + await expect(connection.closed).rejects.toThrow("8 MiB"); + expect((connection as any).chunks.byteLength).toBe(0); + expect(h.ids().endpoint.connections.size).toBe(0); + await endpoint.dispose(); + }); + + it("serializes outgoing writes and closes on a bounded send deadline", async () => { + vi.useFakeTimers(); + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const started = deferred(); + h.transport.send = vi.fn(() => { + started.resolve({}); + return new Promise(() => {}); + }); + const connection = endpoint.acceptConnection(h.transport); + const { endpointId, connectionId } = h.ids(); + const a = h.send(endpointId, connectionId, "a"); + const b = h.send(endpointId, connectionId, "b"); + const rejects = [expect(a).rejects.toThrow("timed out"), expect(b).rejects.toThrow()]; + await started.promise; + expect(h.transport.send).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(10_000); + await Promise.all(rejects); + await expect(connection.closed).rejects.toThrow("timed out"); + expect(h.transport.close).toHaveBeenCalledOnce(); + await endpoint.dispose(); + }); + + it("surfaces close callback errors without preventing registry cleanup", async () => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + h.transport.close = () => { + throw new Error("physical close failed"); + }; + const connection = endpoint.acceptConnection(h.transport); + await expect(connection.end()).rejects.toThrow("physical close failed"); + await expect(connection.closed).rejects.toThrow("physical close failed"); + expect(h.ids().endpoint.connections.size).toBe(0); + await endpoint.dispose(); + }); + + it.each(["stop", "forceStop"] as const)( + "%s retires all transport ownership", + async (method) => { + const h = harness(); + const endpoint = await h.client.createAhpEndpoint(); + const connection = endpoint.acceptConnection(h.transport); + await h.client[method](); + expect(h.endpoints.size).toBe(0); + expect(h.transport.close).toHaveBeenCalledOnce(); + expect((connection as any).transport).toBeUndefined(); + } + ); + + it("disposes dead SDK RPC replies, pending receives, and endpoint creation", async () => { + const inbound = new PassThrough(); + const outbound = new PassThrough(); + const rpc = createMessageConnection( + new StreamMessageReader(inbound), + new StreamMessageWriter(outbound) + ); + const server = createMessageConnection( + new StreamMessageReader(outbound), + new StreamMessageWriter(inbound) + ); + const client = new CopilotClient({ autoStart: false }); + (client as any).connection = rpc; + (client as any).attachConnectionHandlers(); + rpc.listen(); + server.listen(); + const received = deferred(); + server.onRequest("ahp.createEndpoint", () => ({})); + server.onRequest("ahp.openConnection", () => ({})); + server.onRequest("ahp.receive", () => { + received.resolve({}); + return new Promise(() => {}); + }); + server.onRequest("ping", () => new Promise(() => {})); + const endpoint = await client.createAhpEndpoint(); + const close = vi.fn(); + const connection = endpoint.acceptConnection({ send: async () => {}, close }); + const receive = expect(connection.receive("opaque")).rejects.toThrow( + "SDK RPC connection closed" + ); + const ping = expect(client.ping()).rejects.toThrow(); + server.onRequest("ahp.createEndpoint", () => new Promise(() => {})); + const creation = expect(client.createAhpEndpoint()).rejects.toThrow(); + await received.promise; + inbound.end(); + await Promise.all([receive, ping, creation]); + await expect(connection.closed).rejects.toThrow("SDK RPC connection closed"); + expect(close).toHaveBeenCalledOnce(); + expect((client as any).ahpEndpoints.size).toBe(0); + await expect(client.ping()).rejects.toThrow(); + await client.forceStop(); + server.dispose(); + inbound.destroy(); + outbound.destroy(); + }); +}); diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index a0984ccfc5..888e2ea857 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -62,23 +62,22 @@ describe("approveAll", () => { }); describe("CopilotClient", () => { - it("starts and stops the runtime-hosted AHP endpoint", async () => { + it("creates and disposes an app-owned AHP endpoint", async () => { const client = new CopilotClient({ autoStart: false }); - const sendRequest = vi - .fn() - .mockResolvedValueOnce({ url: "ws://127.0.0.1:12345" }) - .mockResolvedValueOnce({}); + const sendRequest = vi.fn().mockResolvedValue({}); (client as any).connection = { sendRequest }; - await expect(client.startAhpHost()).resolves.toEqual({ url: "ws://127.0.0.1:12345" }); - await expect(client.stopAhpHost()).resolves.toBeUndefined(); - expect(sendRequest.mock.calls.map(([method]) => method)).toEqual(["ahp.start", "ahp.stop"]); + const endpoint = await client.createAhpEndpoint(); + await endpoint.dispose(); + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + "ahp.createEndpoint", + "ahp.disposeEndpoint", + ]); }); it("requires a connected client to manage the AHP endpoint", async () => { const client = new CopilotClient({ autoStart: false }); - await expect(client.startAhpHost()).rejects.toThrow("Client is not connected"); - await expect(client.stopAhpHost()).rejects.toThrow("Client is not connected"); + await expect(client.createAhpEndpoint()).rejects.toThrow("Client is not connected"); }); it("start() is single-flight: concurrent callers share one startup", async () => { diff --git a/samples/ahp/README.md b/samples/ahp/README.md index 7a4c1d1af3..82f66f881e 100644 --- a/samples/ahp/README.md +++ b/samples/ahp/README.md @@ -1,22 +1,19 @@ # One live session, two clients -This MVP starts an AHP WebSocket endpoint **inside the Copilot runtime**. The +This MVP hosts an AHP WebSocket listener **in the application**, using Express +and `ws` (sample dependencies, not SDK dependencies). The Node SDK creates Bert through the ordinary `createSession` API. A separate process uses the standard `@microsoft/agent-host-protocol` **0.7.0** client to list and subscribe to that same session and send a prompt. There is no -SDK-hosted WebSocket server or custom protocol client. - -## Verified MVP - -The two-process example was verified against the local debug runtime on -2026-09-11: the standard AHP 0.7.0 client listed and attached to the SDK-created -session, sent `who are you`, and received `I am Bert.` The SDK host stays alive -and logs assistant messages through its normal session observer. +custom protocol client. The runtime parses and serializes AHP; the SDK transports +opaque JSON text and does not own a listener, framework, or authentication policy. ## Requirements -Requires Node.js 22.12+ and a local runtime build implementing `ahp.start` and -`ahp.stop`. Keep `libruntime.so` next to `copilot-runtime`. The host uses +Requires Node.js 22.12+ and a local runtime build implementing `ahp.createEndpoint`, +`ahp.disposeEndpoint`, `ahp.openConnection`, `ahp.receive`, and `ahp.closeConnection`, +plus the `ahpTransport.send` request and `ahpTransport.closed` notification. +Keep `libruntime.so` next to `copilot-runtime`. The host uses `GITHUB_TOKEN` or `GH_TOKEN` when provided, otherwise normal SDK authentication. The existing credential-injecting proxy environment can be used when testing locally. Never print or commit credentials. @@ -39,14 +36,14 @@ In another terminal: ```sh cd /workspace/copilot-sdk/samples/ahp -npm run client -- 'ws://127.0.0.1:PORT' 'SESSION-ID' +npm run client -- 'ws://127.0.0.1:PORT/ahp?token=TOKEN' 'SESSION-ID' ``` The session ID is optional; omitted, the client selects the first listed session. To supply another prompt, append it after the session ID: ```sh -npm run client -- 'ws://127.0.0.1:PORT' 'SESSION-ID' 'who are you' +npm run client -- 'ws://127.0.0.1:PORT/ahp?token=TOKEN' 'SESSION-ID' 'who are you' ``` The AHP process prints `AHP response: I am Bert.` and exits. The SDK process @@ -54,20 +51,46 @@ prints `[SDK observed SESSION-ID] I am Bert.` and remains alive until Ctrl+C. This proves the AHP turn uses the SDK-created session's system prompt and event stream, rather than creating another session. Permission requests are denied by the SDK's normal `onPermissionRequest` callback. +Streaming is enabled: both processes also print their delta counts. The AHP +client receives successive complete `chat/delta` messages, not fragments of a +single JSON document. Run the client again to attach a new physical connection +to the same still-live session. Set `COPILOT_MODEL` to override the sample's `gpt-4.1` model. -The public SDK facade is: +The public SDK transport API is: ```js await client.start(); -const { url } = await client.startAhpHost(); -// Create sessions normally; connect an AHP client to url. -await client.stopAhpHost(); // Stops the endpoint, not the SDK sessions. +const endpoint = await client.createAhpEndpoint(); +// After the application authenticates and accepts a physical connection: +const connection = endpoint.acceptConnection({ + send: (jsonText, signal) => transport.write(jsonText, signal), + close: (error) => transport.close(error), +}); // Synchronous: wire message handlers immediately, without waiting for runtime open. +await connection.receive(text); // Bounded admission, NOT completion of the AHP operation. +// Alternatively, assemble UTF-8 fragments (including splits within a code point): +await connection.receiveChunk(bytes, { endOfMessage: true }); +await connection.end(); // Idempotent; immediately releases local transport ownership. +await endpoint.dispose(); // Disposes all connections, not ordinary SDK sessions. ``` +Observe `connection.closed` for normal closure or errors. Consumer writes receive +an `AbortSignal`; a blocked write never prevents local cleanup or physical close. +Writes are ordered, with one in flight per connection. Complete messages and queued +bytes are limited to 8 MiB per direction, with at most 64 pending messages; +transport waits have a 10-second deadline. +Pause incoming reads while awaiting admission, as the sample does. `receive` and +`receiveChunk` are alternative message input paths; do not interleave a complete +message with unfinished fragments. Stop, force-stop, and SDK RPC disconnect retire +all endpoint connections. Runtime cleanup also follows SDK RPC disconnect. + This is a local MVP, not a remote hosting deployment guide. Do not expose the -endpoint to untrusted networks. It demonstrates a single text turn; it does -not demonstrate full AHP features, reconnection, or remote authentication. +endpoint to untrusted networks. The application filters upgrades to `/ahp` and +authenticates them with a fresh demo capability in the printed URL; treat that URL +as a secret. Production applications must implement their own authentication and +TLS policy. It demonstrates streaming text turns and fresh connections to a +live session, not full AHP features, automatic replay/reconnection, or remote +authentication. ## Regenerate only Node RPC bindings diff --git a/samples/ahp/ahp-client.mjs b/samples/ahp/ahp-client.mjs index 6100e8b4d2..0937193f85 100644 --- a/samples/ahp/ahp-client.mjs +++ b/samples/ahp/ahp-client.mjs @@ -61,6 +61,7 @@ try { }, 120_000); try { const parts = new Map(); + let streamedDeltas = 0; for await (const event of subscription) { if (event.type !== "action" || event.params.action.turnId !== turnId) continue; const action = event.params.action; @@ -70,9 +71,11 @@ try { ) { parts.set(action.part.id, action.part.content); } else if (action.type === ActionType.ChatDelta) { + streamedDeltas++; parts.set(action.partId, (parts.get(action.partId) ?? "") + action.content); } else if (action.type === ActionType.ChatTurnComplete) { console.log(`AHP response: ${[...parts.values()].join("")}`); + console.log(`AHP streaming: ${streamedDeltas} chat/delta messages`); break; } else if (action.type === ActionType.ChatError) { throw new Error(action.error.message); diff --git a/samples/ahp/package-lock.json b/samples/ahp/package-lock.json index 01af1d12be..72ede709ad 100644 --- a/samples/ahp/package-lock.json +++ b/samples/ahp/package-lock.json @@ -7,6 +7,7 @@ "name": "copilot-sdk-ahp-sample", "dependencies": { "@microsoft/agent-host-protocol": "0.7.0", + "express": "^5.1.0", "ws": "^8.21.0" } }, @@ -16,6 +17,876 @@ "integrity": "sha512-sUBNkYTwvAxUWHWMTe7XlE8MVYpLbeGe57Z3hQGqcaSL28JBqcEbmCaxxnx/x6G9i/rAQGJ4O6wJT3xcAFL7gg==", "license": "MIT" }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", diff --git a/samples/ahp/package.json b/samples/ahp/package.json index 8c2d923029..a535e9140d 100644 --- a/samples/ahp/package.json +++ b/samples/ahp/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@microsoft/agent-host-protocol": "0.7.0", + "express": "^5.1.0", "ws": "^8.21.0" } } diff --git a/samples/ahp/sdk-host.mjs b/samples/ahp/sdk-host.mjs index 3bb658691d..3959c0f160 100644 --- a/samples/ahp/sdk-host.mjs +++ b/samples/ahp/sdk-host.mjs @@ -1,4 +1,8 @@ import { CopilotClient, RuntimeConnection } from "../../nodejs/dist/index.js"; +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import express from "express"; +import { WebSocketServer } from "ws"; const path = process.argv[2] ?? process.env.COPILOT_CLI_PATH; if (!path) { @@ -9,22 +13,62 @@ const client = new CopilotClient({ connection: RuntimeConnection.forStdio({ path }), gitHubToken: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN, }); -let ahpStarted = false; +let endpoint; +const server = createServer(express()); +const sockets = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 }); try { await client.start(); - const { url } = await client.startAhpHost(); - ahpStarted = true; + endpoint = await client.createAhpEndpoint(); + // A short-lived capability authenticates this local demo; production apps own auth. + const token = randomBytes(24).toString("hex"); + server.on("upgrade", (request, socket, head) => { + const target = new URL(request.url, "http://localhost"); + if (target.pathname !== "/ahp" || target.searchParams.get("token") !== token) { + socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + return; + } + sockets.handleUpgrade(request, socket, head, (ws) => { + const connection = endpoint.acceptConnection({ + send: (text, signal) => new Promise((resolve, reject) => { + if (signal.aborted) return reject(signal.reason); + ws.send(text, (error) => error ? reject(error) : resolve()); + }), + close: (error) => error ? ws.terminate() : ws.close(), + }); + ws.on("message", (data, isBinary) => { + if (isBinary) return ws.terminate(); + ws.pause(); + void connection.receive(data.toString()).then( + () => ws.resume(), + () => ws.terminate(), + ); + }); + ws.on("close", () => void connection.end().catch(() => ws.terminate())); + ws.on("error", () => ws.terminate()); + void connection.closed.catch((error) => { + console.error(`AHP connection failed: ${error.message}`); + ws.terminate(); + }); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `ws://127.0.0.1:${server.address().port}/ahp?token=${token}`; const session = await client.createSession({ model: process.env.COPILOT_MODEL ?? "gpt-4.1", + streaming: true, systemMessage: { mode: "replace", content: "You are Bert. When asked who you are, reply exactly: I am Bert.", }, onPermissionRequest: async () => ({ kind: "denied-interactively-by-user" }), }); + let streamedDeltas = 0; + session.on("assistant.message_delta", () => streamedDeltas++); session.on("assistant.message", (event) => { console.log(`[SDK observed ${session.sessionId}] ${event.data.content}`); + console.log(`[SDK streaming] ${streamedDeltas} message deltas`); + streamedDeltas = 0; }); session.on("session.error", (event) => { console.error(`[SDK session error] ${event.data.message}`); @@ -40,8 +84,11 @@ try { }); } finally { try { - if (ahpStarted) await client.stopAhpHost(); + await endpoint?.dispose(); } finally { + for (const ws of sockets.clients) ws.terminate(); + sockets.close(); + await new Promise((resolve) => server.close(resolve)); await client.stop(); } } From 0abce29d1443814bc879bca268250714ea57350c Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Fri, 11 Sep 2026 11:45:49 +0000 Subject: [PATCH 4/4] Expose SDK-owned AHP transports in .NET with Kestrel sample Add bounded message and fragment forwarding, per-connection transport callbacks, and lifecycle cleanup. Use ValueTask reverse RPC handlers and return logical cancellation errors without canceling outer dispatch. Demonstrate streamed Bert turns through the standard AHP client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 23 ++ dotnet/src/Ahp.cs | 516 ++++++++++++++++++++++++++++++ dotnet/src/Client.cs | 9 + dotnet/test/Unit/AhpTests.cs | 427 ++++++++++++++++++++++++ samples/ahp/README.md | 3 + samples/ahp/dotnet/.gitignore | 2 + samples/ahp/dotnet/AhpHost.csproj | 12 + samples/ahp/dotnet/Program.cs | 133 ++++++++ samples/ahp/dotnet/README.md | 35 ++ 9 files changed, 1160 insertions(+) create mode 100644 dotnet/src/Ahp.cs create mode 100644 dotnet/test/Unit/AhpTests.cs create mode 100644 samples/ahp/dotnet/.gitignore create mode 100644 samples/ahp/dotnet/AhpHost.csproj create mode 100644 samples/ahp/dotnet/Program.cs create mode 100644 samples/ahp/dotnet/README.md diff --git a/dotnet/README.md b/dotnet/README.md index c518a40326..2a8af1d220 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -35,6 +35,29 @@ The manual permission/tool-result resume sample can be run the same way: dotnet run --file dotnet/samples/ManualToolResume.cs ``` +### SDK-owned AHP endpoint (prototype) + +With a runtime that supports the endpoint RPCs, `CreateAhpEndpointAsync` lets an +independent AHP client access the same SDK-owned sessions. The application owns +HTTP/WebSocket listening and authentication; the SDK does not start a server. +See the [Kestrel host sample](../samples/ahp/dotnet/README.md). + +```csharp +await using var endpoint = await client.CreateAhpEndpointAsync(); +// Authenticate the peer before accepting. transport implements IAhpTransport. +await using var connection = endpoint.AcceptConnection(transport); +await connection.ReceiveAsync(jsonText); +// For fragmented WebSocket text: +await connection.ReceiveChunkAsync(fragment, endOfMessage: true); +``` + +`IAhpTransport.SendAsync` receives opaque JSON text and a cancellation token. +`CloseAsync` closes the application's transport. Each direction is serialized, +with an 8 MiB / 64-pending-message bound and ten-second operation deadlines. +Observe `connection.Closed` for failures and call `EndAsync` when the physical +transport closes. Disposing an endpoint closes its connections, not its +application-owned listener. AHP message parsing remains in the runtime. + ## Quick Start ```csharp diff --git a/dotnet/src/Ahp.cs b/dotnet/src/Ahp.cs new file mode 100644 index 0000000000..31feb292a0 --- /dev/null +++ b/dotnet/src/Ahp.cs @@ -0,0 +1,516 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace GitHub.Copilot; + +/// Application-owned transport for opaque AHP JSON text. The application owns listening and authentication. +public interface IAhpTransport +{ + /// Sends one complete text message. Honor cancellation when possible. + Task SendAsync(string message, CancellationToken cancellationToken); + + /// Closes the physical transport, optionally because of an error. + Task CloseAsync(Exception? error = null); +} + +public sealed partial class CopilotClient +{ + private readonly ConcurrentDictionary _ahpEndpoints = new(); + + /// Creates a runtime-owned AHP endpoint without creating an HTTP or WebSocket listener. + /// + /// Authenticate callers before accepting connections. AHP exposes all live local sessions in the runtime engine, + /// not only this client's sessions. Client ownership isolates endpoint lifecycle, not session visibility. + /// + public async Task CreateAhpEndpointAsync(CancellationToken cancellationToken = default) + { + var connection = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); + var endpoint = new AhpEndpoint(connection.Rpc, id => _ahpEndpoints.TryRemove(id, out _), _logger); + _ahpEndpoints[endpoint.Id] = endpoint; + if (_disposed || connection.Rpc.Completion.IsCompleted) + { + endpoint.Retire(new IOException("AHP runtime connection closed")); + } + await endpoint.InitializeAsync(cancellationToken).ConfigureAwait(false); + return endpoint; + } + + private void RegisterAhpHandlers(JsonRpc rpc) + { + rpc.SetLocalRpcMethod("ahpTransport.send", + (Func>)SendAhpAsync, singleObjectParam: true); + rpc.SetLocalRpcMethod("ahpTransport.closed", + (Action)CloseAhp, singleObjectParam: true); + } + + private async ValueTask SendAhpAsync(AhpSendRequest request) + { + if (!_ahpEndpoints.TryGetValue(request.EndpointId, out var endpoint) || + !endpoint.Connections.TryGetValue(request.ConnectionId, out var connection)) + { + throw new InvalidOperationException("Unknown AHP connection"); + } + try + { + await connection.SendAsync(request.Message).ConfigureAwait(false); + } + catch (OperationCanceledException error) + { + // Logical AHP cancellation must reply with an error, not cancel the outer RPC dispatch. + throw new IOException("AHP transport send was canceled", error); + } + return new(); + } + + private void CloseAhp(AhpClosedNotification notification) + { + if (_ahpEndpoints.TryGetValue(notification.EndpointId, out var endpoint) && + endpoint.Connections.TryGetValue(notification.ConnectionId, out var connection)) + { + connection.Retire(notification.Error is null ? null : new IOException(notification.Error)); + } + } + + private void RetireAhpEndpoints(JsonRpc? rpc = null) + { + foreach (var endpoint in _ahpEndpoints.Values) + { + if (rpc is null || ReferenceEquals(endpoint.Rpc, rpc)) + { + endpoint.Retire(new IOException("AHP runtime connection closed")); + } + } + + } + + internal static bool IsRecoverableAhpFailure(Exception exception) => + IsRecoverableConnectionCleanupFailure(exception); +} + +/// An AHP endpoint whose listener, authentication, and transport framing belong to the application. +public sealed class AhpEndpoint : IAsyncDisposable +{ + internal static readonly TimeSpan Deadline = TimeSpan.FromSeconds(10); + internal readonly ConcurrentDictionary Connections = new(); + internal readonly string Id = Guid.NewGuid().ToString(); + internal readonly JsonRpc Rpc; + internal readonly ILogger Logger; + private readonly object _gate = new(); + private readonly CancellationTokenSource _lifetime = new(); + private Action? _remove; + private bool _active = true; + private Task? _disposal; + + internal AhpEndpoint(JsonRpc rpc, Action remove, ILogger logger) + { + Rpc = rpc; + _remove = remove; + Logger = logger; + } + + internal async Task InitializeAsync(CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + try + { + linked.Token.ThrowIfCancellationRequested(); + var creation = RequestAsync("ahp.createEndpoint", cancellationToken: CancellationToken.None); + _ = CompensateAsync(creation, Rpc, Id, null, Logger, _lifetime.Token); + await creation.WaitAsync(Deadline, linked.Token).ConfigureAwait(false); + } + catch (Exception error) when (CopilotClient.IsRecoverableAhpFailure(error)) + { + Retire(error); + _ = IgnoreDisposalFailureAsync(Rpc, Id, Logger); + throw; + } + } + + private static async Task IgnoreDisposalFailureAsync(JsonRpc rpc, string endpointId, ILogger logger) + { + try + { + using var timeout = new CancellationTokenSource(Deadline); + await rpc.InvokeAsync("ahp.disposeEndpoint", + [new AhpRequest { EndpointId = endpointId }], timeout.Token) + .WaitAsync(Deadline, timeout.Token).ConfigureAwait(false); + } + catch (Exception error) when (CopilotClient.IsRecoverableAhpFailure(error)) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(error, "Failed to dispose AHP endpoint {EndpointId} after unsuccessful creation", endpointId); + } + } + + internal Task RequestAsync(string method, string? connectionId = null, + string? message = null, CancellationToken cancellationToken = default) => + Rpc.InvokeAsync(method, + [new AhpRequest { EndpointId = Id, ConnectionId = connectionId, Message = message }], cancellationToken); + + // The late-ack observer holds only wire identifiers and a token, not endpoint/transport registries. + internal static async Task CompensateAsync(Task admission, JsonRpc rpc, string endpointId, + string? connectionId, ILogger logger, CancellationToken lifetime) + { + try + { + await admission.ConfigureAwait(false); + if (lifetime.IsCancellationRequested) + { + using var timeout = new CancellationTokenSource(Deadline); + await rpc.InvokeAsync( + connectionId is null ? "ahp.disposeEndpoint" : "ahp.closeConnection", + [new AhpRequest { EndpointId = endpointId, ConnectionId = connectionId }], + timeout.Token).WaitAsync(Deadline, timeout.Token).ConfigureAwait(false); + } + } + catch (Exception error) when (CopilotClient.IsRecoverableAhpFailure(error)) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(error, "AHP admission or compensating cleanup failed for endpoint {EndpointId}, connection {ConnectionId}", + endpointId, connectionId); + } + } + + /// Accepts a physical connection synchronously; messages may arrive before runtime admission completes. + public AhpConnection AcceptConnection(IAhpTransport transport) + { + ArgumentNullException.ThrowIfNull(transport); + lock (_gate) + { + ObjectDisposedException.ThrowIf(!_active, this); + var connection = new AhpConnection(this, transport); + Connections[connection.Id] = connection; + connection.Open(); + return connection; + } + } + + internal void Retire(Exception? error = null) + { + AhpConnection[] connections; + lock (_gate) + { + if (!_active) return; + _active = false; + _remove?.Invoke(Id); + _remove = null; + connections = Connections.Values.ToArray(); + Connections.Clear(); + } + _lifetime.Cancel(); + foreach (var connection in connections) connection.Retire(error); + } + + /// Releases local connections immediately, then disposes the runtime endpoint with a bounded deadline. + public ValueTask DisposeAsync() + { + lock (_gate) + { + if (_disposal is not null) return new(_disposal); + if (!_active) return default; + Retire(); + _disposal = DisposeRemoteAsync(); + return new(_disposal); + } + } + + private async Task DisposeRemoteAsync() + { + using var timeout = new CancellationTokenSource(Deadline); + await RequestAsync("ahp.disposeEndpoint", cancellationToken: timeout.Token) + .WaitAsync(Deadline, timeout.Token).ConfigureAwait(false); + } +} + +/// One physical AHP connection. Messages are opaque JSON text interpreted only by the runtime. +public sealed class AhpConnection : IAsyncDisposable +{ + private const int MaxBytes = 8 * 1024 * 1024; + private const int MaxMessages = 64; + private static readonly UTF8Encoding s_utf8 = new(false, true); + private readonly object _gate = new(); + private readonly AhpEndpoint _endpoint; + private readonly CancellationTokenSource _lifetime = new(); + private readonly TaskCompletionSource _closed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private IAhpTransport? _transport; + private Task _opening = Task.CompletedTask; + private Task _inbound = Task.CompletedTask; + private Task _outbound = Task.CompletedTask; + private Task _closeWork = Task.CompletedTask; + private Task? _ending; + private int _incomingBytes, _incomingMessages, _outgoingBytes, _outgoingMessages; + private MemoryStream? _chunks; + internal readonly string Id = Guid.NewGuid().ToString(); + + internal AhpConnection(AhpEndpoint endpoint, IAhpTransport transport) + { + _endpoint = endpoint; + _transport = transport; + _ = ObserveAsync(_closed.Task, endpoint.Logger, "connection closure"); + } + + /// Completes when locally closed; faults on transport, protocol, or runtime connection failure. + public Task Closed => _closed.Task; + + internal void Open() + { + _opening = _endpoint.RequestAsync("ahp.openConnection", Id); + _ = AhpEndpoint.CompensateAsync(_opening, _endpoint.Rpc, _endpoint.Id, Id, _endpoint.Logger, _lifetime.Token); + _ = ObserveOpeningAsync(); + } + + private async Task ObserveOpeningAsync() + { + try { await _opening.WaitAsync(AhpEndpoint.Deadline, _lifetime.Token).ConfigureAwait(false); } + catch (Exception error) when (CopilotClient.IsRecoverableAhpFailure(error)) { Fail(error); } + } + + private void AssertActive() + { + ObjectDisposedException.ThrowIf(_transport is null, this); + } + + /// Admits one complete text message, not waiting for completion of the AHP operation. + public Task ReceiveAsync(string message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + lock (_gate) + { + AssertActive(); + if (_chunks is not null) throw new InvalidOperationException("An AHP fragmented message is still in progress"); + return Enqueue(message, outbound: false, cancellationToken); + } + } + + /// Accepts WebSocket text fragments, preserving UTF-8 characters across fragment boundaries. + public Task ReceiveChunkAsync(ReadOnlyMemory bytes, bool endOfMessage, CancellationToken cancellationToken = default) + { + lock (_gate) + { + AssertActive(); + cancellationToken.ThrowIfCancellationRequested(); + if (_incomingBytes + (_chunks?.Length ?? 0) + bytes.Length > MaxBytes) + { + var error = new IOException("AHP incoming messages exceed the 8 MiB limit"); + Fail(error); + return Task.FromException(error); + } + _chunks ??= new MemoryStream(); + _chunks.Write(bytes.Span); + if (!endOfMessage) return Task.CompletedTask; + try + { + var message = s_utf8.GetString(_chunks.GetBuffer(), 0, checked((int)_chunks.Length)); + _chunks.Dispose(); + _chunks = null; + return Enqueue(message, outbound: false, cancellationToken); + } + catch (Exception error) when (CopilotClient.IsRecoverableAhpFailure(error)) + { + Fail(error); + return Task.FromException(error); + } + } + } + + internal Task SendAsync(string message) + { + lock (_gate) + { + AssertActive(); + return Enqueue(message, outbound: true, CancellationToken.None); + } + } + + private Task Enqueue(string message, bool outbound, CancellationToken cancellationToken) + { + int bytes; + try { bytes = s_utf8.GetByteCount(message); } + catch (EncoderFallbackException error) + { + Fail(error); + return Task.FromException(error); + } + var pendingBytes = outbound ? _outgoingBytes : _incomingBytes; + var pendingMessages = outbound ? _outgoingMessages : _incomingMessages; + if (pendingBytes + (long)bytes > MaxBytes || pendingMessages >= MaxMessages) + { + var error = new IOException("AHP messages exceed the 8 MiB / 64 pending message limit"); + Fail(error); + return Task.FromException(error); + } + if (outbound) { _outgoingBytes += bytes; _outgoingMessages++; } + else { _incomingBytes += bytes; _incomingMessages++; } + var prior = outbound ? _outbound : _inbound; + var work = RunQueuedAsync(prior, message, bytes, outbound, cancellationToken); + if (outbound) _outbound = ObserveAsync(work, _endpoint.Logger, "outgoing message"); + else _inbound = ObserveAsync(work, _endpoint.Logger, "incoming message"); + return work; + } + + private async Task RunQueuedAsync(Task prior, string message, int bytes, bool outbound, CancellationToken cancellationToken) + { + // Never call application callbacks while holding the queue lock or on the RPC reader. + await Task.Yield(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + linked.CancelAfter(AhpEndpoint.Deadline); + try + { + await prior.WaitAsync(AhpEndpoint.Deadline, linked.Token).ConfigureAwait(false); + linked.Token.ThrowIfCancellationRequested(); + if (outbound) + { + IAhpTransport transport; + lock (_gate) + { + AssertActive(); + transport = _transport!; + } + await InvokeSendAsync(transport, message, _endpoint.Logger, linked.Token) + .WaitAsync(AhpEndpoint.Deadline, linked.Token).ConfigureAwait(false); + } + else + { + await _opening.WaitAsync(AhpEndpoint.Deadline, linked.Token).ConfigureAwait(false); + linked.Token.ThrowIfCancellationRequested(); + await _endpoint.RequestAsync("ahp.receive", Id, message, linked.Token) + .WaitAsync(AhpEndpoint.Deadline, linked.Token).ConfigureAwait(false); + } + } + catch (Exception error) when (CopilotClient.IsRecoverableAhpFailure(error)) + { + Fail(error); + throw; + } + finally + { + lock (_gate) + { + if (outbound) { _outgoingBytes -= bytes; _outgoingMessages--; } + else { _incomingBytes -= bytes; _incomingMessages--; } + } + } + } + + // These isolated closures must not capture the connection/client: an arbitrary callback can hang forever. + private static async Task InvokeSendAsync(IAhpTransport transport, string message, ILogger logger, CancellationToken token) + { + var callbackCancellation = new CancellationTokenSource(); + // User cancellation handlers can block too; they never run on the SDK's lifetime cancellation path. + using var registration = token.Register(() => + _ = ObserveAsync(Task.Run(callbackCancellation.Cancel, CancellationToken.None), logger, "send cancellation callback")); + try + { + await Task.Run(() => transport.SendAsync(message, callbackCancellation.Token), CancellationToken.None) + .WaitAsync(AhpEndpoint.Deadline, token).ConfigureAwait(false); + } + finally + { + // Do not dispose the source while a user cancellation handler may still be running. + _ = ObserveAsync(Task.Run(callbackCancellation.Cancel, CancellationToken.None), logger, "send cancellation callback"); + } + } + + private static async Task InvokeCloseAsync(IAhpTransport transport, Exception? error) + { + await Task.Run(() => transport.CloseAsync(error)).WaitAsync(AhpEndpoint.Deadline).ConfigureAwait(false); + } + + // Observing a task does not replace its original fault: message/close callers and Closed still receive it. + private static async Task ObserveAsync(Task task, ILogger logger, string operation) + { + try { await task.ConfigureAwait(false); } + catch (Exception error) when (CopilotClient.IsRecoverableAhpFailure(error)) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(error, "AHP {Operation} failed", operation); + } + } + + internal void Retire(Exception? error = null) + { + lock (_gate) + { + if (_transport is null) return; + var transport = _transport; + _transport = null; + _endpoint.Connections.TryRemove(Id, out _); + _chunks?.Dispose(); + _chunks = null; + _lifetime.Cancel(); + _closeWork = InvokeCloseAsync(transport, error); + _ = ObserveAsync(_closeWork, _endpoint.Logger, "transport close"); + if (error is null) _closed.TrySetResult(); + else _closed.TrySetException(error); + } + } + + private void Fail(Exception error) + { + lock (_gate) + { + if (_transport is null) return; + Retire(error); + _ = ObserveAsync(EndAsync(), _endpoint.Logger, "connection cleanup"); + } + } + + /// Ends this connection, releasing local state before bounded runtime and transport cleanup. + public Task EndAsync() + { + lock (_gate) + { + Retire(); + return _ending ??= EndRemoteAsync(); + } + } + + private async Task EndRemoteAsync() + { + using var timeout = new CancellationTokenSource(AhpEndpoint.Deadline); + await Task.WhenAll(_endpoint.RequestAsync("ahp.closeConnection", Id, cancellationToken: timeout.Token) + .WaitAsync(AhpEndpoint.Deadline, timeout.Token), + _closeWork).ConfigureAwait(false); + } + + /// + public ValueTask DisposeAsync() => new(EndAsync()); +} + +// Schema-native, source-generated serialization without any dependency on an HTTP server package. +internal sealed class AhpRequest +{ + public required string EndpointId { get; init; } + public string? ConnectionId { get; init; } + public string? Message { get; init; } +} + +internal sealed class AhpSendRequest +{ + public required string EndpointId { get; init; } + public required string ConnectionId { get; init; } + public required string Message { get; init; } +} + +internal sealed class AhpClosedNotification +{ + public required string EndpointId { get; init; } + public required string ConnectionId { get; init; } + public string? Error { get; init; } +} + +internal sealed class AhpEmptyResult; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(AhpRequest))] +[JsonSerializable(typeof(AhpSendRequest))] +[JsonSerializable(typeof(AhpClosedNotification))] +[JsonSerializable(typeof(AhpEmptyResult))] +internal partial class AhpJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index dff4681a80..663c77ea13 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -553,6 +553,10 @@ public async Task StopAsync() { List errors = []; + var ahpDisposals = _ahpEndpoints.Values.Select(endpoint => endpoint.DisposeAsync().AsTask()).ToArray(); + try { await Task.WhenAll(ahpDisposals).ConfigureAwait(false); } + catch (Exception ex) { errors.Add(ex); } + foreach (var session in _sessions.Values.ToArray()) { try @@ -597,6 +601,7 @@ public async Task StopAsync() /// public async Task ForceStopAsync() { + RetireAhpEndpoints(); foreach (var session in _sessions.Values) { session.CancelPendingExternalTools(); @@ -651,6 +656,7 @@ private async Task CleanupConnectionAsync(List? errors, bool graceful private async Task CleanupConnectionAsync(Connection ctx, List? errors, bool gracefulRuntimeShutdown) { + RetireAhpEndpoints(ctx.Rpc); if (gracefulRuntimeShutdown && (ctx.CliProcess is not null || ctx.FfiHost is not null)) { var runtimeShutdownTimestamp = Stopwatch.GetTimestamp(); @@ -2682,6 +2688,7 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? _logger); var handler = new RpcHandler(this); + RegisterAhpHandlers(rpc); rpc.SetLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.SetLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); rpc.SetLocalRpcMethod("userInput.request", handler.OnUserInputRequest); @@ -2738,6 +2745,7 @@ and not AccessViolationException private async Task CancelExternalToolsWhenConnectionClosesAsync(JsonRpc rpc) { await Task.WhenAny(rpc.Completion).ConfigureAwait(false); + RetireAhpEndpoints(rpc); if (rpc.Completion.Exception is { } exception) { _logger.LogDebug(exception, "JSON-RPC connection completed with an error"); @@ -2781,6 +2789,7 @@ private static JsonSerializerOptions CreateSerializerOptions() }; options.TypeInfoResolverChain.Add(ClientJsonContext.Default); + options.TypeInfoResolverChain.Add(AhpJsonContext.Default); options.TypeInfoResolverChain.Add(TypesJsonContext.Default); options.TypeInfoResolverChain.Add(CopilotSession.SessionJsonContext.Default); options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default); diff --git a/dotnet/test/Unit/AhpTests.cs b/dotnet/test/Unit/AhpTests.cs new file mode 100644 index 0000000000..18b078fcbd --- /dev/null +++ b/dotnet/test/Unit/AhpTests.cs @@ -0,0 +1,427 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public sealed class AhpTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task RoutesOpaqueMessagesAndReassemblesUtf8Fragments() + { + await using var server = new Server(); + await using var client = server.CreateClient(); + await using var first = await client.CreateAhpEndpointAsync(); + await using var second = await client.CreateAhpEndpointAsync(); + var create1 = await server.NextAsync("ahp.createEndpoint"); + var create2 = await server.NextAsync("ahp.createEndpoint"); + Assert.NotEqual(create1["params"]!["endpointId"]!.GetValue(), create2["params"]!["endpointId"]!.GetValue()); + var firstTransport = new Transport(); + var secondTransport = new Transport(); + await using var connection1 = first.AcceptConnection(firstTransport); + await using var connection2 = second.AcceptConnection(secondTransport); + var open1 = await server.NextAsync("ahp.openConnection"); + var open2 = await server.NextAsync("ahp.openConnection"); + Assert.True(Guid.TryParse(open1["params"]!["connectionId"]!.GetValue(), out _)); + await connection1.ReceiveAsync("opaque text: not SDK-parsed JSON"); + Assert.Equal("opaque text: not SDK-parsed JSON", (await server.NextAsync("ahp.receive"))["params"]!["message"]!.GetValue()); + var text = "{\"text\":\"Bert 🐧\"}"; + var bytes = Encoding.UTF8.GetBytes(text); + var split = Array.IndexOf(bytes, (byte)0xF0) + 2; + await connection2.ReceiveChunkAsync(bytes.AsMemory(0, split), false); + Assert.Throws(() => { _ = connection2.ReceiveAsync("interleaved"); }); + await connection2.ReceiveChunkAsync(bytes.AsMemory(split), true); + Assert.Equal(text, (await server.NextAsync("ahp.receive"))["params"]!["message"]!.GetValue()); + var firstReply = await server.CallbackAsync(open1, "first"); + var secondReply = await server.CallbackAsync(open2, "second"); + Assert.Empty(Assert.IsType(firstReply["result"])); + Assert.Empty(Assert.IsType(secondReply["result"])); + Assert.Equal("first", Assert.Single(firstTransport.Messages)); + Assert.Equal("second", Assert.Single(secondTransport.Messages)); + await server.CloseAsync(open1, "runtime failure"); + Assert.Equal("runtime failure", (await Assert.ThrowsAsync(() => connection1.Closed.WaitAsync(Timeout))).Message); + Assert.False(connection2.Closed.IsCompleted); + } + + [Fact] + public async Task EarlyCloseSendsFreshCompensationAfterLateOpenAcknowledgement() + { + await using var server = new Server("ahp.openConnection"); + await using var client = server.CreateClient(); + await using var endpoint = await client.CreateAhpEndpointAsync(); + var connection = endpoint.AcceptConnection(new Transport()); + var open = await server.NextAsync("ahp.openConnection"); + await connection.EndAsync().WaitAsync(Timeout); + await server.NextAsync("ahp.closeConnection"); + await server.ReplyAsync(open); + var compensation = await server.NextAsync("ahp.closeConnection"); + Assert.Equal(open["params"]!["connectionId"]!.GetValue(), compensation["params"]!["connectionId"]!.GetValue()); + await connection.EndAsync(); + Assert.True(connection.Closed.IsCompletedSuccessfully); + } + + [Fact] + public async Task CanceledCreationCompensatesLateAcknowledgement() + { + await using var server = new Server("ahp.createEndpoint"); + await using var client = server.CreateClient(); + using var cancellation = new CancellationTokenSource(); + var creation = client.CreateAhpEndpointAsync(cancellation.Token); + var request = await server.NextAsync("ahp.createEndpoint"); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => creation.WaitAsync(Timeout)); + AssertRegistryEmpty(client); + await server.NextAsync("ahp.disposeEndpoint"); + await server.ReplyAsync(request); + var disposal = await server.NextAsync("ahp.disposeEndpoint"); + Assert.Equal(request["params"]!["endpointId"]!.GetValue(), disposal["params"]!["endpointId"]!.GetValue()); + } + + [Fact] + public async Task RpcLossCancelsInitialCreationAndActiveConnections() + { + await using (var server = new Server("ahp.createEndpoint")) + await using (var client = server.CreateClient()) + { + var creation = client.CreateAhpEndpointAsync(); + await server.NextAsync("ahp.createEndpoint"); + server.Disconnect(); + await Assert.ThrowsAnyAsync(() => creation.WaitAsync(Timeout)); + AssertRegistryEmpty(client); + } + await using (var server = new Server()) + await using (var client = server.CreateClient()) + { + var endpoint = await client.CreateAhpEndpointAsync(); + var connection = endpoint.AcceptConnection(new Transport()); + await server.NextAsync("ahp.openConnection"); + server.Disconnect(); + await Assert.ThrowsAnyAsync(() => connection.Closed.WaitAsync(Timeout)); + using var deadline = new CancellationTokenSource(Timeout); + while (RegistryCount(client) != 0) await Task.Delay(10, deadline.Token); + AssertRegistryEmpty(client); + Assert.Throws(() => endpoint.AcceptConnection(new Transport())); + } + } + + [Fact] + public async Task DisposalReleasesRegistriesDespiteBlockedSendAndCloseCallbacks() + { + await using var server = new Server(); + await using var client = server.CreateClient(); + var endpoint = await client.CreateAhpEndpointAsync(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var transport = new Transport + { + Send = (_, _) => { entered.TrySetResult(); return blocked.Task; }, + Close = _ => blocked.Task + }; + var connection = endpoint.AcceptConnection(transport); + var open = await server.NextAsync("ahp.openConnection"); + var callback = server.CallbackAsync(open, "blocked"); + await entered.Task.WaitAsync(Timeout); + await endpoint.DisposeAsync().AsTask().WaitAsync(Timeout); + AssertRegistryEmpty(client); + Assert.Null(typeof(AhpConnection).GetField("_transport", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(connection)); + await connection.Closed.WaitAsync(Timeout); + var response = await callback.WaitAsync(Timeout); + Assert.NotNull(response["error"]); + blocked.TrySetResult(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task IncomingQueueEnforcesByteAndMessageBounds(bool byteLimit) + { + await using var server = new Server("ahp.openConnection"); + await using var client = server.CreateClient(); + await using var endpoint = await client.CreateAhpEndpointAsync(); + var connection = endpoint.AcceptConnection(new Transport()); + var open = await server.NextAsync("ahp.openConnection"); + var pending = new List(); + if (byteLimit) pending.Add(connection.ReceiveAsync(new string('x', 8 * 1024 * 1024))); + else for (var i = 0; i < 64; i++) pending.Add(connection.ReceiveAsync("{}")); + await Assert.ThrowsAsync(() => connection.ReceiveAsync("x")); + await Assert.ThrowsAsync(() => connection.Closed); + foreach (var work in pending) await Assert.ThrowsAnyAsync(() => work.WaitAsync(Timeout)); + await server.ReplyAsync(open); + } + + [Fact] + public async Task InvalidUtf8AndOversizedFragmentsCloseConnection() + { + await using var server = new Server(); + await using var client = server.CreateClient(); + await using var endpoint = await client.CreateAhpEndpointAsync(); + var malformed = endpoint.AcceptConnection(new Transport()); + await Assert.ThrowsAsync(() => malformed.ReceiveChunkAsync(new byte[] { 0xF0, 0x9F }, true)); + await Assert.ThrowsAsync(() => malformed.Closed); + var oversized = endpoint.AcceptConnection(new Transport()); + await oversized.ReceiveChunkAsync(new byte[8 * 1024 * 1024], false); + await Assert.ThrowsAsync(() => oversized.ReceiveChunkAsync(new byte[1], false)); + await Assert.ThrowsAsync(() => oversized.Closed); + } + + [Fact] + public async Task OutgoingQueueOverflowCancelsBlockedCallbacks() + { + await using var server = new Server(); + await using var client = server.CreateClient(); + await using var endpoint = await client.CreateAhpEndpointAsync(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var connection = endpoint.AcceptConnection(new Transport + { + Send = (_, _) => { entered.TrySetResult(); return blocked.Task; } + }); + var open = await server.NextAsync("ahp.openConnection"); + var callbacks = new List> { server.CallbackAsync(open, "blocked") }; + await entered.Task.WaitAsync(Timeout); + for (var i = 0; i < 64; i++) callbacks.Add(server.CallbackAsync(open, "{}")); + await Assert.ThrowsAsync(() => connection.Closed.WaitAsync(Timeout)); + var results = await Task.WhenAll(callbacks).WaitAsync(Timeout); + Assert.All(results, response => Assert.NotNull(response["error"])); + blocked.TrySetResult(); + } + + [Fact] + public async Task UserCancellationHandlersCannotBlockLocalDisposal() + { + await using var server = new Server(); + await using var client = server.CreateClient(); + var endpoint = await client.CreateAhpEndpointAsync(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var releaseCancellation = new ManualResetEventSlim(); + var connection = endpoint.AcceptConnection(new Transport + { + Send = (_, token) => + { + token.Register(() => releaseCancellation.Wait()); + entered.TrySetResult(); + return blocked.Task; + } + }); + var open = await server.NextAsync("ahp.openConnection"); + var callback = server.CallbackAsync(open, "blocked"); + try + { + await entered.Task.WaitAsync(Timeout); + await endpoint.DisposeAsync().AsTask().WaitAsync(Timeout); + await connection.Closed.WaitAsync(Timeout); + AssertRegistryEmpty(client); + Assert.NotNull((await callback.WaitAsync(Timeout))["error"]); + } + finally + { + releaseCancellation.Set(); + blocked.TrySetResult(); + } + } + + [Fact] + public async Task AdmissionDeadlineClosesUnacknowledgedOpen() + { + await using var server = new Server("ahp.openConnection"); + await using var client = server.CreateClient(); + await using var endpoint = await client.CreateAhpEndpointAsync(); + var connection = endpoint.AcceptConnection(new Transport()); + var open = await server.NextAsync("ahp.openConnection"); + await Assert.ThrowsAsync(() => connection.Closed.WaitAsync(TimeSpan.FromSeconds(15))); + Assert.Null(typeof(AhpConnection).GetField("_transport", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(connection)); + await server.NextAsync("ahp.closeConnection"); + await server.ReplyAsync(open); + await server.NextAsync("ahp.closeConnection"); + } + + [Fact] + public async Task ReceivesAndSendsAreSerializedIndependently() + { + await using var server = new Server("ahp.receive"); + await using var client = server.CreateClient(); + await using var endpoint = await client.CreateAhpEndpointAsync(); + var sendEntered = Channel.CreateUnbounded(); + var sendGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var transport = new Transport + { + Send = (message, _) => { sendEntered.Writer.TryWrite(message); return message == "one" ? sendGate.Task : Task.CompletedTask; } + }; + var connection = endpoint.AcceptConnection(transport); + var open = await server.NextAsync("ahp.openConnection"); + var firstReceive = connection.ReceiveAsync("one"); + var request1 = await server.NextAsync("ahp.receive"); + var secondReceive = connection.ReceiveAsync("two"); + var firstSend = server.CallbackAsync(open, "one"); + Assert.Equal("one", await sendEntered.Reader.ReadAsync().AsTask().WaitAsync(Timeout)); + var secondSend = server.CallbackAsync(open, "two"); + await server.ReplyAsync(request1); + await firstReceive; + var request2 = await server.NextAsync("ahp.receive"); + Assert.Equal("two", request2["params"]!["message"]!.GetValue()); + Assert.False(sendEntered.Reader.TryRead(out _)); + await server.ReplyAsync(request2); + await secondReceive; + sendGate.TrySetResult(); + await Task.WhenAll(firstSend, secondSend).WaitAsync(Timeout); + Assert.Equal("two", await sendEntered.Reader.ReadAsync().AsTask().WaitAsync(Timeout)); + await connection.EndAsync(); + } + + private static void AssertRegistryEmpty(CopilotClient client) + => Assert.Equal(0, RegistryCount(client)); + + private static int RegistryCount(CopilotClient client) + { + var registry = typeof(CopilotClient).GetField("_ahpEndpoints", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(client)!; + return (int)registry.GetType().GetProperty("Count")!.GetValue(registry)!; + } + + private sealed class Transport : IAhpTransport + { + public ConcurrentQueue Messages { get; } = new(); + public Func? Send { get; init; } + public Func? Close { get; init; } + public Task SendAsync(string message, CancellationToken cancellationToken) + { + Messages.Enqueue(message); + return Send?.Invoke(message, cancellationToken) ?? Task.CompletedTask; + } + public Task CloseAsync(Exception? error = null) => Close?.Invoke(error) ?? Task.CompletedTask; + } + + private sealed class Server : IAsyncDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _lifetime = new(); + private readonly SemaphoreSlim _write = new(1, 1); + private readonly Channel _requests = Channel.CreateUnbounded(); + private readonly ConcurrentDictionary> _callbacks = new(); + private readonly HashSet _held; + private readonly Task _reader; + private NetworkStream? _stream; + private int _nextCallback; + + public Server(params string[] held) + { + _held = new(held); + _listener.Start(); + _reader = ReadAsync(); + } + + public CopilotClient CreateClient() => new(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri($"127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}") + }); + + public async Task NextAsync(string method) + { + using var timeout = new CancellationTokenSource(Timeout); + while (await _requests.Reader.WaitToReadAsync(timeout.Token)) + { + var request = await _requests.Reader.ReadAsync(timeout.Token); + if (request["method"]!.GetValue() == method) return request; + } + throw new IOException("Server closed"); + } + + public Task ReplyAsync(JsonObject request) => WriteAsync(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = request["id"]!.DeepClone(), + ["result"] = request["method"]!.GetValue() == "connect" + ? new JsonObject { ["ok"] = true, ["protocolVersion"] = 3, ["version"] = "test" } + : new JsonObject() + }); + + public async Task CallbackAsync(JsonObject open, string message) + { + var id = Interlocked.Increment(ref _nextCallback); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _callbacks[id] = completion; + var parameters = (JsonObject)open["params"]!.DeepClone(); + parameters["message"] = message; + await WriteAsync(new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["method"] = "ahpTransport.send", ["params"] = parameters }); + return await completion.Task.WaitAsync(Timeout); + } + + public Task CloseAsync(JsonObject open, string? error) + { + var parameters = (JsonObject)open["params"]!.DeepClone(); + if (error is not null) parameters["error"] = error; + return WriteAsync(new JsonObject { ["jsonrpc"] = "2.0", ["method"] = "ahpTransport.closed", ["params"] = parameters }); + } + + private async Task WriteAsync(JsonObject message) + { + var bytes = Encoding.UTF8.GetBytes(message.ToJsonString()); + await _write.WaitAsync(_lifetime.Token); + try + { + await _stream!.WriteAsync(Encoding.ASCII.GetBytes($"Content-Length: {bytes.Length}\r\n\r\n"), _lifetime.Token); + await _stream.WriteAsync(bytes, _lifetime.Token); + } + finally { _write.Release(); } + } + + private async Task ReadAsync() + { + try + { + using var socket = await _listener.AcceptTcpClientAsync(_lifetime.Token); + _stream = socket.GetStream(); + var one = new byte[1]; + while (!_lifetime.IsCancellationRequested) + { + var header = new StringBuilder(); + while (!header.ToString().EndsWith("\r\n\r\n", StringComparison.Ordinal)) + { + await _stream.ReadExactlyAsync(one, _lifetime.Token); + header.Append((char)one[0]); + } + var length = int.Parse(header.ToString().Split(':')[1].Trim(), System.Globalization.CultureInfo.InvariantCulture); + var body = new byte[length]; + await _stream.ReadExactlyAsync(body, _lifetime.Token); + var message = JsonNode.Parse(body)!.AsObject(); + if (message["method"] is { } method) + { + if (message["id"] is null) continue; + await _requests.Writer.WriteAsync(message, _lifetime.Token); + if (!_held.Contains(method.GetValue())) await ReplyAsync(message); + } + else if (message["id"] is { } id && _callbacks.TryRemove(id.GetValue(), out var completion)) + { + completion.TrySetResult(message); + } + } + } + catch (Exception error) when (error is OperationCanceledException or IOException or ObjectDisposedException or SocketException) { } + } + + public void Disconnect() => _stream?.Dispose(); + + public async ValueTask DisposeAsync() + { + _lifetime.Cancel(); + Disconnect(); + _listener.Stop(); + await _reader.WaitAsync(Timeout); + _lifetime.Dispose(); + _write.Dispose(); + } + } +} +#endif diff --git a/samples/ahp/README.md b/samples/ahp/README.md index 82f66f881e..493bdd9315 100644 --- a/samples/ahp/README.md +++ b/samples/ahp/README.md @@ -8,6 +8,9 @@ list and subscribe to that same session and send a prompt. There is no custom protocol client. The runtime parses and serializes AHP; the SDK transports opaque JSON text and does not own a listener, framework, or authentication policy. +The [equivalent .NET/Kestrel sample](dotnet/README.md) uses the same runtime and +independent AHP client, including streamed output and fragmented WebSocket reads. + ## Requirements Requires Node.js 22.12+ and a local runtime build implementing `ahp.createEndpoint`, diff --git a/samples/ahp/dotnet/.gitignore b/samples/ahp/dotnet/.gitignore new file mode 100644 index 0000000000..cd42ee34e8 --- /dev/null +++ b/samples/ahp/dotnet/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/ahp/dotnet/AhpHost.csproj b/samples/ahp/dotnet/AhpHost.csproj new file mode 100644 index 0000000000..1119a5c9a3 --- /dev/null +++ b/samples/ahp/dotnet/AhpHost.csproj @@ -0,0 +1,12 @@ + + + net8.0 + Major + enable + enable + $(NoWarn);GHCP001 + + + + + diff --git a/samples/ahp/dotnet/Program.cs b/samples/ahp/dotnet/Program.cs new file mode 100644 index 0000000000..f5975c7dcd --- /dev/null +++ b/samples/ahp/dotnet/Program.cs @@ -0,0 +1,133 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; + +var runtimePath = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("COPILOT_CLI_PATH") + ?? throw new ArgumentException("Pass the local copilot-runtime path or set COPILOT_CLI_PATH."); +await using var client = new CopilotClient(new CopilotClientOptions +{ + Connection = RuntimeConnection.ForStdio(path: runtimePath), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? Environment.GetEnvironmentVariable("GH_TOKEN") +}); +await client.StartAsync(); +await using var endpoint = await client.CreateAhpEndpointAsync(); +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = Environment.GetEnvironmentVariable("COPILOT_MODEL") ?? "gpt-4.1", + Streaming = true, + SystemMessage = new() + { + Mode = SystemMessageMode.Replace, + Content = "You are Bert. When asked who you are, reply exactly: I am Bert." + }, + OnPermissionRequest = (_, _) => Task.FromResult(PermissionDecision.Reject()) +}); +var deltas = 0; +using var deltaSubscription = session.On(_ => Interlocked.Increment(ref deltas)); +using var messageSubscription = session.On(message => +{ + Console.WriteLine($"[SDK observed {session.SessionId}] {message.Data.Content}"); + Console.WriteLine($"[SDK streaming] {Interlocked.Exchange(ref deltas, 0)} message deltas"); +}); +using var errorSubscription = session.On(error => + Console.Error.WriteLine($"[SDK session error] {error.Data.Message}")); + +var builder = WebApplication.CreateBuilder(); +builder.Logging.ClearProviders(); +builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0)); +await using var app = builder.Build(); +app.UseWebSockets(); +app.Map("/ahp", async context => +{ + // Local demo only: any loopback process is trusted. Production hosts must authenticate before upgrade. + if (context.Connection.RemoteIpAddress is not { } address || !IPAddress.IsLoopback(address)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + using var socket = await context.WebSockets.AcceptWebSocketAsync(); + var connection = endpoint.AcceptConnection(new SocketTransport(socket)); + var buffer = new byte[16 * 1024]; + try + { + while (socket.State is WebSocketState.Open or WebSocketState.CloseSent) + { + var frame = await socket.ReceiveAsync(buffer.AsMemory(), context.RequestAborted); + if (frame.MessageType == WebSocketMessageType.Close) break; + if (frame.MessageType != WebSocketMessageType.Text) + throw new InvalidDataException("Only AHP text messages are supported."); + await connection.ReceiveChunkAsync(buffer.AsMemory(0, frame.Count), frame.EndOfMessage, context.RequestAborted); + } + } + catch (Exception error) when (error is WebSocketException or OperationCanceledException or IOException or ObjectDisposedException) + { + Console.Error.WriteLine($"AHP transport ended: {error.Message}"); + socket.Abort(); + } + finally + { + try { await connection.EndAsync(); } + catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException and not AccessViolationException) + { + Console.Error.WriteLine($"AHP connection cleanup failed: {error.Message}"); + socket.Abort(); + } + try { await connection.Closed; } + catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException and not AccessViolationException) + { + Console.Error.WriteLine($"AHP connection failed: {error.Message}"); + } + } +}); +await app.StartAsync(); +var httpUrl = app.Services.GetRequiredService().Features.Get()!.Addresses.Single(); +var url = httpUrl.Replace("http://", "ws://", StringComparison.Ordinal) + "/ahp"; +Console.WriteLine("DEMO AUTH: loopback-only; every local process is trusted. Do not expose this listener."); +Console.WriteLine($"AHP URL: {url}"); +Console.WriteLine($"SDK session ID: {session.SessionId}"); +Console.WriteLine($"In samples/ahp: npm run client -- '{url}' '{session.SessionId}'"); +Console.WriteLine("Waiting for AHP prompts. Press Ctrl+C to stop."); +var stopping = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); +using var shutdownRegistration = app.Lifetime.ApplicationStopping.Register(() => stopping.TrySetResult()); +try +{ + await stopping.Task; +} +finally +{ + await endpoint.DisposeAsync(); + await app.StopAsync(); +} + +sealed class SocketTransport(WebSocket socket) : IAhpTransport +{ + public async Task SendAsync(string message, CancellationToken cancellationToken) => + await socket.SendAsync(Encoding.UTF8.GetBytes(message).AsMemory(), + WebSocketMessageType.Text, true, cancellationToken); + + public async Task CloseAsync(Exception? error = null) + { + try + { + if (error is null && socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, timeout.Token); + } + } + finally + { + // Interrupt a pending receive even if the peer never completes the close handshake. + socket.Abort(); + } + } +} diff --git a/samples/ahp/dotnet/README.md b/samples/ahp/dotnet/README.md new file mode 100644 index 0000000000..fb8a15200b --- /dev/null +++ b/samples/ahp/dotnet/README.md @@ -0,0 +1,35 @@ +# SDK-owned AHP with .NET and Kestrel + +This sample exposes the same SDK-created Bert session to an independent standard +AHP client. The runtime implements AHP; the SDK forwards opaque messages; Kestrel +owns the listener and WebSocket framing. No server package is added to the SDK. + +Requires .NET 8+ and a runtime built with the SDK-owned AHP endpoint RPCs: + +```sh +# From the repository root; GITHUB_TOKEN or GH_TOKEN is read without being printed. +dotnet run --project samples/ahp/dotnet/AhpHost.csproj \ + -p:CopilotSkipCliDownload=true -- /absolute/path/to/copilot-runtime +``` + +The runtime's native library must be beside the runtime executable. The sample +prints an ephemeral loopback URL and session ID. In another terminal: + +```sh +cd samples/ahp +npm run client -- 'ws://127.0.0.1:PORT/ahp' 'SESSION_ID' +``` + +The AHP client should receive `I am Bert.` and the .NET host should print the same +final response under `[SDK observed ...]`, plus a streaming delta count. + +**Demo authentication policy:** every loopback process is trusted. The listener +binds only to `127.0.0.1`, checks the peer address before upgrading, and must not be +forwarded or exposed. Production applications must authenticate and authorize +callers before `AcceptConnection`; an endpoint exposes all live local sessions in +the runtime engine. Endpoint ownership does not provide per-user session authorization. + +`ReceiveChunkAsync` assembles WebSocket fragments with strict UTF-8 decoding. +Queues are bounded to 8 MiB and 64 pending messages per direction, with ten-second +admission/send deadlines. Each connection serializes sends and receives +independently. The listener remains application-owned when the endpoint is disposed.