From dd1a89790ef05bc76557d82fdf4405c8ad4f94c8 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 11 Sep 2026 18:45:07 +0200 Subject: [PATCH] sdk: feat: add canvas launch authorization and session retention Require an exact live launch-provider contract acknowledgement before session startup and on replacement connections. Preserve ordinary client behavior when no provider is configured, and fail closed instead of falling back. Expose explicit no-turn retention through generated session RPCs and a connected-client retain-by-ID helper. Include source, reconnect, runtime and packed-consumer regression coverage and document the public contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a069b1a2-65a9-4427-b3fe-6546a3bffc9e --- nodejs/README.md | 45 ++ nodejs/src/client.ts | 109 +++- nodejs/src/generated/rpc.ts | 154 ++--- nodejs/src/generated/session-events.ts | 96 ++- nodejs/src/index.ts | 6 + nodejs/src/types.ts | 57 ++ .../e2e/extension_launch_provider.e2e.test.ts | 513 +++++++++++++++ .../fixtures/launch-provider-extension.mjs | 51 ++ nodejs/test/extension-launch-provider.test.ts | 605 ++++++++++++++++++ 9 files changed, 1546 insertions(+), 90 deletions(-) create mode 100644 nodejs/test/e2e/extension_launch_provider.e2e.test.ts create mode 100644 nodejs/test/e2e/fixtures/launch-provider-extension.mjs create mode 100644 nodejs/test/extension-launch-provider.test.ts diff --git a/nodejs/README.md b/nodejs/README.md index 7effb81e95..2b8e1a1e43 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -122,6 +122,7 @@ new CopilotClient(options?: CopilotClientOptions) - `telemetry?: TelemetryConfig` - OpenTelemetry configuration for the runtime process. Providing this object enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below. - `onGetTraceContext?: TraceContextProvider` - Advanced: callback for linking your application's own OpenTelemetry spans into the same distributed trace as the runtime's spans. Not needed for normal telemetry collection. See [Telemetry](#telemetry) below. - `sessionFs?: SessionFsConfig` - Custom session filesystem provider. +- `extensionLaunchProvider?: ExtensionLaunchProvider` - Experimental, connection-global resolver for extension process launches. Registration must acknowledge contract version 1 before startup, creation, or resume completes. See [Extension launch providers](#extension-launch-providers-experimental). - `sessionIdleTimeoutSeconds?: number` - Server-wide idle timeout for sessions in seconds. Ignored when connecting via `RuntimeConnection.forUri`. - `enableRemoteSessions?: boolean` - Enable Mission Control remote session support. Ignored when connecting via `RuntimeConnection.forUri`. @@ -178,6 +179,12 @@ Initial acquisition runs during session creation or resume. Cancellation, provid Resume an existing session. Returns the session with `workspacePath` populated if infinite sessions were enabled. +##### `retainSession(sessionId: string): Promise` _(experimental)_ + +Record and flush durable persistence intent by runtime session ID through the already-connected client. Unlike `session.rpc.retain()`, this does not need a returned `CopilotSession`: it can be awaited reentrantly in an extension launch provider while creation or resume is still pending. It sends the canonical `session.retain` RPC using generated bindings, without creating a turn or waiting for the pending session operation. + +The client must already be started. An empty ID, disconnected client, or runtime retention failure rejects; this method never starts or reconnects implicitly. When persistence must precede package startup, propagate retention failure or deny the launch rather than returning an approved profile. + ##### `ping(message?: string): Promise<{ message: string; timestamp: string }>` Ping the server to check connectivity. @@ -373,6 +380,12 @@ Get all events/messages from this session. Disconnect the session and free resources. Session data on disk is preserved for later resumption. +##### `rpc.retain(): Promise` _(experimental)_ + +Record explicit persistence intent for a local session and flush it before returning, even if no user or assistant turn has occurred. Await this after application approval and before an operation that may save data, such as a canvas open. The runtime records the canonical `session.retained` event; no synthetic prompt or model request is needed. + +Retention is idempotent across stop and cold resume and is not undone by a later failed or cancelled operation. It does not grant permissions or prevent explicit deletion. Remote sessions and runtimes without this operation are unsupported; ordinary unused sessions remain ephemeral unless retained. + ##### `capabilities: SessionCapabilities` Host capabilities reported when the session was created or resumed. Use this to check feature support before calling capability-gated APIs. @@ -500,6 +513,38 @@ Note: `assistant.message` and `assistant.reasoning` (final events) are always se ## Advanced Usage +### Extension launch providers (experimental) + +An `extensionLaunchProvider` receives `{ id, name, modulePath, source, sessionId?, defaultLaunch? }` before an extension launches or reloads. It returns `{ launch: profile }` to approve a process profile, or `{}` / `{ launch: null }` to deny execution. Denial, thrown errors, rejected promises, the runtime's 15-second deadline, and shutdown never fall back to the built-in launcher. + +When available, `defaultLaunch` is the runtime's unexecuted built-in Node bootstrap profile. Preserve it when approving that bootstrap. Embeddings without a built-in launcher, including standalone wrappers, may omit it. Use a runtime Node CLI entry through `RuntimeConnection.forStdio({ path })` when relying on this profile. + +The following example delegates revision and session approval to an application-owned function; that function must verify the installed code, not merely recognize a path. It also makes the session durable before any package startup effects: + +```typescript +const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ path: runtimeNodeCliPath }), + extensionLaunchProvider: async (request) => { + if ( + !request.sessionId || + !request.defaultLaunch || + !(await approveInstalledRevision(request)) + ) { + return { launch: null }; + } + await client.retainSession(request.sessionId); + return { launch: request.defaultLaunch }; + }, +}); +await client.start(); +``` + +Package code can run before `createSession()` resolves. Do not await that pending operation or its eventual `CopilotSession` inside the resolver; use the connected client's retain-by-ID binding instead. The runtime routes this operation reentrantly and flushes retention before acknowledging it. After resume, wait for the required extension/canvas registration before opening or invoking it; the resume response is not a readiness barrier. + +The SDK installs the callback before registration and requires the live response `{ contractVersion: 1 }` on every replacement connection. An older null acknowledgement or registration error rejects startup; the SDK never retries with the provider removed. Do not infer support from a CLI version string. Clients that omit this option send no registration request and preserve legacy launch behavior. + +A resolver is not a package trust store, code-integrity check, snapshot mechanism, or sandbox. The application owns revision approval, session/workspace binding, and ensuring the approved code is the code executed. Runtime-managed restrictions still apply. + ### Manual Server Control ```typescript diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6e4b4fb5b4..4487a6e3bd 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -27,6 +27,7 @@ import { } from "vscode-jsonrpc/node.js"; import { createServerRpc, + createSessionRpc, createInternalServerRpc, registerClientGlobalApiHandlers, registerClientSessionApiHandlers, @@ -60,6 +61,7 @@ import type { ExitPlanModeRequest, ExitPlanModeResult, ExtensionJoinOptions, + ExtensionLaunchProvider, ForegroundSessionInfo, GetAuthStatusResponse, BearerTokenProvider, @@ -450,6 +452,7 @@ export class CopilotClient { private state: "disconnected" | "connecting" | "connected" | "error" = "disconnected"; /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */ private startPromise: Promise | null = null; + private startAbortController: AbortController | null = null; private sessions: Map = new Map(); private stderrBuffer: string = ""; // Captures CLI stderr for error messages /** Resolved connection mode chosen in the constructor. */ @@ -493,6 +496,7 @@ export class CopilotClient { private requestHandler: CopilotRequestHandler | null = null; private builtinPluginDirectories: string[] = []; private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; + private extensionLaunchProvider: ExtensionLaunchProvider | null = null; private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; private githubTokenProviders = new Map< string, @@ -504,7 +508,7 @@ export class CopilotClient { * @throws Error if the client is not connected */ get rpc(): ReturnType { - if (!this.connection) { + if (!this.connection || (this.extensionLaunchProvider && this.state !== "connected")) { throw new Error("Client is not connected. Call start() first."); } if (!this._rpc) { @@ -690,6 +694,7 @@ export class CopilotClient { this.sessionFsConfig = options.sessionFs ?? null; this.requestHandler = options.requestHandler ?? null; this.onGitHubTelemetry = options.onGitHubTelemetry; + this.extensionLaunchProvider = options.extensionLaunchProvider ?? null; this.setupClientGlobalHandlers(); // Connection-level env (child-process transports only) takes precedence @@ -855,6 +860,12 @@ export class CopilotClient { }, }; } + if (this.extensionLaunchProvider) { + const provider = this.extensionLaunchProvider; + handlers.extensionLaunchProvider = { + resolve: async (params) => await provider(params), + }; + } handlers.gitHubToken = { getToken: (params) => this.acquireGitHubToken(params), }; @@ -947,10 +958,17 @@ export class CopilotClient { await this.startPromise; } finally { this.startPromise = null; + this.startAbortController = null; } } private async doStart(): Promise { + const controller = new AbortController(); + this.startAbortController = controller; + if (this.connection || this.cliProcess || this.socket || this.ffiHost) { + await this.cleanupConnection(); + } + controller.signal.throwIfAborted(); this.forceStopping = false; this.connectionClosed = false; this.processTransportError = null; @@ -963,12 +981,29 @@ export class CopilotClient { } else if (!this.isExternalServer) { await this.startCLIServer(); } + controller.signal.throwIfAborted(); // Connect to the server await this.connectToServer(); + controller.signal.throwIfAborted(); // Verify protocol version compatibility await this.verifyProtocolVersion(); + controller.signal.throwIfAborted(); + + // A live acknowledgement is required for every connection. An older + // runtime's null response does not guarantee fail-closed resolution. + if (this.extensionLaunchProvider) { + const registration = await createServerRpc( + this.connection! + ).registerExtensionLaunchProvider(); + if (registration?.contractVersion !== 1) { + throw new Error( + "Extension launch provider requires runtime contractVersion 1." + ); + } + controller.signal.throwIfAborted(); + } if (this.builtinPluginDirectories.length > 0) { try { @@ -998,6 +1033,7 @@ export class CopilotClient { await this.connection!.sendRequest("llmInference.setProvider", {}); } + controller.signal.throwIfAborted(); this.state = "connected"; } catch (error) { const startupError = this.processTransportError ?? error; @@ -1032,6 +1068,10 @@ export class CopilotClient { * ``` */ async stop(): Promise { + if (this.startAbortController) { + await this.forceStop(); + return []; + } const errors: Error[] = []; // Disconnect all active sessions with retry logic @@ -1264,6 +1304,11 @@ export class CopilotClient { * ``` */ async forceStop(): Promise { + this.startAbortController?.abort(new Error("Client stopped during startup.")); + await this.cleanupConnection(); + } + + private async cleanupConnection(): Promise { this.forceStopping = true; // Clear sessions immediately without trying to destroy them @@ -1527,7 +1572,7 @@ export class CopilotClient { if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) { throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); } - if (!this.connection) { + if (this.extensionLaunchProvider || !this.connection) { await this.start(); } @@ -1835,7 +1880,7 @@ export class CopilotClient { if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) { throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); } - if (!this.connection) { + if (this.extensionLaunchProvider || !this.connection) { await this.start(); } @@ -2285,6 +2330,32 @@ export class CopilotClient { return (response as { sessionId?: string }).sessionId; } + /** + * Records and flushes durable persistence intent for a local session by ID. + * + * Uses the already-connected runtime directly, without waiting for a + * {@link CopilotSession} or an in-flight create/resume operation. In an + * {@link ExtensionLaunchProvider}, await this before returning an approved + * profile when persistence must precede the extension's top-level code. + * + * This does not start or reconnect the client. Retention failures propagate; + * a launch provider must not approve a launch when retention fails. + * + * @param sessionId - The runtime session ID to retain + * @throws Error if the ID is empty, the client is not connected, or retention fails + * @experimental + */ + async retainSession(sessionId: string): Promise { + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new Error("sessionId must be a non-empty string."); + } + const connection = this.connection; + if (!connection || this.state !== "connected") { + throw new Error("Client is not connected. Call start() first."); + } + await createSessionRpc(connection, sessionId).retain(); + } + /** * Permanently deletes a session and all its data from disk, including * conversation history, planning state, and artifacts. @@ -2697,6 +2768,7 @@ export class CopilotClient { }); } + const child = this.cliProcess; let stdout = ""; let resolved = false; @@ -2747,8 +2819,8 @@ export class CopilotClient { // Set up a promise that rejects when the process exits (used to race against RPC calls) this.processExitPromise = new Promise((_, rejectProcessExit) => { - this.cliProcess!.on("exit", (code) => { - if (this.messageWriter) { + child.on("exit", (code) => { + if (this.cliProcess === child && this.messageWriter) { this.messageWriter.suppressWriteErrors = true; } const stderrOutput = this.stderrBuffer.trim(); @@ -2923,8 +2995,9 @@ export class CopilotClient { // Keep stdin pipe errors inside the normal JSON-RPC teardown path. // Preserve the failure reason via the gated debug log rather than discarding it. - this.cliProcess.stdin?.on("error", (err) => { - if (this.forceStopping) { + const child = this.cliProcess; + child.stdin?.on("error", (err) => { + if (this.forceStopping || this.cliProcess !== child) { return; } this.state = "error"; @@ -2975,24 +3048,34 @@ export class CopilotClient { * Connect to the CLI server via TCP socket */ private async connectViaTcp(): Promise { + if (this.connectionConfig.kind === "uri") { + const { host, port } = this.parseCliUrl(this.connectionConfig.url); + this.actualHost = host; + this.runtimePort = port; + } if (!this.runtimePort) { throw new Error("Server port not available"); } return new Promise((resolve, reject) => { - this.socket = new Socket(); + const socket = new Socket(); + this.socket = socket; const connectionTimeout = setTimeout(() => { - this.socket?.destroy(); + socket.destroy(); reject(new Error("Timeout connecting to CLI server")); }, 10000); - this.socket.connect(this.runtimePort!, this.actualHost, () => { + socket.once("close", () => { + clearTimeout(connectionTimeout); + reject(new Error("Connection closed while connecting to CLI server")); + }); + socket.connect(this.runtimePort!, this.actualHost, () => { clearTimeout(connectionTimeout); // Create JSON-RPC connection - this.messageWriter = new TeardownResilientStreamMessageWriter(this.socket!); + this.messageWriter = new TeardownResilientStreamMessageWriter(socket); this.connection = createMessageConnection( - new StreamMessageReader(this.socket!), + new StreamMessageReader(socket), this.messageWriter ); @@ -3001,7 +3084,7 @@ export class CopilotClient { resolve(); }); - this.socket.on("error", (error) => { + socket.on("error", (error) => { clearTimeout(connectionTimeout); reject(new Error(`Failed to connect to CLI server: ${error.message}`)); }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 09bfc8ffa7..9a534d9bf2 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -3136,6 +3136,20 @@ export type RemoteSessionMetadataTaskType = | "cca" /** CLI remote task. */ | "cli"; +/** + * Provider-native structured output format. JSON Schema is forwarded without rewriting or validating the schema or the generated output. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ResponseFormat". + */ +/** @experimental */ +export type ResponseFormat = { + jsonSchema: JsonSchemaResponseFormat; + /** + * Output format discriminator. Currently only json_schema is supported. + */ + type: "json_schema"; +}; /** * Origin of the sandbox choice supplied by an internal client. * @@ -7880,6 +7894,19 @@ export interface ExtensionLaunchProfile { [k: string]: string | undefined; }; } +/** + * Authoritative capability acknowledgement for the registered extension launch provider. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ExtensionLaunchProviderRegistrationResult". + */ +/** @experimental */ +export interface ExtensionLaunchProviderRegistrationResult { + /** + * Supported extension launch-provider contract version. Clients requiring this contract must check for version 1 before creating or resuming sessions. + */ + contractVersion: 1; +} /** * A discovered extension entrypoint that the registered integrator may classify and resolve to an opaque launch profile. * @@ -7901,16 +7928,24 @@ export interface ExtensionLaunchProviderResolveRequest { */ modulePath: string; source: ExtensionSource; + /** + * Owning runtime session identifier, when known. + */ + sessionId?: string; + defaultLaunch?: ExtensionLaunchProfile; } /** - * The launch profile for a supported entrypoint. Omit launch when the provider does not support the entrypoint. + * The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "ExtensionLaunchProviderResolveResult". */ /** @experimental */ export interface ExtensionLaunchProviderResolveResult { - launch?: ExtensionLaunchProfile; + /** + * Approved launch profile, or absent/null to deny this candidate without fallback. + */ + launch?: ExtensionLaunchProfile | null; } /** * Extensions discovered for the session, with their current status. @@ -10195,6 +10230,31 @@ export interface InterruptMainTurnResult { */ interrupted: boolean; } +/** + * A JSON Schema output contract. OpenAI receives the name, description, schema and strict setting; Anthropic receives the schema in output_config.format and always uses its native strict enforcement. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "JsonSchemaResponseFormat". + */ +/** @experimental */ +export interface JsonSchemaResponseFormat { + /** + * Name of the output schema, subject to the provider's naming restrictions. + */ + name: string; + /** + * JSON Schema passed unchanged to the inference provider. Schemas larger than 32 MiB when JSON-encoded are rejected before admission, using the runtime's existing request-size ceiling. This is not a guarantee that the entire model request fits. Supported keywords and schema restrictions are determined by the provider. + */ + schema: JsonValue; + /** + * Optional description passed to OpenAI providers. + */ + description?: string; + /** + * Optional strict enforcement setting for OpenAI providers. Omitted uses the provider default. Anthropic always enforces its supported schema subset. + */ + strict?: boolean; +} /** * HTTP headers as a map from lowercased header name to a list of values. Multi-valued headers (e.g. Set-Cookie) preserve all values. * @@ -17118,62 +17178,6 @@ export interface RegisterEventInterestResult { */ handle: string; } -/** - * Params to attach an extension loader's tools to a session. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "RegisterExtensionToolsParams". - */ -/** @experimental */ -/** @internal */ -export interface RegisterExtensionToolsParams { - /** - * Session to register extension tools on. - */ - sessionId: string; - /** - * In-process ExtensionLoader handle used only by the CLI and excluded from the public SDK surface. - * - * @internal - * - * @internal - */ - loader: OpaqueInProcessValue; - options?: SessionsRegisterExtensionToolsOnSessionOptions; -} -/** - * Optional registration options. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "SessionsRegisterExtensionToolsOnSessionOptions". - */ -/** @experimental */ -export interface SessionsRegisterExtensionToolsOnSessionOptions { - /** - * In-process `() => boolean` gating callback used only by the CLI. - * - * @internal - */ - enabled?: OpaqueInProcessValue; -} -/** - * Handle for releasing the extension tool registration. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "RegisterExtensionToolsResult". - */ -/** @experimental */ -/** @internal */ -export interface RegisterExtensionToolsResult { - /** - * In-process unsubscribe function used only by the CLI. - * - * @internal - * - * @internal - */ - unsubscribe: OpaqueInProcessValue; -} /** * Opaque handle previously returned by `registerInterest` to release. * @@ -18068,7 +18072,7 @@ export interface SendMessageItem { /** @experimental */ export interface SendMessagesRequest { /** - * The user messages to append to the conversation, in order. May be empty, in which case a single turn runs over the existing history with no new user message. + * The user messages to append to the conversation, in order, before running one agent loop. When the batch starts a run, its final message is the primary initiating message; earlier messages provide context, not separate runs or replies. May be empty, in which case a single turn runs over the existing history with no new user message or originatingMessageId. */ messages: SendMessageItem[]; mode?: SendMode; @@ -18083,6 +18087,7 @@ export interface SendMessagesRequest { requestHeaders?: { [k: string]: string | undefined; }; + responseFormat?: ResponseFormat; /** * W3C Trace Context traceparent header for distributed tracing of this agent turn */ @@ -18105,7 +18110,7 @@ export interface SendMessagesRequest { /** @experimental */ export interface SendMessagesResult { /** - * Unique identifiers assigned to the messages, one per provided message in order. Empty when no messages were provided. + * Unique identifiers assigned to the messages, one per provided message in order. For a batch that starts a run, assistant messages use the final ID as originatingMessageId throughout that run, including tool iterations and stop-hook corrections. Immediate steering does not replace the active run's origin. Empty when no messages were provided; that run has no originatingMessageId. */ messageIds: string[]; } @@ -18155,6 +18160,7 @@ export interface SendRequest { requestHeaders?: { [k: string]: string | undefined; }; + responseFormat?: ResponseFormat; /** * W3C Trace Context traceparent header for distributed tracing of this agent turn */ @@ -24556,11 +24562,13 @@ export function createServerRpc(connection: MessageConnection) { connection.sendRequest("extensions.disable", params), }, /** - * Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. + * Registers the calling SDK client as the authoritative per-entrypoint extension launch provider and returns the supported contract version. Call before creating any sessions. Contract version 1 supplies sessionId and defaultLaunch when available; absent or null launch, provider errors, timeouts, and shutdown cancellation never fall back. Without a registered provider, legacy launching is unchanged. + * + * @returns Authoritative capability acknowledgement for the registered extension launch provider. * * @experimental */ - registerExtensionLaunchProvider: async (): Promise => + registerExtensionLaunchProvider: async (): Promise => connection.sendRequest("registerExtensionLaunchProvider", {}), /** @experimental */ catalog: { @@ -25178,16 +25186,7 @@ export function createInternalServerRpc(connection: MessageConnection) { getBoardEntryCount: async (params: SessionsGetBoardEntryCountRequest): Promise => connection.sendRequest("sessions.getBoardEntryCount", params), /** - * Registers extension-provided tools on the given session, gated by an optional `enabled` callback. Returns an opaque unsubscribe function the caller must invoke to deregister the tools when the extension is torn down. Marked internal because `loader`, `enabled`, and the returned `unsubscribe` are in-process handles that cannot cross the JSON-RPC boundary. Disappears once extension discovery / launch / tool registration are owned by the runtime: SDK consumers will pass pure config (search paths, disabled ids) via `SessionOptions` and the runtime will resolve, launch, register, and tear down extensions itself. - * - * @param params Params to attach an extension loader's tools to a session. - * - * @returns Handle for releasing the extension tool registration. - */ - registerExtensionToolsOnSession: async (params: RegisterExtensionToolsParams): Promise => - connection.sendRequest("sessions.registerExtensionToolsOnSession", params), - /** - * Attaches (or detaches) an in-process ExtensionController delegate for the given session, used by shared-API surfaces that need to query or modify the session's extension state. Pass `controller: undefined` to detach. Marked internal because the controller is an in-process object that cannot cross the JSON-RPC boundary. Disappears alongside `registerExtensionToolsOnSession`: once the runtime owns extension management, the public surface exposes list/enable/disable/reload as dedicated RPCs served by the runtime. + * Attaches (or detaches) an in-process ExtensionController delegate for the given session in a local host adapter. Pass `controller: undefined` to detach. Internal because the controller cannot cross the JSON-RPC boundary; the runtime manages its own session extension service. * * @param params Params to attach or detach an in-process ExtensionController delegate. */ @@ -25200,6 +25199,13 @@ export function createInternalServerRpc(connection: MessageConnection) { /** Create typed session-scoped RPC methods. */ export function createSessionRpc(connection: MessageConnection, sessionId: string) { return { + /** + * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. + * + * @experimental + */ + retain: async (): Promise => + connection.sendRequest("session.retain", { sessionId }), /** * Suspends the session while preserving persisted state for later resume. * @@ -27856,11 +27862,11 @@ export function registerClientSessionApiHandlers( /** @experimental */ export interface ExtensionLaunchProviderHandler { /** - * Asks the registered SDK client to resolve an opaque process launch profile for one discovered extension entrypoint immediately before launch or reload. The provider must respond within 15 seconds. + * Asks the registered SDK client to approve a launch profile immediately before every extension launch or reload. Return defaultLaunch unchanged to approve the runtime's built-in launcher, or return another profile. An absent or null launch denies execution with no fallback. The provider must respond within 15 seconds. Approval does not sandbox code or freeze mutable files; the host is responsible for approved package contents. * * @param params A discovered extension entrypoint that the registered integrator may classify and resolve to an opaque launch profile. * - * @returns The launch profile for a supported entrypoint. Omit launch when the provider does not support the entrypoint. + * @returns The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher. */ resolve(params: ExtensionLaunchProviderResolveRequest): Promise; } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 5bd67b8042..5f4dcfe248 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -20,6 +20,7 @@ export type SessionEvent = | ScheduleCancelledEvent | ScheduleRearmedEvent | AutopilotObjectiveChangedEvent + | RetainedEvent | InfoEvent | WarningEvent | ModelChangeEvent @@ -77,6 +78,7 @@ export type SessionEvent = | ToolExecutionCompleteEvent | ToolSearchActivatedEvent | SkillInvokedEvent + | SkillContextDeliveredEvent | SubagentStartedEvent | SubagentConfiguredEvent | SubagentCompletedEvent @@ -1876,6 +1878,42 @@ export interface AutopilotObjectiveChangedData { operation: AutopilotObjectiveChangedOperation; status?: AutopilotObjectiveChangedStatus; } +/** + * Session event "session.retained". Explicit host intent to persist this local session independently of conversation turns. Emitted by session.retain before a potentially effectful non-chat operation; not a user or assistant message. + */ +/** @experimental */ +export interface RetainedEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: RetainedData; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "session.retained". + */ + type: "session.retained"; +} +/** + * Explicit host intent to persist this local session independently of conversation turns. Emitted by session.retain before a potentially effectful non-chat operation; not a user or assistant message. + */ +/** @experimental */ +export interface RetainedData {} /** * Session event "session.info". Informational message for timeline display with categorization */ @@ -5076,6 +5114,10 @@ export interface AssistantMessageData { * Model that produced this assistant message, if known */ model?: string; + /** + * Logical ID of the primary user message that initiated this run, matching the messageId returned by session.send (or the last messageId of session.sendMessages). Stable across model/tool iterations, steering messages, and stop-hook corrections. Subagent runs use their own initiating message ID, not the parent's. Absent for runs without an associated initiating message, such as empty batches. + */ + originatingMessageId?: string; /** * Actual output token count from the API response (completion_tokens), used for accurate token accounting */ @@ -6540,7 +6582,7 @@ export interface ToolExecutionCompleteResult { */ contents?: ToolExecutionCompleteContent[]; /** - * Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. + * Detailed tool result for UI/timeline display, preserving complete content such as diffs for most tools. Successful skill invocations intentionally use the concise model-facing content here; the authoritative skill body is carried by the corresponding skill invocation event. Falls back to content when absent. */ detailedContent?: string; /** @@ -7102,6 +7144,54 @@ export interface SkillInvokedData { source?: string; trigger?: SkillInvokedTrigger; } +/** + * Session event "skill.context_delivered". Exact skill context delivered to the model during a tool phase. This is not a user submission or another skill invocation. + */ +/** @experimental */ +export interface SkillContextDeliveredEvent { + /** + * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. + */ + agentId?: string; + data: SkillContextDeliveredData; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * Type discriminator. Always "skill.context_delivered". + */ + type: "skill.context_delivered"; +} +/** + * Exact skill context delivered to the model during a tool phase. This is not a user submission or another skill invocation. + */ +export interface SkillContextDeliveredData { + /** + * Exact model-facing skill wrapper, including its invocation-time file context + */ + content: string; + /** + * Interaction that delivered this context, when known + */ + interactionId?: string; + /** + * Unmodified injection provenance, in the form skill- + */ + source: string; +} /** * Session event "subagent.started". Sub-agent startup details including parent tool call and agent information */ @@ -7537,7 +7627,7 @@ export interface HookStartData { */ hookType: string; /** - * Input data passed to the hook. For postToolUse hooks the retained copy served by session.eventLog.read (and by a resumed session) elides the tool result's inline `contents`/`uiResource` and replaces an over-long `textResultForLlm` with a `[copilot:elided ...]` marker, to keep a multi-megabyte payload out of the durable event log; the live subscription stream still delivers the full value. Read the adjacent tool.execution_complete event for the tool result itself. + * Input data passed to the hook. For postToolUse hooks the retained copy served by session.eventLog.read (and by a resumed session) drops the tool result's inline `contents`/`uiResource`/`skillInvocation` and replaces duplicated text result fields with a `[copilot:elided ...]` marker; the live subscription stream still delivers the full value. Canonical tool output remains in the adjacent tool.execution_complete event, while an invoked skill's authoritative body remains in its skill invocation event. */ input?: JsonValue; /** @@ -7589,7 +7679,7 @@ export interface HookEndData { */ hookType: string; /** - * Output data produced by the hook + * Output data produced by the hook. Durable and resumed postToolUse receipts may omit messages owned by a successful skill invocation and replace an unchanged skill sessionLog copy with an elision marker; hook-modified or re-sourced values are preserved, and the authoritative body remains in the skill invocation event. */ output?: JsonValue; /** diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 6251df4fc7..62fba3d0d5 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -92,6 +92,12 @@ export type { ExitPlanModeRequest, ExitPlanModeResult, ExtensionInfo, + ExtensionLaunchProfile, + ExtensionLaunchProvider, + ExtensionLaunchProviderRegistrationResult, + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, + ExtensionSource, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9c4258d9c9..6bbc563dbb 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -22,6 +22,8 @@ import type { import type { CopilotSession } from "./session.js"; import type { FactoryJsonSchema, JsonValue } from "./factory.js"; import type { + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, GitHubTokenAcquireRequest, GitHubTokenAcquireResult, GitHubTelemetryNotification, @@ -31,6 +33,13 @@ import type { CurrentToolMetadata, } from "./generated/rpc.js"; import type { ToolSet } from "./toolSet.js"; +export type { + ExtensionLaunchProfile, + ExtensionLaunchProviderRegistrationResult, + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, + ExtensionSource, +} from "./generated/rpc.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; export type { @@ -66,6 +75,32 @@ export type GitHubTokenProviderResult = GitHubTokenAcquireResult; export type GitHubTokenProvider = ( args: GitHubTokenProviderArgs ) => GitHubTokenProviderResult | Promise; + +/** + * Resolves a process launch profile for one runtime-discovered extension + * entrypoint, immediately before the runtime launches or reloads it. + * + * Return an approved `launch` profile, or return `{}` / `{ launch: null }` to + * deny execution. Denial, callback failure, timeout, and shutdown never fall back + * to the runtime's built-in launcher. When provided, `request.defaultLaunch` + * is the runtime's unexecuted built-in bootstrap profile; approve and return + * it only after checking the candidate and its owning `request.sessionId`. + * Some runtime embeddings have no built-in profile and omit `defaultLaunch`. + * If durable backing must precede package startup, await + * {@link CopilotClient.retainSession} with `request.sessionId` before returning + * the profile. Do not await the pending create/resume or its session facade + * from this callback; extension startup may be blocking that operation. + * + * A launch resolver does not establish package trust, freeze code, verify + * integrity, or sandbox execution. The caller owns revision approval and + * must ensure that the approved code is the code being launched. + * + * The provider must respond within 15 seconds; the runtime enforces this + * deadline independent of the SDK. + */ +export type ExtensionLaunchProvider = ( + request: ExtensionLaunchProviderResolveRequest +) => ExtensionLaunchProviderResolveResult | Promise; export type { ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, @@ -496,6 +531,28 @@ export interface CopilotClientOptions { */ onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; + /** + * Experimental. Registers this client as the connection-global extension + * launch provider before {@link CopilotClient.start} returns and before + * creating or resuming sessions. Each connection must acknowledge exactly + * `contractVersion: 1`; unsupported or failed registration rejects startup + * without retrying with the provider disabled. + * + * When set, the runtime asks this callback to resolve a process launch + * profile for each extension entrypoint it discovers, immediately before + * launching or reloading it, instead of using its built-in launcher for + * that entrypoint. When unset (the default), the runtime's built-in + * launcher handles every entrypoint and no registration request is sent — + * fully backward compatible with clients that never set this option. + * + * An omitted or null `launch` denies execution without fallback, as do + * errors and timeouts. See {@link ExtensionLaunchProvider} for approval + * responsibilities; this option is not a package trust or integrity store. + * + * @experimental + */ + extensionLaunchProvider?: ExtensionLaunchProvider; + /** * Server-wide idle timeout for sessions in seconds. * Sessions without activity for this duration are automatically cleaned up. diff --git a/nodejs/test/e2e/extension_launch_provider.e2e.test.ts b/nodejs/test/e2e/extension_launch_provider.e2e.test.ts new file mode 100644 index 0000000000..46a1dd775d --- /dev/null +++ b/nodejs/test/e2e/extension_launch_provider.e2e.test.ts @@ -0,0 +1,513 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, onTestFinished } from "vitest"; +import { + approveAll, + CopilotClient, + CopilotRequestHandler, + RuntimeConnection, + type CopilotSession, + type CopilotWebSocketHandler, + type ExtensionLaunchProvider, + type ExtensionLaunchProviderResolveRequest, + type ExtensionLaunchProviderResolveResult, + type SessionConfig, +} from "../../src/index.js"; +import { retry } from "./harness/sdkTestHelper.js"; + +// The published runtime does not yet promise this experimental contract. Opt in +// with an explicit Node CLI entry, not a version string or the standalone wrapper. +const runtimePath = process.env.COPILOT_EXTENSION_LAUNCH_TEST_CLI; +const sdkPath = fileURLToPath(new URL("../../dist/", import.meta.url)); +const scratchPath = fileURLToPath(new URL("../../../.local/canvas-tests/", import.meta.url)); +const extensionFixture = fileURLToPath( + new URL("./fixtures/launch-provider-extension.mjs", import.meta.url) +); +const extensionId = "plugin:launch-provider:marker"; + +class OfflineRequests extends CopilotRequestHandler { + requests = 0; + + protected override async sendRequest(): Promise { + this.requests++; + throw new Error("This test must not issue a model request"); + } + + protected override async openWebSocket(): Promise { + this.requests++; + throw new Error("This test must not open a model WebSocket"); + } +} + +function readPids(directory: string, marker = "startup-pids"): number[] { + const path = join(directory, marker); + return existsSync(path) + ? readFileSync(path, "utf8").trim().split("\n").filter(Boolean).map(Number) + : []; +} + +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function createLaunchContext() { + if (!runtimePath) { + throw new Error("Set COPILOT_EXTENSION_LAUNCH_TEST_CLI to the runtime Node CLI entry"); + } + if (!existsSync(join(sdkPath, "extension.js"))) { + throw new Error("Build the SDK with npm run build before running this suite"); + } + await mkdir(scratchPath, { recursive: true }); + const root = await mkdtemp(join(scratchPath, "launch-provider-")); + const home = join(root, "home"); + const copilotHome = join(root, "copilot-home"); + const workspace = join(root, "workspace"); + const dataDirectory = join(root, "data"); + const pluginDirectory = join(root, "installed", "revision-one"); + const extensionDirectory = join(pluginDirectory, "extensions", "marker"); + await Promise.all( + [home, copilotHome, workspace, dataDirectory, extensionDirectory].map((path) => + mkdir(path, { recursive: true }) + ) + ); + await writeFile( + join(pluginDirectory, "plugin.json"), + JSON.stringify({ name: "launch-provider", version: "1.0.0" }) + ); + await copyFile(extensionFixture, join(extensionDirectory, "extension.mjs")); + execFileSync("git", ["init", "--quiet"], { cwd: workspace }); + + const requests = new OfflineRequests(); + const clients: CopilotClient[] = []; + onTestFinished(async () => { + try { + for (const client of clients) { + await client.stop(); + } + await retry("reap fixture processes", async () => { + expect(readPids(extensionDirectory).filter(isRunning)).toEqual([]); + }); + expect(requests.requests).toBe(0); + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); + } + }); + + const createClient = (provider?: ExtensionLaunchProvider) => { + const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ path: runtimePath }), + extensionLaunchProvider: provider, + mode: "empty", + baseDirectory: copilotHome, + useLoggedInUser: false, + workingDirectory: workspace, + requestHandler: requests, + env: { + PATH: process.env.PATH ?? "", + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + HOME: home, + USERPROFILE: home, + COPILOT_HOME: copilotHome, + GH_CONFIG_DIR: home, + XDG_CONFIG_HOME: home, + XDG_STATE_HOME: home, + XDG_CACHE_HOME: home, + APPDATA: home, + LOCALAPPDATA: home, + TMPDIR: root, + TEMP: root, + TMP: root, + COPILOT_DISABLE_KEYTAR: "1", + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", + COPILOT_OTEL_ENABLED: "false", + DO_NOT_TRACK: "1", + }, + }); + clients.push(client); + return client; + }; + + const sessionConfig: SessionConfig = { + onPermissionRequest: approveAll, + availableTools: [], + workingDirectory: workspace, + enableExperimentalMode: true, + requestExtensions: true, + pluginDirectories: [pluginDirectory], + extensionSdkPath: sdkPath, + }; + const approve = (request: ExtensionLaunchProviderResolveRequest) => { + if (request.id !== extensionId || !request.defaultLaunch) { + return { launch: null }; + } + return { + launch: { + ...request.defaultLaunch, + env: { + ...request.defaultLaunch.env, + VSCODE_CANVAS_DATA_DIR: dataDirectory, + }, + }, + }; + }; + const waitForLaunch = async (session: CopilotSession, count: number) => { + await retry("start the approved extension", async () => { + const pids = readPids(extensionDirectory, "ready-pids"); + expect(pids).toHaveLength(count); + const extensions = await session.rpc.extensions.list(); + expect(extensions.extensions).toContainEqual( + expect.objectContaining({ + id: extensionId, + status: "running", + pid: pids.at(-1), + }) + ); + }); + }; + const expectDenied = async (session: CopilotSession) => { + await retry( + "settle denied extension startup", + async () => { + const extensions = await session.rpc.extensions.list(); + expect(extensions.extensions).toContainEqual( + expect.objectContaining({ id: extensionId, status: "failed" }) + ); + }, + 200 + ); + expect(readPids(extensionDirectory)).toEqual([]); + }; + + return { + createClient, + sessionConfig, + approve, + waitForLaunch, + expectDenied, + extensionDirectory, + dataDirectory, + workspace, + copilotHome, + requests, + }; +} + +describe.skipIf(!runtimePath)("Extension launch provider — real Node runtime", () => { + it("approves the runtime bootstrap on create, reload, and cold resume", async () => { + const context = await createLaunchContext(); + const calls: ExtensionLaunchProviderResolveRequest[] = []; + const client = context.createClient((request) => { + calls.push(request); + return context.approve(request); + }); + const session = await client.createSession(context.sessionConfig); + await context.waitForLaunch(session, 1); + expect(calls).toEqual([ + expect.objectContaining({ + id: extensionId, + sessionId: session.sessionId, + source: "plugin", + modulePath: join(context.extensionDirectory, "extension.mjs"), + defaultLaunch: expect.objectContaining({ + executable: expect.any(String), + args: expect.arrayContaining([ + expect.stringContaining("extension_bootstrap.mjs"), + ]), + env: expect.objectContaining({ + COPILOT_SDK_PATH: sdkPath, + SESSION_ID: session.sessionId, + }), + }), + }), + ]); + await session.rpc.retain(); + await session.rpc.extensions.reload(); + await context.waitForLaunch(session, 2); + expect(calls).toHaveLength(2); + expect(await client.stop()).toEqual([]); + await retry("stop pre-resume extension processes", async () => { + expect(readPids(context.extensionDirectory).filter(isRunning)).toEqual([]); + }); + + const resumed = await client.resumeSession(session.sessionId, context.sessionConfig); + await context.waitForLaunch(resumed, 3); + expect(calls.map((request) => request.sessionId)).toEqual([ + session.sessionId, + session.sessionId, + session.sessionId, + ]); + expect(new Set(readPids(context.extensionDirectory)).size).toBe(3); + expect(await resumed.getEvents()).not.toContainEqual( + expect.objectContaining({ type: "user.message" }) + ); + }); + + it.each(["omitted", "null", "sync-error", "async-error"])( + "never launches the default process after %s", + async (outcome) => { + const context = await createLaunchContext(); + const calls: ExtensionLaunchProviderResolveRequest[] = []; + const client = context.createClient((request) => { + calls.push(request); + if (outcome === "sync-error") { + throw new Error("Fixture approval lookup failed"); + } + if (outcome === "async-error") { + return Promise.reject(new Error("Fixture approval lookup failed")); + } + return outcome === "null" ? { launch: null } : {}; + }); + const session = await client.createSession(context.sessionConfig); + await context.expectDenied(session); + expect(calls).toHaveLength(1); + expect(calls[0].defaultLaunch).toBeDefined(); + await session.rpc.extensions.reload(); + await context.expectDenied(session); + expect(calls).toHaveLength(2); + } + ); + + it("leaves the built-in launcher unchanged when the provider option is absent", async () => { + const context = await createLaunchContext(); + const client = context.createClient(); + const session = await client.createSession(context.sessionConfig); + await context.waitForLaunch(session, 1); + expect(readPids(context.extensionDirectory)).toHaveLength(1); + }); + + it("denies a timed-out resolver without launching the default profile", async () => { + const context = await createLaunchContext(); + const calls: ExtensionLaunchProviderResolveRequest[] = []; + const client = context.createClient((request) => { + calls.push(request); + return new Promise(() => {}); + }); + const session = await client.createSession(context.sessionConfig); + await context.expectDenied(session); + expect(calls).toHaveLength(1); + expect(calls[0].defaultLaunch).toBeDefined(); + }); + + it("does not carry an outstanding callback across shutdown and process replacement", async () => { + const context = await createLaunchContext(); + const calls: ExtensionLaunchProviderResolveRequest[] = []; + let release!: (result: ExtensionLaunchProviderResolveResult) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + let block = true; + const client = context.createClient((request) => { + calls.push(request); + return block ? pending : context.approve(request); + }); + const creating = client.createSession(context.sessionConfig); + const settled = Promise.allSettled([creating]); + await retry("receive the blocked callback", async () => { + expect(calls).toHaveLength(1); + }); + await client.forceStop(); + await settled; + expect(readPids(context.extensionDirectory)).toEqual([]); + + block = false; + await client.start(); + release(context.approve(calls[0])); + const replacement = await client.createSession(context.sessionConfig); + await context.waitForLaunch(replacement, 1); + expect(calls).toHaveLength(2); + expect(calls[0].sessionId).not.toBe(replacement.sessionId); + expect(calls[1].sessionId).toBe(replacement.sessionId); + expect(readPids(context.extensionDirectory)).toHaveLength(1); + }); + + it("retains non-chat effects across stop and cold resume without inventing a turn", async () => { + const context = await createLaunchContext(); + const client = context.createClient(context.approve); + const session = await client.createSession(context.sessionConfig); + await context.waitForLaunch(session, 1); + const retention: Promise = session.rpc.retain(); + await retention; + await session.rpc.retain(); + await session.rpc.canvas.open({ + canvasId: "launch-marker", + instanceId: "saved-open", + }); + await expect( + session.rpc.canvas.open({ + canvasId: "launch-marker", + instanceId: "failed-open", + input: { fail: true }, + }) + ).rejects.toThrow("Fixture open failed after saving data"); + await session.abort(); + + const events = await session.getEvents(); + const retained = events.filter((event) => event.type === "session.retained"); + expect(retained).toEqual([expect.objectContaining({ data: {} })]); + expect(events.filter((event) => /^(user|assistant)\./.test(event.type))).toEqual([]); + expect(await client.stop()).toEqual([]); + expect(readFileSync(join(context.dataDirectory, "operations"), "utf8")).toBe( + "saved-open\nfailed-open\n" + ); + + const freshClient = context.createClient(context.approve); + const resumed = await freshClient.resumeSession(session.sessionId, context.sessionConfig); + await context.waitForLaunch(resumed, 2); + await resumed.rpc.retain(); + const restoredEvents = await resumed.getEvents(); + expect(restoredEvents.filter((event) => event.type === "session.retained")).toEqual( + retained + ); + expect(restoredEvents.filter((event) => /^(user|assistant)\./.test(event.type))).toEqual( + [] + ); + expect((await freshClient.listSessions()).map((entry) => entry.sessionId)).toContain( + session.sessionId + ); + }); + + it.each([false, true])( + "flushes retention before the first package startup (defer plugin roots: %s)", + async (deferPluginRoots) => { + const context = await createLaunchContext(); + const sessionId = randomUUID(); + const config: SessionConfig = { ...context.sessionConfig, sessionId }; + await writeFile( + join(context.extensionDirectory, "retention-events-path"), + join(context.copilotHome, "session-state", sessionId, "events.jsonl") + ); + const calls: ExtensionLaunchProviderResolveRequest[] = []; + let launchAdmitted = false; + const client = context.createClient((request) => { + calls.push(request); + return launchAdmitted ? context.approve(request) : { launch: null }; + }); + + const inert = await client.createSession({ + ...config, + requestExtensions: false, + pluginDirectories: deferPluginRoots ? [] : config.pluginDirectories, + }); + await expect(inert.rpc.extensions.list()).resolves.toEqual({ extensions: [] }); + expect(calls).toEqual([]); + expect(readPids(context.extensionDirectory)).toEqual([]); + await inert.rpc.retain(); + const retained = (await inert.getEvents()).filter( + (event) => event.type === "session.retained" + ); + expect(retained).toEqual([expect.objectContaining({ data: {} })]); + await inert.disconnect(); + + launchAdmitted = true; + const active = await client.resumeSession(sessionId, config); + await context.waitForLaunch(active, 1); + expect(readPids(context.extensionDirectory, "retained-startup-pids")).toEqual( + readPids(context.extensionDirectory) + ); + expect(calls.map((request) => request.sessionId)).toEqual([sessionId]); + const events = await active.getEvents(); + expect(events.filter((event) => event.type === "session.retained")).toEqual(retained); + expect(events.filter((event) => /^(user|assistant)\./.test(event.type))).toEqual([]); + } + ); + + it.each([false, true])( + "retains by ID inside the resolver before initial create completes (yield: %s)", + async (yieldBeforeRetain) => { + const context = await createLaunchContext(); + const sessionId = randomUUID(); + await writeFile( + join(context.extensionDirectory, "retention-events-path"), + join(context.copilotHome, "session-state", sessionId, "events.jsonl") + ); + let operationResolved = false; + const observedResolved: boolean[] = []; + const client: CopilotClient = context.createClient(async (request) => { + if (!request.sessionId || !request.defaultLaunch) { + return { launch: null }; + } + observedResolved.push(operationResolved); + if (yieldBeforeRetain) { + await new Promise((resolve) => setImmediate(resolve)); + } + await client.retainSession(request.sessionId); + return context.approve(request); + }); + const config: SessionConfig = { ...context.sessionConfig, sessionId }; + const session = await client.createSession(config); + operationResolved = true; + await context.waitForLaunch(session, 1); + expect(observedResolved).toEqual([false]); + expect(readPids(context.extensionDirectory, "retained-startup-pids")).toEqual( + readPids(context.extensionDirectory) + ); + const retained = (await session.getEvents()).filter( + (event) => event.type === "session.retained" + ); + expect(retained).toEqual([expect.objectContaining({ data: {} })]); + expect(await client.stop()).toEqual([]); + + operationResolved = false; + const resumed = await client.resumeSession(sessionId, config); + operationResolved = true; + await context.waitForLaunch(resumed, 2); + expect(observedResolved).toHaveLength(2); + expect(readPids(context.extensionDirectory, "retained-startup-pids")).toEqual( + readPids(context.extensionDirectory) + ); + const events = await resumed.getEvents(); + expect(events.filter((event) => event.type === "session.retained")).toEqual(retained); + expect(events.filter((event) => /^(user|assistant)\./.test(event.type))).toEqual([]); + } + ); + + it("does not launch when the resolver's retain-by-ID call fails", async () => { + const context = await createLaunchContext(); + const client: CopilotClient = context.createClient(async (request) => { + await client.retainSession("missing-runtime-session"); + return context.approve(request); + }); + const session = await client.createSession(context.sessionConfig); + await context.expectDenied(session); + expect( + (await session.getEvents()).filter((event) => event.type === "session.retained") + ).toEqual([]); + }); + + it("keeps ordinary unused sessions ephemeral", async () => { + const context = await createLaunchContext(); + const client = context.createClient(); + const session = await client.createSession({ + ...context.sessionConfig, + sessionId: randomUUID(), + requestExtensions: false, + pluginDirectories: [], + }); + expect(await client.stop()).toEqual([]); + const freshClient = context.createClient(); + await freshClient.start(); + expect((await freshClient.listSessions()).map((entry) => entry.sessionId)).not.toContain( + session.sessionId + ); + await expect( + freshClient.resumeSession(session.sessionId, { + ...context.sessionConfig, + requestExtensions: false, + pluginDirectories: [], + }) + ).rejects.toThrow(); + }); +}); diff --git a/nodejs/test/e2e/fixtures/launch-provider-extension.mjs b/nodejs/test/e2e/fixtures/launch-provider-extension.mjs new file mode 100644 index 0000000000..00f716b0a2 --- /dev/null +++ b/nodejs/test/e2e/fixtures/launch-provider-extension.mjs @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// When requested by the test, observe native durability before the first write. +const retentionEventsPath = new URL("./retention-events-path", import.meta.url); +if (existsSync(retentionEventsPath)) { + const events = readFileSync(readFileSync(retentionEventsPath, "utf8"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + if (!events.some((event) => event.type === "session.retained")) { + throw new Error("Package startup preceded durable session retention"); + } + appendFileSync(new URL("./retained-startup-pids", import.meta.url), `${process.pid}\n`); +} + +// This marker precedes all SDK code: a denied candidate must not reach it. +appendFileSync(new URL("./startup-pids", import.meta.url), `${process.pid}\n`); + +const { createCanvas } = await import("@github/copilot-sdk"); +const { joinSession } = await import("@github/copilot-sdk/extension"); + +await joinSession({ + canvases: [ + createCanvas({ + id: "launch-marker", + displayName: "Launch Marker", + description: "Records a local non-chat operation for SDK integration tests.", + inputSchema: { + type: "object", + properties: { fail: { type: "boolean" } }, + }, + open: (context) => { + appendFileSync( + join(process.env.VSCODE_CANVAS_DATA_DIR, "operations"), + `${context.instanceId}\n` + ); + if (context.input?.fail) { + throw new Error("Fixture open failed after saving data"); + } + return { url: "https://example.test/launch-marker", title: "Launch Marker" }; + }, + }), + ], +}); + +appendFileSync(new URL("./ready-pids", import.meta.url), `${process.pid}\n`); diff --git a/nodejs/test/extension-launch-provider.test.ts b/nodejs/test/extension-launch-provider.test.ts new file mode 100644 index 0000000000..ed91e38327 --- /dev/null +++ b/nodejs/test/extension-launch-provider.test.ts @@ -0,0 +1,605 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { PassThrough } from "node:stream"; +import { createServer, type Server, type Socket } from "node:net"; +import { + createMessageConnection, + ErrorCodes, + type MessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; +import { + approveAll, + CopilotClient, + RuntimeConnection, + type ExtensionLaunchProvider, + type ExtensionLaunchProviderResolveRequest, +} from "../src/index.js"; +import { registerClientGlobalApiHandlers } from "../src/generated/rpc.js"; + +// This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead. +// +// These tests exercise the public `extensionLaunchProvider` client option end to end over a +// real TCP socket (no CLI subprocess involved): a hand-scripted fake server plays the runtime +// side of the wire protocol so we can assert exact request ordering, dispatch, and error +// semantics without depending on a real Copilot CLI binary being present. + +const sampleRequest: ExtensionLaunchProviderResolveRequest = { + id: "project:legacy-extension", + name: "Legacy extension", + modulePath: "/extensions/legacy/index.js", + source: "project", + sessionId: "session-1", + defaultLaunch: { + executable: "/usr/bin/node", + args: ["/runtime/extension-bootstrap.mjs"], + env: { EXTENSION_PATH: "/extensions/legacy/index.js" }, + }, +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +/** Minimal fake runtime server: answers `connect` and lets a test script the rest. */ +class FakeRuntimeServer { + private server: Server; + private connections: MessageConnection[] = []; + private sockets: Socket[] = []; + private port = 0; + + private constructor(server: Server) { + this.server = server; + } + + static async start(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const fake = new FakeRuntimeServer(server); + const address = server.address(); + if (address && typeof address === "object") { + fake.port = address.port; + } + return fake; + } + + get url(): string { + return `localhost:${this.port}`; + } + + /** Accepts the next incoming connection and wires it as a JSON-RPC peer, replying to `connect`. */ + async acceptOne(): Promise { + const socket = await new Promise((resolve) => { + this.server.once("connection", resolve); + }); + this.sockets.push(socket); + const connection = createMessageConnection( + new StreamMessageReader(socket), + new StreamMessageWriter(socket) + ); + this.connections.push(connection); + connection.onRequest("connect", async () => ({ + ok: true, + protocolVersion: 3, + version: "test", + })); + connection.listen(); + return connection; + } + + async close(): Promise { + for (const connection of this.connections) { + connection.dispose(); + } + for (const socket of this.sockets) { + socket.destroy(); + } + await new Promise((resolve) => this.server.close(() => resolve())); + } +} + +describe("extensionLaunchProvider client option", () => { + let servers: FakeRuntimeServer[] = []; + + afterEach(async () => { + await Promise.all(servers.map((s) => s.close())); + servers = []; + }); + + it("registers no handler and sends no registration request when omitted (backward compatible)", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const client = new CopilotClient({ connection: RuntimeConnection.forUri(server.url) }); + onTestFinished(() => client.forceStop()); + + const serverConnectionPromise = server.acceptOne(); + const startPromise = client.start(); + const accepted = await serverConnectionPromise; + const registerSpy = vi.fn(); + accepted.onRequest("registerExtensionLaunchProvider", async () => { + registerSpy(); + return null; + }); + + await startPromise; + + expect(registerSpy).not.toHaveBeenCalled(); + await expect( + accepted.sendRequest("extensionLaunchProvider.resolve", sampleRequest) + ).rejects.toThrow("No extensionLaunchProvider client-global handler registered"); + }); + + it("registers the provider before start() resolves and dispatches a resolve request", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const calls: ExtensionLaunchProviderResolveRequest[] = []; + const provider: ExtensionLaunchProvider = (request) => { + calls.push(request); + return { launch: { executable: "/app/copilot", args: ["run"], env: {} } }; + }; + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: provider, + }); + onTestFinished(() => client.forceStop()); + + const serverConnectionPromise = server.acceptOne(); + const startPromise = client.start(); + const serverConnection = await serverConnectionPromise; + + let registered = false; + serverConnection.onRequest("registerExtensionLaunchProvider", async () => { + registered = true; + return { contractVersion: 1 }; + }); + + // `start()` only resolves once the registration round trip completes, so by + // the time it resolves the provider is guaranteed to be wired up. + await startPromise; + expect(registered).toBe(true); + + const resolveResult = await serverConnection.sendRequest( + "extensionLaunchProvider.resolve", + sampleRequest + ); + expect(resolveResult).toEqual({ + launch: { executable: "/app/copilot", args: ["run"], env: {} }, + }); + expect(calls).toEqual([sampleRequest]); + }); + + it.each(["synchronous", "asynchronous"])( + "propagates a %s callback failure as a JSON-RPC error", + async (kind) => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const failure = new Error("extension profile lookup failed"); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: + kind === "synchronous" + ? () => { + throw failure; + } + : async () => { + throw failure; + }, + }); + onTestFinished(() => client.forceStop()); + + const serverConnectionPromise = server.acceptOne(); + const startPromise = client.start(); + const serverConnection = await serverConnectionPromise; + serverConnection.onRequest("registerExtensionLaunchProvider", async () => ({ + contractVersion: 1, + })); + await startPromise; + + await expect( + serverConnection.sendRequest("extensionLaunchProvider.resolve", sampleRequest) + ).rejects.toMatchObject({ + code: ErrorCodes.InternalError, + message: expect.stringContaining("extension profile lookup failed"), + }); + } + ); + + it.each([{}, { launch: null }])( + "forwards explicit denial %j without substituting a default launch", + async (denial) => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: () => denial, + }); + onTestFinished(() => client.forceStop()); + + const serverConnectionPromise = server.acceptOne(); + const startPromise = client.start(); + const serverConnection = await serverConnectionPromise; + serverConnection.onRequest("registerExtensionLaunchProvider", async () => ({ + contractVersion: 1, + })); + await startPromise; + + const result = await serverConnection.sendRequest( + "extensionLaunchProvider.resolve", + sampleRequest + ); + expect(result).toEqual(denial); + } + ); + + it("re-registers when the same client reconnects instead of reusing prior acknowledgement", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const seen: string[][] = []; + + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: () => ({ + launch: { executable: "/copilot", args: [], env: {} }, + }), + }); + onTestFinished(() => client.forceStop()); + + for (let attempt = 0; attempt < 2; attempt++) { + const serverConnectionPromise = server.acceptOne(); + const startPromise = client.start(); + const serverConnection = await serverConnectionPromise; + const registrations: string[] = []; + serverConnection.onRequest("registerExtensionLaunchProvider", async () => { + registrations.push("registered"); + return { contractVersion: 1 }; + }); + await startPromise; + seen.push(registrations); + await client.stop(); + } + + // Each connection independently re-sends its own registration request; the + // second client's registration is not skipped or merged with the first's. + expect(seen).toEqual([["registered"], ["registered"]]); + }); + + it("waits for registration before concurrent start, create, and resume calls complete", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const acknowledgement = deferred<{ contractVersion: 1 }>(); + const registering = deferred(); + const calls: string[] = []; + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: (request) => ({ launch: request.defaultLaunch }), + }); + onTestFinished(() => client.forceStop()); + + const accepted = server.acceptOne(); + const starting = client.start(); + const connection = await accepted; + connection.onRequest("registerExtensionLaunchProvider", async () => { + calls.push("register"); + expect( + await connection.sendRequest("extensionLaunchProvider.resolve", sampleRequest) + ).toEqual({ launch: sampleRequest.defaultLaunch }); + registering.resolve(); + await acknowledgement.promise; + calls.push("acknowledge"); + return { contractVersion: 1 }; + }); + for (const method of ["session.create", "session.resume"]) { + connection.onRequest(method, (params: { sessionId: string }) => { + calls.push(method); + return { sessionId: params.sessionId }; + }); + } + await registering.promise; + const restarting = client.start(); + const creating = client.createSession({ + sessionId: "created", + onPermissionRequest: approveAll, + }); + const resuming = client.resumeSession("resumed", { + onPermissionRequest: approveAll, + }); + expect(() => client.rpc).toThrow("Call start() first"); + await expect(client.retainSession("not-yet-admitted")).rejects.toThrow( + "Call start() first" + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(calls).toEqual(["register"]); + acknowledgement.resolve({ contractVersion: 1 }); + + await Promise.all([starting, restarting, creating, resuming]); + expect(calls).toEqual(["register", "acknowledge", "session.create", "session.resume"]); + }); + + it("retains by ID over the live connection while create is blocked on the resolver", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const calls: string[] = []; + let createResolved = false; + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: async (request) => { + if (!request.sessionId) { + return { launch: null }; + } + expect(createResolved).toBe(false); + await client.retainSession(request.sessionId); + calls.push("approve"); + return { launch: request.defaultLaunch }; + }, + }); + onTestFinished(() => client.forceStop()); + const accepted = server.acceptOne(); + const starting = client.start(); + const connection = await accepted; + connection.onRequest("registerExtensionLaunchProvider", () => ({ contractVersion: 1 })); + connection.onRequest("session.retain", (params: { sessionId: string }) => { + calls.push(`retain:${params.sessionId}`); + return null; + }); + connection.onRequest("session.create", async (params: { sessionId: string }) => { + calls.push("create"); + const result = await connection.sendRequest("extensionLaunchProvider.resolve", { + ...sampleRequest, + sessionId: params.sessionId, + }); + expect(result).toEqual({ launch: sampleRequest.defaultLaunch }); + calls.push("created"); + return { sessionId: params.sessionId }; + }); + await starting; + // No local CopilotSession is needed, even for an ID unknown to this SDK. + await expect(client.retainSession("existing-runtime-session")).resolves.toBeUndefined(); + const created = await client.createSession({ + sessionId: "new-session", + onPermissionRequest: approveAll, + }); + createResolved = true; + expect(created.sessionId).toBe("new-session"); + expect(calls).toEqual([ + "retain:existing-runtime-session", + "create", + "retain:new-session", + "approve", + "created", + ]); + }); + + it("rejects empty IDs and disconnected retention without starting the client", async () => { + const client = new CopilotClient({ + connection: RuntimeConnection.forUri("localhost:1"), + }); + onTestFinished(() => client.forceStop()); + const start = vi.spyOn(client, "start"); + await expect(client.retainSession("")).rejects.toThrow("non-empty string"); + await expect(client.retainSession("session-1")).rejects.toThrow("Call start() first"); + expect(start).not.toHaveBeenCalled(); + }); + + it("propagates retain-by-ID errors and rejects pending retention on shutdown", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: () => ({ launch: null }), + }); + onTestFinished(() => client.forceStop()); + const accepted = server.acceptOne(); + const starting = client.start(); + const connection = await accepted; + const retaining = deferred(); + connection.onRequest("registerExtensionLaunchProvider", () => ({ contractVersion: 1 })); + connection.onRequest("session.retain", (params: { sessionId: string }) => { + if (params.sessionId === "missing") { + throw new Error("Session persistence is unavailable"); + } + retaining.resolve(); + return new Promise(() => {}); + }); + await starting; + await expect(client.retainSession("missing")).rejects.toThrow( + "Session persistence is unavailable" + ); + const pending = expect(client.retainSession("pending")).rejects.toThrow(); + await retaining.promise; + await client.forceStop(); + await pending; + await expect(client.retainSession("pending")).rejects.toThrow("Call start() first"); + }); + + it.each([ + null, + {}, + { contractVersion: 0 }, + { contractVersion: 2 }, + { contractVersion: "1" }, + 1, + ])("rejects unsupported registration %j without any session request", async (response) => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: () => ({ launch: null }), + }); + onTestFinished(() => client.forceStop()); + const accepted = server.acceptOne(); + const starting = client.start(); + const creating = client.createSession({ onPermissionRequest: approveAll }); + const resuming = client.resumeSession("existing", { + onPermissionRequest: approveAll, + }); + const settled = Promise.allSettled([starting, creating, resuming]); + const connection = await accepted; + const calls: string[] = []; + connection.onRequest((method) => { + calls.push(method); + return response; + }); + + const results = await settled; + expect(results).toEqual( + Array.from({ length: 3 }, () => ({ + status: "rejected", + reason: expect.objectContaining({ + message: "Extension launch provider requires runtime contractVersion 1.", + }), + })) + ); + expect(calls).toEqual(["registerExtensionLaunchProvider"]); + expect(() => client.rpc).toThrow("Call start() first"); + }); + + it("propagates registration errors and retries only with the provider still configured", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const provider = vi.fn(() => ({ launch: sampleRequest.defaultLaunch })); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: provider, + }); + onTestFinished(() => client.forceStop()); + const calls: string[] = []; + + const firstConnection = server.acceptOne(); + const firstStart = client.start(); + const rejected = expect(firstStart).rejects.toThrow("registration unavailable"); + (await firstConnection).onRequest("registerExtensionLaunchProvider", () => { + calls.push("failed registration"); + throw new Error("registration unavailable"); + }); + await rejected; + + const secondConnection = server.acceptOne(); + const secondStart = client.start(); + const connection = await secondConnection; + connection.onRequest("registerExtensionLaunchProvider", () => { + calls.push("new registration"); + return { contractVersion: 1 }; + }); + await secondStart; + await connection.sendRequest("extensionLaunchProvider.resolve", sampleRequest); + expect(calls).toEqual(["failed registration", "new registration"]); + expect(provider).toHaveBeenCalledExactlyOnceWith(sampleRequest); + }); + + it("rejects a replacement runtime that acknowledges an older contract", async () => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: () => ({ launch: null }), + }); + onTestFinished(() => client.forceStop()); + for (const response of [{ contractVersion: 1 }, null]) { + const accepted = server.acceptOne(); + const starting = client.start(); + const assertion = + response === null + ? expect(starting).rejects.toThrow("contractVersion 1") + : expect(starting).resolves.toBeUndefined(); + (await accepted).onRequest("registerExtensionLaunchProvider", () => response); + await assertion; + await client.stop(); + } + }); + + it.each(["stop", "forceStop"] as const)( + "%s cancels registration instead of admitting a session", + async (method) => { + const server = await FakeRuntimeServer.start(); + servers.push(server); + const registering = deferred(); + const acknowledgement = deferred<{ contractVersion: 1 }>(); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + extensionLaunchProvider: () => ({ launch: null }), + }); + onTestFinished(() => client.forceStop()); + const accepted = server.acceptOne(); + const creating = client.createSession({ onPermissionRequest: approveAll }); + const assertion = expect(creating).rejects.toThrow(); + const connection = await accepted; + connection.onRequest("registerExtensionLaunchProvider", () => { + registering.resolve(); + return acknowledgement.promise; + }); + const create = vi.fn(); + connection.onRequest("session.create", create); + await registering.promise; + await client[method](); + await assertion; + acknowledgement.resolve({ contractVersion: 1 }); + expect(create).not.toHaveBeenCalled(); + expect(() => client.rpc).toThrow("Call start() first"); + } + ); + + it("surfaces the generic client-global-handler error when no provider is registered on a bare connection", async () => { + const clientToServer = new PassThrough(); + const serverToClient = new PassThrough(); + const clientConn = createMessageConnection( + new StreamMessageReader(serverToClient), + new StreamMessageWriter(clientToServer) + ); + const serverConn = createMessageConnection( + new StreamMessageReader(clientToServer), + new StreamMessageWriter(serverToClient) + ); + onTestFinished(() => { + clientConn.dispose(); + serverConn.dispose(); + }); + + registerClientGlobalApiHandlers(clientConn, {}); + clientConn.listen(); + serverConn.listen(); + + await expect( + serverConn.sendRequest("extensionLaunchProvider.resolve", sampleRequest) + ).rejects.toThrow("No extensionLaunchProvider client-global handler registered"); + }); +}); + +describe("requestCanvasRenderer does not imply extension launch provider authority", () => { + it("session-level requestCanvasRenderer leaves the client-global provider unset", async () => { + const server = await FakeRuntimeServer.start(); + onTestFinished(() => server.close()); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri(server.url), + }); + onTestFinished(() => client.forceStop()); + const accepted = server.acceptOne(); + const creating = client.createSession({ + requestCanvasRenderer: true, + onPermissionRequest: approveAll, + }); + const connection = await accepted; + const requests: Array<{ method: string; params: object }> = []; + connection.onRequest((method: string, params: { sessionId: string }) => { + requests.push({ method, params }); + return { sessionId: params.sessionId }; + }); + await creating; + expect(requests).toEqual([ + { + method: "session.create", + params: expect.objectContaining({ requestCanvasRenderer: true }), + }, + ]); + await expect( + connection.sendRequest("extensionLaunchProvider.resolve", sampleRequest) + ).rejects.toThrow("No extensionLaunchProvider client-global handler registered"); + }); +});