From 4c4da3ec190c3dcbafec913861addfe0eb8cdc50 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sat, 12 Sep 2026 09:33:05 +0200 Subject: [PATCH 1/2] nodejs: feat: expose canvas launch admission and retention Attach the connection-owned launch provider before handshake and require an explicit v1 acknowledgement before session creation or resume. Expose global and scoped no-turn retention with canonical null results, and forward initial script-safety configuration before new extension work. Make cancellation teardown safe before registration and under overlapping request/connection cancellation, preserving synchronous resolver errors. Add focused public loopback coverage and document runtime/release limits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 79 ++ nodejs/src/client.ts | 53 +- nodejs/src/extensionLaunchProvider.ts | 117 +++ nodejs/src/generated/rpc.ts | 91 +- nodejs/src/generated/session-events.ts | 37 + nodejs/src/index.ts | 7 + nodejs/src/types.ts | 47 + nodejs/test/client.test.ts | 1 + nodejs/test/extension-launch-provider.test.ts | 947 ++++++++++++++++++ nodejs/tsconfig.test.json | 7 +- scripts/codegen/typescript.ts | 29 +- 11 files changed, 1378 insertions(+), 37 deletions(-) create mode 100644 nodejs/src/extensionLaunchProvider.ts create mode 100644 nodejs/test/extension-launch-provider.test.ts diff --git a/nodejs/README.md b/nodejs/README.md index 7effb81e95..4c1397e73d 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?: ExtensionLaunchProviderHandler` - Experimental, connection-owned extension launch admission. Requires explicit runtime contract version 1; see [Extension launch admission](#extension-launch-admission-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`. @@ -131,6 +132,84 @@ new CopilotClient(options?: CopilotClientOptions) Start the CLI server and establish connection. +##### Extension launch admission (experimental) + +Configure `extensionLaunchProvider` before starting the client. The SDK attaches +the handler before the RPC handshake, registers it once per connection, and requires +`{ contractVersion: 1 }` before allowing session creation or resume. An older +runtime's null acknowledgement, an unsupported version, or a registration error +rejects startup. Omitting the option preserves legacy launching. + +```typescript +const client = new CopilotClient({ + extensionLaunchProvider: { + async resolve(request, cancellation) { + // approveRevision is the embedding application's source-admission routine. + if (!(await approveRevision(request, cancellation))) { + return { launch: null }; + } + if (!request.sessionId || !request.defaultLaunch) { + throw new Error("This launch requires session and runtime bootstrap context"); + } + await client.rpc.session.retain({ sessionId: request.sessionId }); + return { launch: request.defaultLaunch }; + }, + }, +}); +await client.start(); +``` + +The request preserves the source-qualified ID, name, original module path, +source (`project`, `user`, `plugin`, or `session`), and optional `sessionId` and +`defaultLaunch`. The latter is the runtime's unexecuted executable, arguments, +and bootstrap environment overrides, not its inherited environment. Do not +invent missing session IDs or reconstruct private bootstrap paths. + +The handler must respond within the runtime's 15-second deadline. An absent/null +launch, callback error, timeout, or cancellation denies execution without a +fallback. The optional transport cancellation token also signals disconnect and +stop. Reconnection requires a fresh registration; approvals are not cached or +replayed. A shared runtime may keep a disconnected provider authoritative to +prevent a fallback to legacy launching. If it rejects replacement registration, +the SDK surfaces that error; it does not take over the old registration. Restarting +an SDK-owned runtime permits fresh negotiation. Shared-runtime reattachment +requires support from the runtime contract. + +This contract does not sandbox Node, freeze files or dependencies, or +implement source-revision approval or immediate revocation. + +`await client.rpc.session.retain({ sessionId })` works reentrantly while +`createSession` is pending. After creation, `await session.rpc.retain()` performs +the same operation. Both return the runtime's `null` acknowledgement only after +durable retention and writer flush, and propagate errors. Retention is idempotent, +requires a local session, and creates no prompt, turn, title, permission grant, or +provider process. It preserves session storage across shutdown and cold resume, +not volatile extension memory, and does not prevent explicit deletion. + +Approve the source revision, await retention, then return the approved launch +recipe: top-level extension code can have effects before `joinSession` or canvas +open. Create/resume completion is not registry readiness; wait for the expected +entry in `session.rpc.canvas.list()` or a registry-change event before opening it. + +For read-only shell-command classification from the first new extension operation, +pass `enableScriptSafety: true` in the initial `createSession` and `resumeSession` +configurations, rather than only updating options after they return. Commands +classified as read-only may run without a permission prompt, subject to runtime +and managed policy. This is not blanket tool approval, a policy override, or +retroactive protection for already-running extensions. + +The setting is in-memory, not persisted by retention. An omitted cold-resume +setting uses the runtime default (classification disabled); omission on a resident +resume preserves the current value. Hosts requiring classification should supply +`true` on every create and cold resume. Explicit `false` and omission are forwarded +without an SDK default. + +These bindings require a runtime implementing the launch v1 and retention +contracts and initial script-safety configuration. The checked-in CLI pin alone +does not establish their availability; an older runtime rejects these opt-in +operations. Publishing and qualifying a matching SDK/runtime pair is a separate +release step. + ##### `stop(): Promise` Stop the server and close all sessions. Returns a list of any errors encountered during cleanup. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 035f0f5e60..ebbca4f730 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -33,6 +33,7 @@ import { } from "./generated/rpc.js"; import type { ConnectClientInfo, + ExtensionLaunchProviderHandler, GitHubTelemetryNotification, GitHubTokenAcquireRequest, GitHubTokenAcquireResult, @@ -41,6 +42,7 @@ import type { TaskKind, } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; +import { ExtensionLaunchProviderConnection } from "./extensionLaunchProvider.js"; import { CopilotSession } from "./session.js"; import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; import { ensureRuntimeBundle } from "./runtimeArtifacts.js"; @@ -491,6 +493,8 @@ export class CopilotClient { /** Connection-level session filesystem config, set via constructor option. */ private sessionFsConfig: SessionFsConfig | null = null; private requestHandler: CopilotRequestHandler | null = null; + private extensionLaunchProvider?: ExtensionLaunchProviderHandler; + private extensionLaunchProviderConnection?: ExtensionLaunchProviderConnection; private builtinPluginDirectories: string[] = []; private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; @@ -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.connectionClosed) { throw new Error("Client is not connected. Call start() first."); } if (!this._rpc) { @@ -689,6 +693,7 @@ export class CopilotClient { this.onGetTraceContext = options.onGetTraceContext; this.sessionFsConfig = options.sessionFs ?? null; this.requestHandler = options.requestHandler ?? null; + this.extensionLaunchProvider = options.extensionLaunchProvider; this.onGitHubTelemetry = options.onGitHubTelemetry; this.setupClientGlobalHandlers(); @@ -951,6 +956,9 @@ export class CopilotClient { } private async doStart(): Promise { + if (this.connectionClosed) { + await this.forceStop(); + } this.forceStopping = false; this.connectionClosed = false; this.processTransportError = null; @@ -966,6 +974,7 @@ export class CopilotClient { // Connect to the server await this.connectToServer(); + const launchProviderConnection = this.extensionLaunchProviderConnection; // Verify protocol version compatibility await this.verifyProtocolVersion(); @@ -998,6 +1007,7 @@ export class CopilotClient { await this.connection!.sendRequest("llmInference.setProvider", {}); } + await launchProviderConnection?.register(); this.state = "connected"; } catch (error) { const startupError = this.processTransportError ?? error; @@ -1033,6 +1043,7 @@ export class CopilotClient { */ async stop(): Promise { const errors: Error[] = []; + this.extensionLaunchProviderConnection?.dispose(); // Disconnect all active sessions with retry logic const activeSessions = [...this.sessions.values()]; @@ -1218,6 +1229,7 @@ export class CopilotClient { this.runtimePort = null; this.stderrBuffer = ""; this.processExitPromise = null; + this.extensionLaunchProviderConnection = undefined; return errors; } @@ -1265,6 +1277,7 @@ export class CopilotClient { */ async forceStop(): Promise { this.forceStopping = true; + this.extensionLaunchProviderConnection?.dispose(); // Clear sessions immediately without trying to destroy them for (const session of this.sessions.values()) { @@ -1331,6 +1344,7 @@ export class CopilotClient { this.runtimePort = null; this.stderrBuffer = ""; this.processExitPromise = null; + this.extensionLaunchProviderConnection = undefined; } /** @@ -1526,7 +1540,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.connection || this.startPromise || this.connectionClosed) { await this.start(); } @@ -1682,6 +1696,7 @@ export class CopilotClient { enableSessionTelemetry: config.enableSessionTelemetry, enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, + enableScriptSafety: config.enableScriptSafety, sessionLimits: config.sessionLimits, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), @@ -1834,7 +1849,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.connection || this.startPromise || this.connectionClosed) { await this.start(); } @@ -1927,6 +1942,7 @@ export class CopilotClient { excludedBuiltinAgents: config.excludedBuiltinAgents, enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, + enableScriptSafety: config.enableScriptSafety, sessionLimits: config.sessionLimits, tools: config.tools?.map((tool) => ({ name: tool.name, @@ -2805,8 +2821,13 @@ export class CopilotClient { case "inprocess": return this.connectViaFfi(); case "tcp": - case "uri": return this.connectViaTcp(); + case "uri": { + const { host, port } = this.parseCliUrl(this.connectionConfig.url); + this.actualHost = host; + this.runtimePort = port; + return this.connectViaTcp(); + } } } @@ -3066,7 +3087,20 @@ 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); + const connection = this.connection; + const globalHandlers = { ...this.clientGlobalHandlers }; + this._rpc = createServerRpc(connection); + if (this.extensionLaunchProvider) { + const provider = new ExtensionLaunchProviderConnection( + this.extensionLaunchProvider, + this._rpc.registerExtensionLaunchProvider + ); + this.extensionLaunchProviderConnection = provider; + this._rpc.registerExtensionLaunchProvider = () => provider.register(); + globalHandlers.extensionLaunchProvider = provider.handler; + } + const launchProviderConnection = this.extensionLaunchProviderConnection; + registerClientGlobalApiHandlers(connection, globalHandlers); // `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 @@ -3079,8 +3113,8 @@ export class CopilotClient { } ); - const connection = this.connection; const markDisconnected = () => { + launchProviderConnection?.dispose(); if (this.connection !== connection) { return; } @@ -3093,11 +3127,8 @@ export class CopilotClient { this.githubTokenProviders.clear(); }; this.connection.onClose(markDisconnected); - this.connection.onError(() => { - if (this.connection === connection) { - this.state = "disconnected"; - } - }); + this.connection.onDispose(markDisconnected); + this.connection.onError(markDisconnected); } private handleSessionEventNotification(notification: unknown): void { diff --git a/nodejs/src/extensionLaunchProvider.ts b/nodejs/src/extensionLaunchProvider.ts new file mode 100644 index 0000000000..f434f7cb1a --- /dev/null +++ b/nodejs/src/extensionLaunchProvider.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { + CancellationTokenSource, + ResponseError, + type CancellationToken, + type Disposable, +} from "vscode-jsonrpc/node.js"; +import type { + ExtensionLaunchProviderHandler, + ExtensionLaunchProviderRegistrationResult, + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, +} from "./generated/rpc.js"; + +function cancelled(): ResponseError { + return new ResponseError(-32800, "Extension launch provider request cancelled"); +} + +async function withCancellation(run: () => Promise, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + throw cancelled(); + } + let subscription: Disposable | undefined; + const cancellation = new Promise((_, reject) => { + subscription = token.onCancellationRequested(() => reject(cancelled())); + }); + try { + // Invoke synchronously, but turn throws into promises before observing both race inputs. + const operation = (async () => run())(); + const result = await Promise.race([operation, cancellation]); + if (token.isCancellationRequested) { + throw cancelled(); + } + return result; + } finally { + subscription?.dispose(); + } +} + +/** One launch-provider registration and its outstanding requests on a single connection. */ +export class ExtensionLaunchProviderConnection { + private readonly lifetime = new CancellationTokenSource(); + private registration?: Promise; + private registered = false; + + readonly handler: ExtensionLaunchProviderHandler = { + resolve: (params, token) => this.resolve(params, token), + }; + + constructor( + private readonly provider: ExtensionLaunchProviderHandler, + private readonly registerProvider: () => Promise + ) {} + + async register(): Promise { + if (this.lifetime.token.isCancellationRequested) { + throw cancelled(); + } + this.registration ??= withCancellation(async () => { + const result = await this.registerProvider(); + if (result?.contractVersion !== 1) { + throw new Error( + "Extension launch provider requires contract version 1; the runtime did not acknowledge it." + ); + } + if (this.lifetime.token.isCancellationRequested) { + throw cancelled(); + } + this.registered = true; + return result; + }, this.lifetime.token); + return this.registration; + } + + dispose(): void { + // Materialize the lazy token before cancelling, and make teardown reentrant. + if (this.lifetime.token.isCancellationRequested) { + return; + } + this.lifetime.cancel(); + this.lifetime.dispose(); + } + + private async resolve( + params: ExtensionLaunchProviderResolveRequest, + token?: CancellationToken + ): Promise { + if (!this.registered) { + throw new Error("Extension launch provider contract has not been acknowledged"); + } + const request = new CancellationTokenSource(); + const requestToken = request.token; + const cancelRequest = () => { + if (!requestToken.isCancellationRequested) { + request.cancel(); + } + }; + const connectionSubscription = this.lifetime.token.onCancellationRequested(cancelRequest); + const requestSubscription = token?.onCancellationRequested(cancelRequest); + if (this.lifetime.token.isCancellationRequested || token?.isCancellationRequested) { + cancelRequest(); + } + try { + return await withCancellation( + () => this.provider.resolve(params, requestToken), + requestToken + ); + } finally { + connectionSubscription.dispose(); + requestSubscription?.dispose(); + request.dispose(); + } + } +} diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index b2ce8c05e9..3ef8e43c25 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -3,7 +3,7 @@ * Generated from: api.schema.json */ -import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; import type { AbortReason, AgentModelPolicy, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, RemediationAction, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; @@ -7676,16 +7676,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. @@ -23957,6 +23965,32 @@ export interface WorkspacesWriteAutopilotObjectiveResult { */ operation: string; } +/** + * 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; +} +/** + * Identifies the target session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SessionRetainRequest". + */ +/** @experimental */ +export interface SessionRetainRequest { + /** + * Target session identifier + */ + sessionId: string; +} /** @experimental */ export interface SessionFactoryPauseAtCheckpointResult { @@ -24304,11 +24338,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: { @@ -24850,6 +24886,16 @@ export function createServerRpc(connection: MessageConnection) { spawn: async (params: AgentRegistrySpawnRequest): Promise => connection.sendRequest("agentRegistry.spawn", params), }, + /** @experimental */ + session: { + /** + * 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. + * + * @param params Identifies the target session. + */ + retain: async (params: SessionRetainRequest): Promise => + connection.sendRequest("session.retain", params), + }, }; } @@ -24948,6 +24994,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. * @@ -27604,13 +27657,13 @@ 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; + resolve(params: ExtensionLaunchProviderResolveRequest, token?: CancellationToken): Promise; } /** Handler for `llmInference` client global API methods. */ @@ -27623,7 +27676,7 @@ export interface LlmInferenceHandler { * * @returns Acknowledgement. Returning successfully simply means the SDK accepted the start frame; it does not imply the request will succeed. */ - httpRequestStart(params: LlmInferenceHttpRequestStartRequest): Promise; + httpRequestStart(params: LlmInferenceHttpRequestStartRequest, token?: CancellationToken): Promise; /** * Delivers a body byte range (or a cancellation signal) for a request previously announced via httpRequestStart, correlated by requestId. The runtime fires at least one chunk per request — when there is no body, a single chunk with empty data and end=true. Mid-stream the runtime may send a chunk with cancel=true to abort the request; the SDK then stops issuing httpResponseChunk frames and may emit a terminal httpResponseChunk with error set. * @@ -27631,7 +27684,7 @@ export interface LlmInferenceHandler { * * @returns Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as fire-and-forget. */ - httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest): Promise; + httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest, token?: CancellationToken): Promise; } /** Handler for `gitHubTelemetry` client global API methods. */ @@ -27655,7 +27708,7 @@ export interface GitHubTokenHandler { * * @returns SDK host response to a GitHub credential request. */ - getToken(params: GitHubTokenAcquireRequest): Promise; + getToken(params: GitHubTokenAcquireRequest, token?: CancellationToken): Promise; } /** All client global API handler groups. */ @@ -27677,29 +27730,29 @@ export function registerClientGlobalApiHandlers( connection: MessageConnection, handlers: ClientGlobalApiHandlers, ): void { - connection.onRequest("extensionLaunchProvider.resolve", async (params: ExtensionLaunchProviderResolveRequest) => { + connection.onRequest("extensionLaunchProvider.resolve", async (params: ExtensionLaunchProviderResolveRequest, token: CancellationToken) => { const handler = handlers.extensionLaunchProvider; if (!handler) throw new Error("No extensionLaunchProvider client-global handler registered"); - return handler.resolve(params); + return handler.resolve(params, token); }); - connection.onRequest("llmInference.httpRequestStart", async (params: LlmInferenceHttpRequestStartRequest) => { + connection.onRequest("llmInference.httpRequestStart", async (params: LlmInferenceHttpRequestStartRequest, token: CancellationToken) => { const handler = handlers.llmInference; if (!handler) throw new Error("No llmInference client-global handler registered"); - return handler.httpRequestStart(params); + return handler.httpRequestStart(params, token); }); - connection.onRequest("llmInference.httpRequestChunk", async (params: LlmInferenceHttpRequestChunkRequest) => { + connection.onRequest("llmInference.httpRequestChunk", async (params: LlmInferenceHttpRequestChunkRequest, token: CancellationToken) => { const handler = handlers.llmInference; if (!handler) throw new Error("No llmInference client-global handler registered"); - return handler.httpRequestChunk(params); + return handler.httpRequestChunk(params, token); }); connection.onNotification("gitHubTelemetry.event", async (params: GitHubTelemetryNotification) => { const handler = handlers.gitHubTelemetry; if (!handler) return; await handler.event(params); }); - connection.onRequest("gitHubToken.getToken", async (params: GitHubTokenAcquireRequest) => { + connection.onRequest("gitHubToken.getToken", async (params: GitHubTokenAcquireRequest, token: CancellationToken) => { const handler = handlers.gitHubToken; if (!handler) throw new Error("No gitHubToken client-global handler registered"); - return handler.getToken(params); + return handler.getToken(params, token); }); } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index b33830dc7d..b738c850bd 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 @@ -1849,6 +1850,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 */ diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 6251df4fc7..4912bace7d 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -92,6 +92,12 @@ export type { ExitPlanModeRequest, ExitPlanModeResult, ExtensionInfo, + ExtensionLaunchProfile, + ExtensionLaunchProviderHandler, + ExtensionLaunchProviderRegistrationResult, + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, + ExtensionSource, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, @@ -171,6 +177,7 @@ export type { SessionContext, SessionListFilter, SessionMetadata, + SessionRetainRequest, SessionUiApi, SessionFsConfig, SessionFsProvider, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index efff9b47df..9947d8458e 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -22,6 +22,7 @@ import type { import type { CopilotSession } from "./session.js"; import type { FactoryJsonSchema, JsonValue } from "./factory.js"; import type { + ExtensionLaunchProviderHandler, GitHubTokenAcquireRequest, GitHubTokenAcquireResult, GitHubTelemetryNotification, @@ -29,10 +30,20 @@ import type { OpenCanvasInstance, RemoteSessionMode, CurrentToolMetadata, + SessionOpenOptions, } from "./generated/rpc.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; +export type { + ExtensionLaunchProfile, + ExtensionLaunchProviderHandler, + ExtensionLaunchProviderRegistrationResult, + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, + ExtensionSource, + SessionRetainRequest, +} from "./generated/rpc.js"; export type { GitHubTokenAcquireReason, GitHubTokenAcquireResult, @@ -484,6 +495,30 @@ export interface CopilotClientOptions { */ requestHandler?: CopilotRequestHandler; + /** + * Connection-owned extension launch admission handler. + * + * Attached before the RPC handshake. `start()` registers it and requires an explicit + * contract-version-1 acknowledgement before create/resume can proceed. + * Missing support, invalid acknowledgements, and registration errors reject + * startup; they never opt back into the runtime's legacy launcher. + * + * Each resolve receives the original source identity and optional runtime + * session/default-launch context. Return `defaultLaunch` unchanged only + * after approving the source and completing any required retention through + * `client.rpc.session.retain({ sessionId })`. An absent or null launch denies + * execution. The optional cancellation token is cancelled on request + * cancellation, disconnect, or stop; late results are not reused. + * + * Reconnecting negotiates a new registration and resolves each launch anew. + * A runtime that keeps a disconnected provider authoritative may refuse + * replacement; that error is propagated rather than bypassing the old owner. + * Omitting this option preserves legacy runtime extension behavior. + * + * @experimental + */ + extensionLaunchProvider?: ExtensionLaunchProviderHandler; + /** * Experimental. Receives GitHub telemetry events the runtime forwards to * this connection. When set, the client opts each session it creates or @@ -2563,6 +2598,18 @@ export interface SessionConfigBase { */ enableFileChangeTracking?: boolean; + /** + * Enables read-only classification of built-in shell commands. When true, + * commands classified as read-only may run without a permission prompt, + * subject to runtime policy. This is not an extension sandbox. + * + * Applied during session creation or resume, before new extension + * initialization. Omission preserves the runtime's existing behavior. + * + * @experimental + */ + enableScriptSafety?: SessionOpenOptions["enableScriptSafety"]; + /** * Limits applied to this session's current accounting window. * diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 119ed40a10..40daf5f4c5 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -4130,6 +4130,7 @@ describe("CopilotClient", () => { onNotification: vi.fn(), onRequest: vi.fn(), onClose: vi.fn(), + onDispose: vi.fn(), onError: vi.fn(), }; diff --git a/nodejs/test/extension-launch-provider.test.ts b/nodejs/test/extension-launch-provider.test.ts new file mode 100644 index 0000000000..7a075da3b8 --- /dev/null +++ b/nodejs/test/extension-launch-provider.test.ts @@ -0,0 +1,947 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { createServer, type Socket } from "node:net"; +import { setTimeout } from "node:timers/promises"; +import { describe, expect, expectTypeOf, it, onTestFinished, vi } from "vitest"; +import { + CancellationTokenSource, + createMessageConnection, + ErrorCodes, + ResponseError, + StreamMessageReader, + StreamMessageWriter, + type CancellationToken, + type MessageConnection, +} from "vscode-jsonrpc/node.js"; +import { + CopilotClient, + RuntimeConnection, + type CopilotClientOptions, + type ExtensionLaunchProfile, + type ExtensionLaunchProviderHandler, + type ExtensionLaunchProviderRegistrationResult, + type ExtensionLaunchProviderResolveRequest, + type ExtensionLaunchProviderResolveResult, + type ExtensionSource, + type PermissionRequestedEvent, + type ResumeSessionConfig, + type RetainedEvent, + type SessionConfig, + type SessionEvent, + type SessionRetainRequest, +} from "../src/index.js"; +import { ExtensionLaunchProviderConnection } from "../src/extensionLaunchProvider.js"; +import type { PermissionDecisionRequest, SessionOpenOptions } from "../src/generated/rpc.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +// A synthetic loopback runtime peer, not an injected client connection or handler table. +async function runtimePeer(configure: (connection: MessageConnection) => void = () => {}) { + const peers: { connection: MessageConnection; socket: Socket }[] = []; + const clients: CopilotClient[] = []; + const server = createServer((socket) => { + const connection = createMessageConnection( + new StreamMessageReader(socket), + new StreamMessageWriter(socket) + ); + peers.push({ connection, socket }); + connection.onRequest("connect", () => ({ protocolVersion: 3 })); + connection.onRequest("registerExtensionLaunchProvider", () => ({ contractVersion: 1 })); + connection.onRequest("session.create", (params: { sessionId: string }) => ({ + sessionId: params.sessionId, + })); + connection.onRequest("session.resume", (params: { sessionId: string }) => ({ + sessionId: params.sessionId, + })); + connection.onRequest("session.detach", () => ({ success: true })); + connection.onClose(() => connection.dispose()); + configure(connection); + connection.listen(); + }); + onTestFinished(async () => { + const errors: Error[] = []; + try { + for (const client of clients) { + errors.push(...(await client.stop())); + } + } finally { + for (const peer of peers) { + peer.connection.dispose(); + peer.socket.destroy(); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + expect(errors).toEqual([]); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a loopback TCP listener"); + } + return { + peers, + client(options: Omit = {}) { + const client = new CopilotClient({ + ...options, + connection: RuntimeConnection.forUri(`127.0.0.1:${address.port}`), + }); + clients.push(client); + return client; + }, + }; +} + +const profile: ExtensionLaunchProfile = { + executable: "/synthetic/bin/node", + args: ["--import", "/original directory/bootstrap.mjs", "/original directory/extension.mjs"], + env: { SYNTHETIC_LITERAL: "literal value", COPILOT_SDK_PATH: "/runtime-selected/sdk" }, +}; +const candidate: ExtensionLaunchProviderResolveRequest = { + id: "project:fixture", + name: "fixture", + modulePath: "/original directory/extension.mjs", + source: "project", + sessionId: "unit-session", + defaultLaunch: profile, +}; +const grant: ExtensionLaunchProviderHandler = { + resolve: async (request) => ({ launch: request.defaultLaunch }), +}; + +describe("public script safety lifecycle configuration", () => { + it("uses the canonical optional setting for both public configs", () => { + expectTypeOf().toEqualTypeOf< + SessionOpenOptions["enableScriptSafety"] + >(); + expectTypeOf().toEqualTypeOf< + boolean | undefined + >(); + }); + + describe.each(["create", "resume"])("%s", (operation) => { + it.each([undefined, false, true])( + "forwards %j before the resolver and pre-return permission handling", + async (enableScriptSafety) => { + let returned = false; + const order: string[] = []; + const events: SessionEvent[] = []; + const responded = deferred(); + const runtime = await runtimePeer((connection) => { + connection.onRequest( + "session.permissions.handlePendingPermissionRequest", + (params: PermissionDecisionRequest & SessionRetainRequest) => { + expect(params).toEqual({ + sessionId: "script-safety-session", + requestId: "early-permission", + result: { kind: "reject" }, + }); + order.push("permission-response"); + responded.resolve(); + return { success: true }; + } + ); + connection.onRequest( + `session.${operation}`, + async ( + params: SessionRetainRequest & + Pick + ) => { + expect(params.enableScriptSafety).toBe(enableScriptSafety); + expect(Object.hasOwn(params, "enableScriptSafety")).toBe( + enableScriptSafety !== undefined + ); + order.push("initial-request"); + await expect( + connection.sendRequest("extensionLaunchProvider.resolve", { + ...candidate, + sessionId: params.sessionId, + }) + ).resolves.toEqual({ launch: profile }); + const event: PermissionRequestedEvent = { + type: "permission.requested", + id: randomUUID(), + timestamp: new Date().toISOString(), + parentId: null, + data: { + requestId: "early-permission", + permissionRequest: { + kind: "shell", + canOfferSessionApproval: false, + commands: [], + fullCommandText: "pwd", + hasWriteFileRedirection: false, + intention: "Synthetic early permission routing", + possiblePaths: [], + possibleUrls: [], + }, + }, + }; + await connection.sendNotification("session.event", { + sessionId: params.sessionId, + event, + }); + await responded.promise; + expect(returned).toBe(false); + return { sessionId: params.sessionId }; + } + ); + }); + const client = runtime.client({ + extensionLaunchProvider: { + resolve: async () => { + expect(returned).toBe(false); + order.push("resolver"); + return { launch: profile }; + }, + }, + }); + const config: SessionConfig = { + ...(enableScriptSafety === undefined ? {} : { enableScriptSafety }), + requestExtensions: true, + onEvent: (event) => events.push(event), + onPermissionRequest: (_, context) => { + expect(returned).toBe(false); + expect(context.sessionId).toBe("script-safety-session"); + order.push("permission-handler"); + return { kind: "reject" }; + }, + }; + const session = + operation === "create" + ? await client.createSession({ + ...config, + sessionId: "script-safety-session", + }) + : await client.resumeSession("script-safety-session", config); + returned = true; + expect(session.sessionId).toBe("script-safety-session"); + expect(order).toEqual([ + "initial-request", + "resolver", + "permission-handler", + "permission-response", + ]); + expect(events.map((event) => event.type)).toEqual(["permission.requested"]); + } + ); + }); +}); + +describe("public extension launch provider attachment", () => { + it("does not register a provider when the option is omitted", async () => { + const register = vi.fn(() => ({ contractVersion: 1 })); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", register); + }); + const client = runtime.client(); + const session = await client.createSession({}); + await client.resumeSession(session.sessionId, {}); + expect(register).not.toHaveBeenCalled(); + }); + + describe.each(["start", "create", "resume"])("%s negotiation", (operation) => { + it.each([ + null, + undefined, + {}, + { contractVersion: 0 }, + { contractVersion: 2 }, + { contractVersion: "1" }, + ])( + "rejects an old or invalid acknowledgement %j before creating/resuming", + async (acknowledgement) => { + const create = vi.fn(); + const resume = vi.fn(); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", () => acknowledgement); + connection.onRequest("session.create", create); + connection.onRequest("session.resume", resume); + }); + const client = runtime.client({ extensionLaunchProvider: grant }); + const result = + operation === "start" + ? client.start() + : operation === "create" + ? client.createSession({}) + : client.resumeSession("unit-session", {}); + await expect(result).rejects.toThrow("requires contract version 1"); + expect(create).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(() => client.rpc).toThrow("not connected"); + } + ); + }); + + it.each([ErrorCodes.MethodNotFound, -32001])( + "preserves registration error %s without a fallback", + async (code) => { + const runtime = await runtimePeer((connection) => { + connection.onRequest( + "registerExtensionLaunchProvider", + () => + new ResponseError(code, "registration refused", { owner: "another-client" }) + ); + }); + const client = runtime.client({ extensionLaunchProvider: grant }); + await expect(client.start()).rejects.toMatchObject({ + code, + message: "registration refused", + data: { owner: "another-client" }, + }); + expect(() => client.rpc).toThrow("not connected"); + } + ); + + it("gates overlapping start/create/resume calls and registers once per connection", async () => { + const entered = deferred(); + const acknowledgement = deferred(); + const register = vi.fn(() => { + entered.resolve(); + return acknowledgement.promise; + }); + const create = vi.fn((params: { sessionId: string }) => ({ sessionId: params.sessionId })); + const resume = vi.fn((params: { sessionId: string }) => ({ sessionId: params.sessionId })); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", register); + connection.onRequest("session.create", create); + connection.onRequest("session.resume", resume); + }); + const client = runtime.client({ extensionLaunchProvider: grant }); + const start = client.start(); + await entered.promise; + const creating = client.createSession({ sessionId: "created" }); + const resuming = client.resumeSession("resumed", {}); + await setTimeout(20); + expect(create).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + acknowledgement.resolve({ contractVersion: 1 }); + await Promise.all([start, client.start(), creating, resuming]); + await expect(client.rpc.registerExtensionLaunchProvider()).resolves.toEqual({ + contractVersion: 1, + }); + await expect(client.rpc.registerExtensionLaunchProvider()).resolves.toEqual({ + contractVersion: 1, + }); + expect(register).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledTimes(1); + }); + + it("attaches before registration but does not approve before acknowledgement", async () => { + const resolve = vi.fn(grant.resolve); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", async () => { + await expect( + connection.sendRequest("extensionLaunchProvider.resolve", candidate) + ).rejects.toThrow("has not been acknowledged"); + return { contractVersion: 1 }; + }); + }); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + await client.start(); + expect(resolve).not.toHaveBeenCalled(); + await expect( + runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", candidate) + ).resolves.toEqual({ launch: profile }); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it.each(["project", "user", "plugin", "session"])( + "preserves %s source, path, identity, context and opaque launch recipe", + async (source) => { + const request: ExtensionLaunchProviderResolveRequest = { + ...candidate, + id: `${source}:fixture`, + source, + }; + const resolve = vi.fn(grant.resolve); + const runtime = await runtimePeer(); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + await client.start(); + await expect( + runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", request) + ).resolves.toEqual({ launch: profile }); + expect(resolve.mock.calls[0][0]).toEqual(request); + expect(resolve.mock.calls[0][1]?.isCancellationRequested).toBe(false); + } + ); + + it("does not invent optional session or bootstrap context", async () => { + const request: ExtensionLaunchProviderResolveRequest = { + id: candidate.id, + name: candidate.name, + modulePath: candidate.modulePath, + source: candidate.source, + }; + const resolve = vi.fn(async () => ({})); + const runtime = await runtimePeer(); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + await client.start(); + await expect( + runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", request) + ).resolves.toEqual({}); + expect(resolve).toHaveBeenCalledWith(request, expect.anything()); + }); + + it.each([{}, { launch: null }])( + "preserves an explicit denial %j", + async (denial) => { + const runtime = await runtimePeer(); + const client = runtime.client({ + extensionLaunchProvider: { resolve: async () => denial }, + }); + await client.start(); + await expect( + runtime.peers[0].connection.sendRequest( + "extensionLaunchProvider.resolve", + candidate + ) + ).resolves.toEqual(denial); + } + ); + + it("preserves callback error codes and data, with no success-shaped fallback", async () => { + const runtime = await runtimePeer(); + const client = runtime.client({ + extensionLaunchProvider: { + resolve: () => { + throw new ResponseError(-32005, "source approval failed", { + stage: "revision", + }); + }, + }, + }); + await client.start(); + await expect( + runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", candidate) + ).rejects.toMatchObject({ + code: -32005, + message: "source approval failed", + data: { stage: "revision" }, + }); + }); + + it.each<"stop" | "forceStop">(["stop", "forceStop"])( + "observes cancellation when a resolver synchronously calls %s and throws", + async (operation) => { + const runtime = await runtimePeer(); + const failure = new Error("synchronous provider failure"); + let stopping: Promise | undefined; + let cancelledSynchronously: boolean | undefined; + const resolve = vi.fn((_request, token) => { + stopping = client[operation](); + cancelledSynchronously = token?.isCancellationRequested; + throw failure; + }); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + await client.start(); + await expect( + runtime.peers[0].connection.sendRequest( + "extensionLaunchProvider.resolve", + candidate + ) + ).rejects.toBeInstanceOf(Error); + expect(resolve).toHaveBeenCalledTimes(1); + if (!stopping) throw new Error("The resolver did not initiate shutdown"); + expect(await stopping).toEqual(operation === "stop" ? [] : undefined); + expect(cancelledSynchronously).toBe(true); + await setTimeout(0); + } + ); + + it("cancels in-flight resolution and never reuses a late grant", async () => { + const entered = deferred(); + const lateGrant = deferred(); + let observedToken: CancellationToken | undefined; + const resolve = vi.fn( + async (_request, token) => { + observedToken = token; + entered.resolve(); + return lateGrant.promise; + } + ); + const runtime = await runtimePeer(); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + await client.start(); + const cancellation = new CancellationTokenSource(); + onTestFinished(() => cancellation.dispose()); + const request = runtime.peers[0].connection.sendRequest( + "extensionLaunchProvider.resolve", + candidate, + cancellation.token + ); + await entered.promise; + cancellation.cancel(); + await expect(request).rejects.toMatchObject({ code: -32800 }); + expect(observedToken?.isCancellationRequested).toBe(true); + lateGrant.resolve({ launch: profile }); + await expect( + runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", candidate) + ).resolves.toEqual({ launch: profile }); + expect(resolve).toHaveBeenCalledTimes(2); + }); + + it.each(["stop", "forceStop", "disconnect"])( + "%s cancels outstanding grants and reconnects with a fresh registration", + async (operation) => { + const entered = deferred(); + const lateGrant = deferred(); + const register = vi.fn(() => ({ contractVersion: 1 })); + let observedToken: CancellationToken | undefined; + const resolve = vi.fn( + async (_request, token) => { + observedToken = token; + entered.resolve(); + return lateGrant.promise; + } + ); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", register); + }); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + await client.start(); + const originalRpc = client.rpc; + const pending = runtime.peers[0].connection.sendRequest( + "extensionLaunchProvider.resolve", + candidate + ); + const rejected = expect(pending).rejects.toBeInstanceOf(Error); + await entered.promise; + if (operation === "stop") { + expect(await client.stop()).toEqual([]); + } else if (operation === "forceStop") { + await client.forceStop(); + } else { + runtime.peers[0].socket.destroy(); + } + await rejected; + await expect.poll(() => observedToken?.isCancellationRequested).toBe(true); + lateGrant.resolve({ launch: profile }); + await expect(originalRpc.registerExtensionLaunchProvider()).rejects.toMatchObject({ + code: -32800, + }); + await client.start(); + expect(register).toHaveBeenCalledTimes(2); + expect(resolve).toHaveBeenCalledTimes(1); + await expect( + runtime.peers[1].connection.sendRequest( + "extensionLaunchProvider.resolve", + candidate + ) + ).resolves.toEqual({ launch: profile }); + expect(resolve).toHaveBeenCalledTimes(2); + } + ); + + it("surfaces a shared runtime's refusal to replace a disconnected provider", async () => { + let registrations = 0; + const create = vi.fn(); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", () => { + if (++registrations === 1) return { contractVersion: 1 }; + return new ResponseError( + -32603, + "Another client is already the extension launch provider." + ); + }); + connection.onRequest("session.create", create); + }); + const client = runtime.client({ extensionLaunchProvider: grant }); + await client.start(); + expect(await client.stop()).toEqual([]); + await expect(client.createSession({})).rejects.toThrow( + "already the extension launch provider" + ); + expect(registrations).toBe(2); + expect(create).not.toHaveBeenCalled(); + expect(() => client.rpc).toThrow("not connected"); + }); + + it("stopping during negotiation rejects startup instead of accepting a late acknowledgement", async () => { + const entered = deferred(); + const acknowledgement = deferred(); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", () => { + entered.resolve(); + return acknowledgement.promise; + }); + }); + const client = runtime.client({ extensionLaunchProvider: grant }); + const starting = client.start(); + const rejected = expect(starting).rejects.toBeInstanceOf(Error); + await entered.promise; + expect(await client.stop()).toEqual([]); + await rejected; + acknowledgement.resolve({ contractVersion: 1 }); + expect(() => client.rpc).toThrow("not connected"); + }); +}); + +describe("extension launch cancellation adapter", () => { + it("disposes an unused connection repeatedly and refuses later registration", async () => { + const register = vi.fn<() => Promise>( + async () => ({ contractVersion: 1 }) + ); + const connection = new ExtensionLaunchProviderConnection(grant, register); + onTestFinished(() => connection.dispose()); + connection.dispose(); + connection.dispose(); + await expect(connection.register()).rejects.toMatchObject({ code: -32800 }); + await expect(connection.register()).rejects.toMatchObject({ code: -32800 }); + expect(register).not.toHaveBeenCalled(); + }); + + it("handles synchronous overlapping wire and lifetime cancellation before callback entry", async () => { + const resolve = vi.fn(grant.resolve); + const connection = new ExtensionLaunchProviderConnection({ resolve }, async () => ({ + contractVersion: 1, + })); + onTestFinished(() => connection.dispose()); + await connection.register(); + const subscriptionDisposed = vi.fn(); + const token: CancellationToken = { + isCancellationRequested: true, + onCancellationRequested(listener) { + listener(undefined); + connection.dispose(); + listener(undefined); + return { dispose: subscriptionDisposed }; + }, + }; + await expect(connection.handler.resolve(candidate, token)).rejects.toMatchObject({ + code: -32800, + }); + expect(resolve).not.toHaveBeenCalled(); + expect(subscriptionDisposed).toHaveBeenCalledTimes(1); + await expect(connection.register()).rejects.toMatchObject({ code: -32800 }); + }); + + it("preserves synchronous invocation and the original error when the resolver disposes then throws", async () => { + const failure = new Error("synchronous provider failure"); + let entered = false; + const connection = new ExtensionLaunchProviderConnection( + { + resolve() { + entered = true; + connection.dispose(); + throw failure; + }, + }, + async () => ({ contractVersion: 1 }) + ); + onTestFinished(() => connection.dispose()); + await connection.register(); + const resolving = connection.handler.resolve(candidate); + expect(entered).toBe(true); + await expect(resolving).rejects.toBe(failure); + await setTimeout(0); + }); +}); + +describe("public launch provider cancellation lifecycle", () => { + it.each(["start", "create", "resume"])( + "%s preserves handshake failures before provider registration and supports repeated cleanup", + async (operation) => { + const diagnostics = vi.spyOn(console, "error"); + onTestFinished(() => diagnostics.mockRestore()); + let rejectHandshake = true; + const registration = vi.fn(() => ({ contractVersion: 1 })); + const resolve = vi.fn(grant.resolve); + const runtime = await runtimePeer((connection) => { + connection.onRequest("connect", () => + rejectHandshake + ? new ResponseError(-32041, "synthetic handshake failure", { + phase: "before-registration", + }) + : { protocolVersion: 3 } + ); + connection.onRequest("registerExtensionLaunchProvider", registration); + }); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + const operationResult = + operation === "start" + ? client.start() + : operation === "create" + ? client.createSession({}) + : client.resumeSession("unit-session", {}); + await expect(operationResult).rejects.toMatchObject({ + code: -32041, + message: "synthetic handshake failure", + data: { phase: "before-registration" }, + }); + expect(registration).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + expect(() => client.rpc).toThrow("not connected"); + await client.forceStop(); + expect(await client.stop()).toEqual([]); + await client.forceStop(); + expect(diagnostics).not.toHaveBeenCalled(); + + rejectHandshake = false; + await client.start(); + expect(registration).toHaveBeenCalledTimes(1); + expect(resolve).not.toHaveBeenCalled(); + expect(await client.stop()).toEqual([]); + expect(await client.stop()).toEqual([]); + await client.forceStop(); + expect(diagnostics).not.toHaveBeenCalled(); + } + ); + + it.each(["stop", "forceStop", "disconnect"])( + "%s before handshake completion safely cancels an unused provider lifetime", + async (operation) => { + const diagnostics = vi.spyOn(console, "error"); + onTestFinished(() => diagnostics.mockRestore()); + const entered = deferred(); + const handshake = deferred<{ protocolVersion: number }>(); + const registration = vi.fn(() => ({ contractVersion: 1 })); + const runtime = await runtimePeer((connection) => { + connection.onRequest("connect", () => { + entered.resolve(); + return handshake.promise; + }); + connection.onRequest("registerExtensionLaunchProvider", registration); + }); + const client = runtime.client({ extensionLaunchProvider: grant }); + const starting = client.start(); + const failure = starting.catch((error: unknown) => error); + await entered.promise; + const originalRpc = client.rpc; + if (operation === "disconnect") { + runtime.peers[0].socket.destroy(); + await expect + .poll(() => { + try { + return client.rpc; + } catch (error) { + return error; + } + }) + .toBeInstanceOf(Error); + await client.forceStop(); + } else if (operation === "forceStop") { + await client.forceStop(); + } else { + expect(await client.stop()).toEqual([]); + } + await expect(failure).resolves.toMatchObject({ + code: ErrorCodes.PendingResponseRejected, + }); + handshake.resolve({ protocolVersion: 3 }); + await expect(originalRpc.registerExtensionLaunchProvider()).rejects.toMatchObject({ + code: -32800, + }); + expect(registration).not.toHaveBeenCalled(); + expect(await client.stop()).toEqual([]); + await client.forceStop(); + expect(diagnostics).not.toHaveBeenCalled(); + } + ); + + it.each(["stop", "forceStop", "disconnect"])( + "overlapping wire cancellation and %s notify once and cannot replay a late grant", + async (operation) => { + const entered = deferred(); + const late = deferred(); + const notified = deferred(); + const diagnostics = vi.spyOn(console, "error"); + onTestFinished(() => diagnostics.mockRestore()); + const registration = vi.fn(() => ({ contractVersion: 1 })); + const runtime = await runtimePeer((connection) => { + connection.onRequest("registerExtensionLaunchProvider", registration); + }); + const freshProfile: ExtensionLaunchProfile = { + ...profile, + args: [...profile.args, "--fresh-resolution"], + }; + let notifications = 0; + let stopping: Promise[]> | undefined; + const resolve = vi + .fn() + .mockImplementationOnce(async (_request, token) => { + if (!token) throw new Error("Expected the public cancellation token"); + token.onCancellationRequested(() => { + notifications++; + if (operation === "disconnect") { + runtime.peers[0].socket.destroy(); + stopping = Promise.resolve([]); + } else { + stopping = Promise.allSettled([ + operation === "stop" ? client.stop() : client.forceStop(), + ]); + } + notified.resolve(); + }); + entered.resolve(); + return late.promise; + }) + .mockResolvedValue({ launch: freshProfile }); + const client = runtime.client({ extensionLaunchProvider: { resolve } }); + await client.start(); + const oldRpc = client.rpc; + const wireCancellation = new CancellationTokenSource(); + onTestFinished(() => wireCancellation.dispose()); + const pending = runtime.peers[0].connection.sendRequest( + "extensionLaunchProvider.resolve", + candidate, + wireCancellation.token + ); + const failed = expect(pending).rejects.toBeInstanceOf(Error); + await entered.promise; + wireCancellation.cancel(); + wireCancellation.cancel(); + await notified.promise; + if (!stopping) throw new Error("Cancellation did not initiate connection teardown"); + for (const result of await stopping) { + expect(result.status).toBe("fulfilled"); + if (result.status === "fulfilled") { + expect(result.value).toEqual(operation === "stop" ? [] : undefined); + } + } + await failed; + await client.forceStop(); + expect(await client.stop()).toEqual([]); + expect(notifications).toBe(1); + late.resolve({ launch: profile }); + await expect(oldRpc.registerExtensionLaunchProvider()).rejects.toMatchObject({ + code: -32800, + }); + await client.start(); + expect(registration).toHaveBeenCalledTimes(2); + expect(resolve).toHaveBeenCalledTimes(1); + await expect( + runtime.peers[1].connection.sendRequest( + "extensionLaunchProvider.resolve", + candidate + ) + ).resolves.toEqual({ launch: freshProfile }); + expect(resolve).toHaveBeenCalledTimes(2); + expect(diagnostics).not.toHaveBeenCalled(); + } + ); +}); + +describe("public no-turn retention bindings", () => { + it("retains reentrantly before create returns and delivers the early retained event", async () => { + let createReturned = false; + const events: SessionEvent[] = []; + const retained: SessionRetainRequest[] = []; + const runtime = await runtimePeer((connection) => { + connection.onRequest("session.retain", async (params: SessionRetainRequest) => { + retained.push(params); + const event: RetainedEvent = { + type: "session.retained", + id: randomUUID(), + timestamp: new Date().toISOString(), + parentId: null, + data: {}, + }; + await connection.sendNotification("session.event", { + sessionId: params.sessionId, + event, + }); + return null; + }); + connection.onRequest("session.create", async (params: { sessionId: string }) => { + await expect( + connection.sendRequest("extensionLaunchProvider.resolve", { + ...candidate, + sessionId: params.sessionId, + }) + ).resolves.toEqual({ launch: profile }); + return { sessionId: params.sessionId }; + }); + }); + const client = runtime.client({ + extensionLaunchProvider: { + async resolve(request) { + expect(createReturned).toBe(false); + if (!request.sessionId) + throw new Error("Expected actual runtime session correlation"); + const result = await client.rpc.session.retain({ + sessionId: request.sessionId, + }); + expectTypeOf(result).toEqualTypeOf(); + expect(result).toBeNull(); + return { launch: request.defaultLaunch }; + }, + }, + }); + const session = await client.createSession({ onEvent: (event) => events.push(event) }); + createReturned = true; + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf< + Parameters[0] + >().toEqualTypeOf(); + expect(events.map((event) => event.type)).toEqual(["session.retained"]); + expect(retained).toEqual([{ sessionId: session.sessionId }]); + await expect(session.rpc.retain()).resolves.toBeNull(); + expect(retained).toEqual([ + { sessionId: session.sessionId }, + { sessionId: session.sessionId }, + ]); + }); + + it.each([ + [ErrorCodes.MethodNotFound, "unsupported"], + [-32001, "persistence unavailable"], + [-32002, "writer flush failed"], + [-32800, "retention cancelled"], + ])("propagates %s (%s) from both bindings", async (code, message) => { + const retain = vi.fn(() => new ResponseError(code, message, { operation: "retain" })); + const runtime = await runtimePeer((connection) => { + connection.onRequest("session.retain", retain); + }); + const client = runtime.client(); + const session = await client.createSession({}); + await expect( + client.rpc.session.retain({ sessionId: session.sessionId }) + ).rejects.toMatchObject({ + code, + message, + data: { operation: "retain" }, + }); + await expect(session.rpc.retain()).rejects.toMatchObject({ + code, + message, + data: { operation: "retain" }, + }); + expect(retain).toHaveBeenCalledTimes(2); + }); + + it("rejects connection loss during retention and never retries the effect", async () => { + const entered = deferred(); + const flush = deferred(); + const retain = vi.fn(() => { + entered.resolve(); + return flush.promise; + }); + const runtime = await runtimePeer((connection) => { + connection.onRequest("session.retain", retain); + }); + const client = runtime.client(); + await client.start(); + const pending = client.rpc.session.retain({ sessionId: "unit-session" }); + const rejected = expect(pending).rejects.toBeInstanceOf(Error); + await entered.promise; + await client.forceStop(); + await rejected; + flush.resolve(null); + await client.start(); + expect(retain).toHaveBeenCalledTimes(1); + }); +}); diff --git a/nodejs/tsconfig.test.json b/nodejs/tsconfig.test.json index 03d23317f7..6952eafe03 100644 --- a/nodejs/tsconfig.test.json +++ b/nodejs/tsconfig.test.json @@ -5,6 +5,11 @@ "emitDeclarationOnly": false, "types": ["node"] }, - "include": ["src/**/*", "test/session-event-types.test.ts", "test/message-source.test.ts"], + "include": [ + "src/**/*", + "test/session-event-types.test.ts", + "test/message-source.test.ts", + "test/extension-launch-provider.test.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index f5e8acb146..cdbe983bda 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -52,6 +52,8 @@ import { } from "./utils.js"; const TS_EXPERIMENTAL_JSDOC = "/** @experimental */"; +// Retention must also be callable before a create/resume response exposes a session. +const CONNECTION_SESSION_METHODS = new Set(["session.retain"]); const EXTERNAL_SCHEMA_TS_IMPORT: Record = { "session-events.schema.json": "./session-events.js", }; @@ -678,7 +680,9 @@ function tsNullableResultTypeName(method: RpcMethod): string | undefined { } function tsResultType(method: RpcMethod): string { - if (isVoidSchema(getMethodResultSchema(method))) return "void"; + if (isVoidSchema(getMethodResultSchema(method))) { + return CONNECTION_SESSION_METHODS.has(method.rpcMethod) ? "null" : "void"; + } return tsNullableResultTypeName(method) ?? resultTypeName(method); } @@ -717,7 +721,7 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema * Generated from: api.schema.json */ -import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; `); const externalSchemaRefs = collectExternalSchemaRefNames(schema); @@ -794,7 +798,11 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; if (paramsExternalRef) { continue; } - if (method.rpcMethod.startsWith("session.") && resolvedParams?.properties) { + if ( + method.rpcMethod.startsWith("session.") && + !CONNECTION_SESSION_METHODS.has(method.rpcMethod) && + resolvedParams?.properties + ) { const filtered: JSONSchema7 = { ...resolvedParams, properties: Object.fromEntries( @@ -889,6 +897,14 @@ function hasInternalMethods(node: Record): boolean { lines.push(`export function createServerRpc(connection: MessageConnection) {`); lines.push(` return {`); lines.push(...emitGroup(schema.server, " ", false, false, false, "public")); + const connectionSessionMethods = Object.fromEntries( + Object.entries(schema.session ?? {}).filter( + ([, method]) => isRpcMethod(method) && CONNECTION_SESSION_METHODS.has(method.rpcMethod) + ) + ); + if (Object.keys(connectionSessionMethods).length > 0) { + lines.push(...emitGroup({ session: connectionSessionMethods }, " ", false, false, false, "public")); + } lines.push(` };`); lines.push(`}`); lines.push(""); @@ -1211,7 +1227,8 @@ function emitClientGlobalApiRegistration(clientSchema: Record): includeExperimental: method.stability === "experimental" && !groupExperimental, }); if (hasParams) { - lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); + const cancellationParam = method.notification ? "" : ", token?: CancellationToken"; + lines.push(` ${name}(params: ${pType}${cancellationParam}): Promise<${rType}>;`); } else { lines.push(` ${name}(): Promise<${rType}>;`); } @@ -1270,10 +1287,10 @@ function emitClientGlobalApiRegistration(clientSchema: Record): lines.push(` });`); } } else if (hasParams) { - lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); + lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}, token: CancellationToken) => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) throw new Error("No ${groupName} client-global handler registered");`); - lines.push(` return handler.${name}(params);`); + lines.push(` return handler.${name}(params, token);`); lines.push(` });`); } else { lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); From 861f7794257745126c03bff5ade9070de4a65646 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sat, 12 Sep 2026 19:39:23 +0200 Subject: [PATCH 2/2] nodejs: fix: make canvas contract generation reproducible Check in the reviewed experimental schema fragments and their exact released predecessor fingerprints. Apply them in ordinary Node codegen without modifying release inputs or overriding an unexpected new contract. Keep explicit schema inputs and the other SDK generators unchanged. Add regression coverage for revision drift, idempotence, source isolation, and retained-event insertion. Document the Node-only experimental scope and preserve the compatible-runtime negotiation and release requirements. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 23 ++ nodejs/src/generated/rpc.ts | 42 +-- nodejs/src/generated/session-events.ts | 2 +- nodejs/test/canvas-codegen.test.ts | 174 +++++++++ scripts/codegen/canvas-schema.ts | 106 ++++++ .../codegen/experimental/canvas.schema.json | 346 ++++++++++++++++++ scripts/codegen/typescript.ts | 35 +- 7 files changed, 694 insertions(+), 34 deletions(-) create mode 100644 nodejs/test/canvas-codegen.test.ts create mode 100644 scripts/codegen/canvas-schema.ts create mode 100644 scripts/codegen/experimental/canvas.schema.json diff --git a/nodejs/README.md b/nodejs/README.md index 4c1397e73d..4b0f1ae3e8 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -210,6 +210,11 @@ does not establish their availability; an older runtime rejects these opt-in operations. Publishing and qualifying a matching SDK/runtime pair is a separate release step. +These experimental high-level bindings are currently Node-only. Generated wire +types or an earlier launch-provider API in another SDK do not establish equivalent +launch-v1, retention, cancellation, or initial script-safety behavior. +High-level parity in the other SDKs is a separate follow-up. + ##### `stop(): Promise` Stop the server and close all sessions. Returns a list of any errors encountered during cleanup. @@ -1280,6 +1285,24 @@ npm ci npm test ``` +Run `npm run generate` to regenerate bindings from the checksum-verified pinned +CLI schemas. The default Node generator also applies the reviewed experimental +[canvas schema revision](../scripts/codegen/experimental/canvas.schema.json). +That checked-in input records the canonical producer schema hashes, the exact +released predecessor fingerprints, and the launch-v1/retention fragments; it +does not invent a CLI release or change the downloaded schemas. + +The revision accepts only its recorded predecessor or an already matching +canonical field. Unexpected changes fail generation rather than silently +overriding a newer contract. When the runtime contract is released, review and +remove the corresponding revision entries as part of the normal pin update. +Other language generators remain on the release schema, and explicit schema +arguments to the Node generator remain complete caller-supplied inputs. + +This makes ordinary codegen reproducible, not the experimental runtime available. +The launch-version acknowledgement and compatible-runtime requirements above +still apply. + ## License MIT diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 8cf67217e2..2ee6951764 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -1,6 +1,6 @@ /** * AUTO-GENERATED FILE - DO NOT EDIT - * Generated from: api.schema.json + * Generated from: api.schema.json + experimental/canvas.schema.json */ import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; @@ -24230,19 +24230,6 @@ export interface ExtensionLaunchProviderRegistrationResult { */ contractVersion: 1; } -/** - * Identifies the target session. - * - * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema - * via the `definition` "SessionRetainRequest". - */ -/** @experimental */ -export interface SessionRetainRequest { - /** - * Target session identifier - */ - sessionId: string; -} /** @experimental */ export interface SessionFactoryPauseAtCheckpointResult { @@ -24354,6 +24341,19 @@ export interface SessionLimitPredictionPredictRequest { modelId?: string; clientType?: SessionLimitPredictionClientType; } +/** + * Identifies the target session. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SessionRetainRequest". + */ +/** @experimental */ +export interface SessionRetainRequest { + /** + * Target session identifier + */ + sessionId: string; +} /** * Identifies the target session. * @@ -25246,13 +25246,6 @@ 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. * @@ -27267,6 +27260,13 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin stop: async (params: ScheduleStopRequest): Promise => connection.sendRequest("session.schedule.stop", { sessionId, ...params }), }, + /** + * 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 }), }; } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 02b84ccc6d..a1001de4b9 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -1,6 +1,6 @@ /** * AUTO-GENERATED FILE - DO NOT EDIT - * Generated from: session-events.schema.json + * Generated from: session-events.schema.json + experimental/canvas.schema.json */ /** A value that can be represented losslessly on the SDK JSON wire. */ diff --git a/nodejs/test/canvas-codegen.test.ts b/nodejs/test/canvas-codegen.test.ts new file mode 100644 index 0000000000..756b75e05d --- /dev/null +++ b/nodejs/test/canvas-codegen.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { + applySchemaRevision, + loadCanvasSchemaRevisions, + schemaFingerprint, + type SchemaRevision, +} from "../../scripts/codegen/canvas-schema.ts"; + +const previousMethod = { params: null, result: { type: "null" } }; +const approvedMethod = { params: null, result: { $ref: "#/definitions/Acknowledgement" } }; +const retainedVariant = { + $ref: "#/definitions/RetainedEvent", + description: "Explicit persistence intent.", +}; +const revision: SchemaRevision = { + replacements: [ + { + path: ["server", "register"], + beforeSha256: schemaFingerprint(previousMethod), + value: approvedMethod, + }, + { + path: ["definitions", "Acknowledgement"], + beforeSha256: null, + value: { type: "object", properties: { contractVersion: { const: 1 } } }, + }, + ], + insertions: [ + { + path: ["definitions", "SessionEvent", "anyOf"], + after: "#/definitions/StartEvent", + value: retainedVariant, + }, + ], +}; + +function releasedSchema() { + return { + server: { + register: structuredClone(previousMethod), + ping: { result: { type: "string" } }, + }, + definitions: { + SessionEvent: { + anyOf: [{ $ref: "#/definitions/StartEvent" }, { $ref: "#/definitions/InfoEvent" }], + }, + }, + }; +} + +describe("canvas schema revisions", () => { + it("applies the exact reviewed changes without modifying release inputs", () => { + const released = releasedSchema(); + const original = structuredClone(released); + const actual = applySchemaRevision(released, revision); + + expect(actual.server.register).toEqual(approvedMethod); + expect(actual.server.ping).toEqual(released.server.ping); + expect(actual.definitions).toHaveProperty("Acknowledgement"); + expect(actual.definitions.SessionEvent.anyOf).toEqual([ + { $ref: "#/definitions/StartEvent" }, + retainedVariant, + { $ref: "#/definitions/InfoEvent" }, + ]); + expect(released).toEqual(original); + }); + + it("accepts an already matching release without duplicate union members", () => { + const applied = applySchemaRevision(releasedSchema(), revision); + + expect(applySchemaRevision(applied, revision)).toEqual(applied); + }); + + it("does not depend on schema object key order", () => { + expect(schemaFingerprint({ b: 2, a: 1 })).toBe(schemaFingerprint({ a: 1, b: 2 })); + }); + + it("refuses to overwrite a changed released method", () => { + const released = releasedSchema(); + released.server.register.result.type = "object"; + + expect(() => applySchemaRevision(released, revision)).toThrow( + "Canvas schema revision mismatch at server/register" + ); + }); + + it("refuses an unexpected definition even when an earlier replacement succeeded", () => { + const released = { + ...releasedSchema(), + definitions: { ...releasedSchema().definitions, Acknowledgement: { type: "string" } }, + }; + const original = structuredClone(released); + + expect(() => applySchemaRevision(released, revision)).toThrow( + "Canvas schema revision mismatch at definitions/Acknowledgement" + ); + expect(released).toEqual(original); + }); + + it("refuses a missing parent instead of creating a new API namespace", () => { + expect(() => applySchemaRevision({ definitions: {} }, revision)).toThrow( + "Missing canvas schema parent: server/register" + ); + }); + + it("refuses a missing insertion anchor", () => { + const released = releasedSchema(); + released.definitions.SessionEvent.anyOf = []; + + expect(() => applySchemaRevision(released, revision)).toThrow( + "Missing canvas event insertion anchor" + ); + }); + + it("refuses a conflicting existing event variant", () => { + const released = releasedSchema(); + released.definitions.SessionEvent.anyOf.push({ $ref: retainedVariant.$ref }); + + expect(() => applySchemaRevision(released, revision)).toThrow( + "Canvas schema revision mismatch at definitions/SessionEvent/anyOf" + ); + }); + + it("refuses duplicate existing event variants", () => { + const released = applySchemaRevision(releasedSchema(), revision); + released.definitions.SessionEvent.anyOf.push(retainedVariant); + + expect(() => applySchemaRevision(released, revision)).toThrow( + "Canvas schema revision mismatch at definitions/SessionEvent/anyOf" + ); + }); + + it.each([ + { path: [] }, + { path: ["__proto__", "polluted"] }, + { path: ["constructor", "prototype", "polluted"] }, + ])("refuses an invalid schema path $path", ({ path }) => { + expect(() => + applySchemaRevision( + {}, + { + replacements: [{ path, beforeSha256: null, value: true }], + insertions: [], + } + ) + ).toThrow("Invalid canvas schema path"); + }); + + it("loads the checked-in experimental contract and explicit retained-event insertion", async () => { + const canvas = await loadCanvasSchemaRevisions(); + + expect(canvas.api.replacements.map(({ path }) => path.join("/"))).toEqual([ + "definitions/ExtensionLaunchProfile", + "definitions/ExtensionLaunchProviderRegistrationResult", + "definitions/ExtensionLaunchProviderResolveRequest", + "definitions/ExtensionLaunchProviderResolveResult", + "definitions/ExtensionSource", + "server/registerExtensionLaunchProvider", + "clientGlobal/extensionLaunchProvider/resolve", + "session/retain", + ]); + expect(canvas.sessionEvents.insertions).toEqual([ + { + path: ["definitions", "SessionEvent", "anyOf"], + after: "#/definitions/AutopilotObjectiveChangedEvent", + value: expect.objectContaining({ $ref: "#/definitions/RetainedEvent" }), + }, + ]); + }); +}); diff --git a/scripts/codegen/canvas-schema.ts b/scripts/codegen/canvas-schema.ts new file mode 100644 index 0000000000..ee6fc5a510 --- /dev/null +++ b/scripts/codegen/canvas-schema.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { loadSchemaJson, stableStringify } from "./utils.js"; + +export interface SchemaRevision { + replacements: { + path: string[]; + /** Hash of the released predecessor, or null if the field must be absent. */ + beforeSha256: string | null; + value: unknown; + }[]; + insertions: { + path: string[]; + after: string; + value: { $ref: string; description: string }; + }[]; +} + +interface CanvasSchemaRevisions { + api: SchemaRevision; + sessionEvents: SchemaRevision; +} + +export function schemaFingerprint(value: unknown): string { + return createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +export async function loadCanvasSchemaRevisions(): Promise { + return loadSchemaJson( + fileURLToPath(new URL("./experimental/canvas.schema.json", import.meta.url)) + ); +} + +function isSchemaObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function schemaSlot(schema: unknown, path: readonly string[]) { + const key = path.at(-1); + if (!key || path.some((part) => ["__proto__", "prototype", "constructor"].includes(part))) { + throw new Error(`Invalid canvas schema path: ${path.join("/")}`); + } + let parent = schema; + for (const part of path.slice(0, -1)) { + if (!isSchemaObject(parent) || !Object.hasOwn(parent, part)) { + throw new Error(`Missing canvas schema parent: ${path.join("/")}`); + } + parent = parent[part]; + } + if (!isSchemaObject(parent)) { + throw new Error(`Invalid canvas schema parent: ${path.join("/")}`); + } + return { parent, key }; +} + +function revisionMismatch(path: readonly string[]): Error { + return new Error( + `Canvas schema revision mismatch at ${path.join("/")}. ` + + "The released contract changed; review or remove experimental/canvas.schema.json instead of overriding it." + ); +} + +/** Applies only the reviewed predecessor-to-candidate changes, without mutating the release input. */ +export function applySchemaRevision(schema: T, revision: SchemaRevision): T { + const result = structuredClone(schema); + for (const replacement of revision.replacements) { + const { parent, key } = schemaSlot(result, replacement.path); + const currentHash = Object.hasOwn(parent, key) ? schemaFingerprint(parent[key]) : null; + if (currentHash === schemaFingerprint(replacement.value)) { + continue; + } + if (currentHash !== replacement.beforeSha256) { + throw revisionMismatch(replacement.path); + } + parent[key] = structuredClone(replacement.value); + } + for (const insertion of revision.insertions) { + const { parent, key } = schemaSlot(result, insertion.path); + const value = parent[key]; + if (!Array.isArray(value)) { + throw new Error(`Missing canvas event union: ${insertion.path.join("/")}`); + } + const variants: unknown[] = value; + const existing = variants.filter( + (variant) => isSchemaObject(variant) && variant.$ref === insertion.value.$ref + ); + if (existing.length > 0) { + if (existing.length !== 1 || schemaFingerprint(existing[0]) !== schemaFingerprint(insertion.value)) { + throw revisionMismatch(insertion.path); + } + continue; + } + const anchor = variants.findIndex( + (variant) => isSchemaObject(variant) && variant.$ref === insertion.after + ); + if (anchor === -1) { + throw new Error(`Missing canvas event insertion anchor: ${insertion.after}`); + } + variants.splice(anchor + 1, 0, structuredClone(insertion.value)); + } + return result; +} diff --git a/scripts/codegen/experimental/canvas.schema.json b/scripts/codegen/experimental/canvas.schema.json new file mode 100644 index 0000000000..ce239079b2 --- /dev/null +++ b/scripts/codegen/experimental/canvas.schema.json @@ -0,0 +1,346 @@ +{ + "$comment": "Reviewed experimental Node-only canvas contract. This is a schema input, not a released CLI version or runtime capability assertion.", + "provenance": { + "predecessor": { + "cliVersion": "1.0.84-5", + "apiSchemaSha256": "5835517c600d1661bb857aa74deeb2abd2cfb9ed1e69cdedd1402181ea9eca98", + "sessionEventsSchemaSha256": "fded8ae9faa212cc84adae273c8b107a450b90ab66a723ee03e9057d41f8e4c5" + }, + "canonical": { + "apiSchemaSha256": "37a0f38a49aed1b6a9edbac763e2ee2a7fb72c10f6ddaff8534ecc32b65c335e", + "sessionEventsSchemaSha256": "8742c94016ec0ad6be83814f72003c4e77530c2fe83e395d00a467941b1b119e", + "definitions": "src/native/sdk-contract/src/api/rpc.rs and src/native/sdk-contract/src/session_events/events.rs" + }, + "fingerprint": "SHA-256 of scripts/codegen/utils.ts stableStringify output" + }, + "api": { + "replacements": [ + { + "path": [ + "definitions", + "ExtensionLaunchProfile" + ], + "beforeSha256": "796c245ca25c8db7f10437251bffe1a4500ef5e28f92f6dc90e093bf5b2af5af", + "value": { + "type": "object", + "properties": { + "executable": { + "type": "string", + "minLength": 1, + "description": "Executable used to launch the extension entrypoint." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Opaque integrator-defined arguments passed to the executable. The runtime does not append the extension entrypoint." + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Opaque integrator-defined environment variables. Runtime-owned COPILOT_SDK_PATH, SESSION_ID, and COPILOT_EXTENSION_PARENT_PID values take precedence." + } + }, + "required": [ + "executable", + "args", + "env" + ], + "additionalProperties": false, + "description": "Opaque integrator-owned process launch profile for one extension entrypoint.", + "title": "ExtensionLaunchProfile", + "stability": "experimental" + } + }, + { + "path": [ + "definitions", + "ExtensionLaunchProviderRegistrationResult" + ], + "beforeSha256": null, + "value": { + "type": "object", + "properties": { + "contractVersion": { + "type": "integer", + "minimum": 0, + "const": 1, + "description": "Supported extension launch-provider contract version. Clients requiring this contract must check for version 1 before creating or resuming sessions." + } + }, + "required": [ + "contractVersion" + ], + "additionalProperties": false, + "description": "Authoritative capability acknowledgement for the registered extension launch provider.", + "title": "ExtensionLaunchProviderRegistrationResult", + "stability": "experimental" + } + }, + { + "path": [ + "definitions", + "ExtensionLaunchProviderResolveRequest" + ], + "beforeSha256": "dad7ed71eb5e07faf614f827bd753a8e3828f8e177dd0b482e44b73ac4c5e72c", + "value": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Source-qualified extension identifier." + }, + "name": { + "type": "string", + "description": "Human-readable extension name." + }, + "modulePath": { + "type": "string", + "description": "Absolute path to the discovered extension entrypoint." + }, + "source": { + "$ref": "#/definitions/ExtensionSource", + "description": "Discovery source for the extension entrypoint." + }, + "sessionId": { + "type": "string", + "description": "Owning runtime session identifier, when known." + }, + "defaultLaunch": { + "$ref": "#/definitions/ExtensionLaunchProfile", + "description": "Runtime-computed built-in launch profile, not yet executed. Contains only bootstrap environment overrides, never the inherited process environment. Return unchanged to approve this candidate. Omitted when this embedding has no built-in launcher; no private bootstrap paths should be fabricated by the client." + } + }, + "required": [ + "id", + "name", + "modulePath", + "source" + ], + "additionalProperties": false, + "description": "A discovered extension entrypoint that the registered integrator may classify and resolve to an opaque launch profile.", + "title": "ExtensionLaunchProviderResolveRequest", + "stability": "experimental" + } + }, + { + "path": [ + "definitions", + "ExtensionLaunchProviderResolveResult" + ], + "beforeSha256": "3e27c78ee6be64dabbe9805a13e563bf9c56b627f575208d2d7c66bab12804a9", + "value": { + "type": "object", + "properties": { + "launch": { + "anyOf": [ + { + "$ref": "#/definitions/ExtensionLaunchProfile", + "description": "Opaque integrator-owned process launch profile for one extension entrypoint." + }, + { + "type": "null" + } + ], + "description": "Approved launch profile, or absent/null to deny this candidate without fallback." + } + }, + "additionalProperties": false, + "description": "The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher.", + "title": "ExtensionLaunchProviderResolveResult", + "stability": "experimental" + } + }, + { + "path": [ + "definitions", + "ExtensionSource" + ], + "beforeSha256": "58a2258b134f7eb4d008719d7e3d7c4fb3cdbb699b63e9477e36c47c092be2d4", + "value": { + "type": "string", + "enum": [ + "project", + "user", + "plugin", + "session" + ], + "description": "Discovery source: project (.github/extensions/), user (~/.copilot/extensions/), plugin (installed plugin), or session (session-state//extensions/)", + "title": "ExtensionSource", + "x-enumDescriptions": { + "project": "Extension discovered from the current project's .github/extensions directory.", + "user": "Extension discovered from the user's ~/.copilot/extensions directory.", + "plugin": "Extension contributed by an installed plugin.", + "session": "Extension discovered from the current session's state directory (loaded only for this session)." + } + } + }, + { + "path": [ + "server", + "registerExtensionLaunchProvider" + ], + "beforeSha256": "1994969d70c4c0718055be5543386db0a61d83a739e1a0954adf320ad61de467", + "value": { + "rpcMethod": "registerExtensionLaunchProvider", + "description": "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.", + "params": null, + "result": { + "$ref": "#/definitions/ExtensionLaunchProviderRegistrationResult", + "description": "Authoritative capability acknowledgement for the registered extension launch provider." + }, + "stability": "experimental" + } + }, + { + "path": [ + "clientGlobal", + "extensionLaunchProvider", + "resolve" + ], + "beforeSha256": "221dcedfb17a4b83ddf9f07666a9b34e0d3cfc135fecfe15a47904ebea09aa36", + "value": { + "rpcMethod": "extensionLaunchProvider.resolve", + "description": "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.", + "params": { + "$ref": "#/definitions/ExtensionLaunchProviderResolveRequest", + "description": "A discovered extension entrypoint that the registered integrator may classify and resolve to an opaque launch profile." + }, + "result": { + "$ref": "#/definitions/ExtensionLaunchProviderResolveResult", + "description": "The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher." + }, + "stability": "experimental" + } + }, + { + "path": [ + "session", + "retain" + ], + "beforeSha256": null, + "value": { + "rpcMethod": "session.retain", + "description": "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.", + "params": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Target session identifier" + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false, + "description": "Identifies the target session." + }, + "result": { + "type": "null" + }, + "stability": "experimental" + } + } + ], + "insertions": [] + }, + "sessionEvents": { + "replacements": [ + { + "path": [ + "definitions", + "RetainedData" + ], + "beforeSha256": null, + "value": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "description": "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.", + "title": "RetainedData", + "stability": "experimental" + } + }, + { + "path": [ + "definitions", + "RetainedEvent" + ], + "beforeSha256": null, + "value": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique event identifier (UUID v4), generated when the event is emitted" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the event was created" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "description": "ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event." + }, + "ephemeral": { + "type": "boolean", + "description": "When true, the event is transient and not persisted to the session event log on disk" + }, + "agentId": { + "type": "string", + "description": "Sub-agent instance identifier. Absent for events from the root/main agent and session-level events." + }, + "type": { + "type": "string", + "const": "session.retained", + "description": "Type discriminator. Always \"session.retained\"." + }, + "data": { + "$ref": "#/definitions/RetainedData", + "description": "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." + } + }, + "required": [ + "id", + "timestamp", + "parentId", + "type", + "data" + ], + "additionalProperties": false, + "description": "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.", + "title": "RetainedEvent", + "stability": "experimental" + } + } + ], + "insertions": [ + { + "path": [ + "definitions", + "SessionEvent", + "anyOf" + ], + "after": "#/definitions/AutopilotObjectiveChangedEvent", + "value": { + "$ref": "#/definitions/RetainedEvent", + "description": "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." + } + } + ] + } +} diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts index cdbe983bda..634cd1c79b 100644 --- a/scripts/codegen/typescript.ts +++ b/scripts/codegen/typescript.ts @@ -11,6 +11,7 @@ import type { JSONSchema7 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import path from "path"; import { fileURLToPath } from "url"; +import { applySchemaRevision, loadCanvasSchemaRevisions } from "./canvas-schema.js"; import { getApiSchemaPath, fixNullableRequiredRefsInApiSchema, @@ -524,11 +525,9 @@ export function filterPublicSessionEventVariants( return { publicVariants, excludedDefinitionNames }; } -async function generateSessionEvents(schemaPath?: string): Promise { +async function generateSessionEvents(schema: JSONSchema7, source: string): Promise { console.log("TypeScript: generating session-events..."); - const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); - const schema = (await loadSchemaJson(resolvedPath)) as JSONSchema7; const processed = propagateInternalVisibility(postProcessSchema(schema)); const definitionCollections = collectDefinitionCollections(processed as Record); const sessionEvent = @@ -564,7 +563,7 @@ async function generateSessionEvents(schemaPath?: string): Promise { bannerComment: [ `/** * AUTO-GENERATED FILE - DO NOT EDIT - * Generated from: session-events.schema.json + * Generated from: ${source} */`, opaqueTypeAliasBlock(opaqueTypeAliases), ] @@ -696,11 +695,10 @@ function paramsTypeName(method: RpcMethod): string { return externalRef?.definitionName ?? getRpcSchemaTypeName(schema, fallback); } -async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema7): Promise { +async function generateRpc(input: ApiSchema, source: string, sessionEventsSchema?: JSONSchema7): Promise { console.log("TypeScript: generating RPC types..."); - const resolvedPath = schemaPath ?? (await getApiSchemaPath()); - let schema = fixNullableRequiredRefsInApiSchema((await loadSchemaJson(resolvedPath)) as ApiSchema); + let schema = fixNullableRequiredRefsInApiSchema(input); if (sessionEventsSchema) { const sharedDefinitions = findSharedSchemaDefinitions( schema as unknown as Record, @@ -718,7 +716,7 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema const lines: string[] = []; lines.push(`/** * AUTO-GENERATED FILE - DO NOT EDIT - * Generated from: api.schema.json + * Generated from: ${source} */ import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; @@ -1311,11 +1309,24 @@ function emitClientGlobalApiRegistration(clientSchema: Record): // ── Main ──────────────────────────────────────────────────────────────────── async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { - await generateSessionEvents(sessionSchemaPath); + // Explicit schema arguments remain complete, caller-supplied inputs. + const canvas = sessionSchemaPath || apiSchemaPath ? undefined : await loadCanvasSchemaRevisions(); + const sourceSuffix = canvas ? " + experimental/canvas.schema.json" : ""; + const resolvedSessionPath = sessionSchemaPath ?? (await getSessionEventsSchemaPath()); + const releasedSessionSchema = await loadSchemaJson(resolvedSessionPath); + const sessionSchema = canvas + ? applySchemaRevision(releasedSessionSchema, canvas.sessionEvents) + : releasedSessionSchema; + await generateSessionEvents(sessionSchema, `session-events.schema.json${sourceSuffix}`); try { - const resolvedSessionPath = sessionSchemaPath ?? (await getSessionEventsSchemaPath()); - const sessionSchema = propagateInternalVisibility(postProcessSchema((await loadSchemaJson(resolvedSessionPath)) as JSONSchema7)); - await generateRpc(apiSchemaPath, sessionSchema); + const resolvedApiPath = apiSchemaPath ?? (await getApiSchemaPath()); + const releasedApiSchema = await loadSchemaJson(resolvedApiPath); + const apiSchema = canvas ? applySchemaRevision(releasedApiSchema, canvas.api) : releasedApiSchema; + await generateRpc( + apiSchema, + `api.schema.json${sourceSuffix}`, + propagateInternalVisibility(postProcessSchema(sessionSchema)) + ); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { console.log("TypeScript: skipping RPC (api.schema.json not found)");