From 461fb9dbdf4aa17d03590f4b8c841b8bdba5498a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Ruales?= <1588988+jruales@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:41:26 -0700 Subject: [PATCH] Expose the extension launch provider in the Node SDK Register an optional extension-launch callback before client startup completes, reusing the existing runtime protocol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/README.md | 19 ++++++++++- nodejs/src/client.ts | 14 ++++++++ nodejs/src/index.ts | 3 ++ nodejs/src/types.ts | 16 +++++++++ nodejs/test/client.test.ts | 68 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/nodejs/README.md b/nodejs/README.md index 7effb81e95..88f87e89d6 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -28,7 +28,24 @@ release's `SHA256SUMS.txt`. npm install @github/copilot-sdk ``` -## Run the Sample +## Standalone extension launch providers + +`CopilotClientOptions.onExtensionLaunch` resolves a process launch specification for +each extension entrypoint discovered by the runtime. The SDK registers the callback +before `start()` returns; registration failures reject startup. Clients that omit +the callback retain their existing behavior. `CopilotClient.supportsExtensionLaunchProvider` +allows integrators to detect support before opting sessions into extensions. + +Return `{ launch: { executable, args, env } }`, or `{}` for an unsupported entrypoint. +The runtime does not append the module path to `args`. Use the runtime-provided +extension bootstrap and supply the discovered path as `EXTENSION_PATH`. The runtime +continues to own discovery, spawning, termination, and the reserved `COPILOT_SDK_PATH`, +`SESSION_ID`, and `COPILOT_EXTENSION_PARENT_PID` environment variables. Normal sessions +must opt in with `requestExtensions: true` and a compatible `extensionSdkPath`. +The callback receives untrusted extension identity/path metadata; never interpolate it +into a shell command. Return an executable and argument array instead. + +## Run the sample Try the interactive chat sample (from the repo root): diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index eb92cf0bed..bd4a1ac647 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -438,6 +438,8 @@ class TeardownResilientStreamMessageWriter extends StreamMessageWriter { } export class CopilotClient { + /** Whether this SDK supports CopilotClientOptions.onExtensionLaunch. */ + static readonly supportsExtensionLaunchProvider = true; private cliStartTimeout: ReturnType | null = null; private cliProcess: ChildProcess | null = null; private ffiHost: FfiRuntimeHost | null = null; @@ -492,6 +494,7 @@ export class CopilotClient { private builtinPluginDirectories: string[] = []; private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; + private onExtensionLaunch?: CopilotClientOptions["onExtensionLaunch"]; private githubTokenProviders = new Map< string, { provider: GitHubTokenProvider; sessionId?: string; committed: boolean } @@ -688,6 +691,7 @@ export class CopilotClient { this.sessionFsConfig = options.sessionFs ?? null; this.requestHandler = options.requestHandler ?? null; this.onGitHubTelemetry = options.onGitHubTelemetry; + this.onExtensionLaunch = options.onExtensionLaunch; this.setupClientGlobalHandlers(); // Connection-level env (child-process transports only) takes precedence @@ -832,6 +836,12 @@ export class CopilotClient { private setupClientGlobalHandlers(): void { const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; + if (this.onExtensionLaunch) { + const onExtensionLaunch = this.onExtensionLaunch; + handlers.extensionLaunchProvider = { + resolve: async (request) => onExtensionLaunch(request), + }; + } if (this.requestHandler) { handlers.llmInference = createCopilotRequestAdapter(this.requestHandler, () => { if (!this.connection) { @@ -954,6 +964,10 @@ export class CopilotClient { // Verify protocol version compatibility await this.verifyProtocolVersion(); + if (this.onExtensionLaunch) { + await this.rpc.registerExtensionLaunchProvider(); + } + if (this.builtinPluginDirectories.length > 0) { try { await this.connection!.sendRequest("plugins.builtin.set", { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 6251df4fc7..cdb2160d35 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -92,6 +92,9 @@ export type { ExitPlanModeRequest, ExitPlanModeResult, ExtensionInfo, + ExtensionLaunchProfile, + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 068e0f0a58..7ad8124de6 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -22,6 +22,8 @@ import type { import type { CopilotSession } from "./session.js"; import type { FactoryJsonSchema, JsonValue } from "./factory.js"; import type { + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, GitHubTokenAcquireRequest, GitHubTokenAcquireResult, GitHubTelemetryNotification, @@ -34,6 +36,9 @@ import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; export type { + ExtensionLaunchProfile, + ExtensionLaunchProviderResolveRequest, + ExtensionLaunchProviderResolveResult, GitHubTokenAcquireReason, GitHubTokenAcquireResult, GitHubTelemetryNotification, @@ -344,6 +349,17 @@ export interface CopilotClientInfo { } export interface CopilotClientOptions { + /** + * Resolves a launch profile for an extension entrypoint discovered by the runtime. + * Registration completes during client.start(), before sessions can be created. + * Return an empty object for unsupported entrypoints. The runtime owns discovery, + * process creation, lifetime, and reserved SDK/session environment variables. + * + * @experimental + */ + onExtensionLaunch?: ( + request: ExtensionLaunchProviderResolveRequest + ) => ExtensionLaunchProviderResolveResult | Promise; /** * How to connect to the Copilot runtime. When omitted, defaults to * {@link RuntimeConnection.forStdio} with the bundled runtime. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3db96ea47b..04c020a84b 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -119,6 +119,74 @@ describe("CopilotClient", () => { expect(sendRequest).toHaveBeenCalledWith("plugins.builtin.set", { paths }); }); + it("registers the extension launch provider before start completes and forwards its profile", async () => { + const profile = { + launch: { + executable: "/node", + args: ["/bootstrap.mjs"], + env: { EXTENSION_PATH: "/extension.mjs" }, + }, + }; + const onExtensionLaunch = vi.fn(async () => profile); + const client = new CopilotClient({ + connection: RuntimeConnection.forUri("localhost:1234"), + onExtensionLaunch, + }); + let finishRegistration!: () => void; + const registration = new Promise((resolve) => { + finishRegistration = resolve; + }); + const sendRequest = vi.fn(() => registration); + vi.spyOn(client as any, "connectToServer").mockImplementation(async () => { + (client as any).connection = { sendRequest }; + }); + vi.spyOn(client as any, "verifyProtocolVersion").mockResolvedValue(undefined); + let started = false; + const starting = client.start().then(() => { + started = true; + }); + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith("registerExtensionLaunchProvider", {}) + ); + expect(started).toBe(false); + finishRegistration(); + await starting; + const request = { + id: "project:example", + name: "example", + modulePath: "/extension.mjs", + source: "project" as const, + }; + const result = await (client as any).clientGlobalHandlers.extensionLaunchProvider.resolve( + request + ); + expect({ + supported: CopilotClient.supportsExtensionLaunchProvider, + result, + calls: onExtensionLaunch.mock.calls, + }).toEqual({ + supported: true, + result: profile, + calls: [[request]], + }); + }); + + it("fails startup when extension launch provider registration is rejected", async () => { + const client = new CopilotClient({ + connection: RuntimeConnection.forUri("localhost:1234"), + onExtensionLaunch: () => ({}), + }); + vi.spyOn(client as any, "connectToServer").mockImplementation(async () => { + (client as any).connection = { + sendRequest: vi.fn().mockRejectedValue(new Error("unsupported")), + }; + }); + vi.spyOn(client as any, "verifyProtocolVersion").mockResolvedValue(undefined); + const stop = vi.spyOn(client, "forceStop").mockResolvedValue(); + await expect(client.start()).rejects.toThrow("unsupported"); + expect(stop).toHaveBeenCalledOnce(); + }); + it("rejects relative built-in plugin directories", () => { expect( () =>