Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
14 changes: 14 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ class TeardownResilientStreamMessageWriter extends StreamMessageWriter {
}

export class CopilotClient {
/** Whether this SDK supports CopilotClientOptions.onExtensionLaunch. */
static readonly supportsExtensionLaunchProvider = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK consistency: onExtensionLaunch / extension launch provider is Node.js-only

This PR wires up a client-level, high-level API for the extension launch provider feature in the Node.js SDK (CopilotClientOptions.onExtensionLaunch, CopilotClient.supportsExtensionLaunchProvider, and automatic registerExtensionLaunchProvider registration during start()).

The generated RPC primitives (ExtensionLaunchProfile, ExtensionLaunchProviderResolveRequest/Result, registerExtensionLaunchProvider) already exist in every generated layer (Go, Python, .NET, Java, Rust), since those are produced from the shared CLI schema. However, only Rust also has a matching high-level convenience API (ClientOptions::with_extension_launch_provider in rust/src/lib.rs / rust/src/extension_launch_provider.rs). Go, Python, .NET, and Java currently expose only the low-level generated RPC method/types and have no ergonomic client option to register a launch provider or a supports_extension_launch_provider-equivalent capability flag.

Suggestion: consider adding the equivalent high-level wiring to Go, Python, .NET, and Java (following each language's naming convention, e.g. with_extension_launch_provider in Python, WithExtensionLaunchProvider/SupportsExtensionLaunchProvider in Go/.NET, onExtensionLaunch/supportsExtensionLaunchProvider in Java) in a follow-up PR, so this experimental feature reaches parity across SDKs before it graduates from @experimental.

private cliStartTimeout: ReturnType<typeof setTimeout> | null = null;
private cliProcess: ChildProcess | null = null;
private ffiHost: FfiRuntimeHost | null = null;
Expand Down Expand Up @@ -492,6 +494,7 @@ export class CopilotClient {
private builtinPluginDirectories: string[] = [];
private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise<void>;
private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {};
private onExtensionLaunch?: CopilotClientOptions["onExtensionLaunch"];
private githubTokenProviders = new Map<
string,
{ provider: GitHubTokenProvider; sessionId?: string; committed: boolean }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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", {
Expand Down
3 changes: 3 additions & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export type {
ExitPlanModeRequest,
ExitPlanModeResult,
ExtensionInfo,
ExtensionLaunchProfile,
ExtensionLaunchProviderResolveRequest,
ExtensionLaunchProviderResolveResult,
ForegroundSessionInfo,
GetAuthStatusResponse,
GetStatusResponse,
Expand Down
16 changes: 16 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<ExtensionLaunchProviderResolveResult>;
/**
* How to connect to the Copilot runtime. When omitted, defaults to
* {@link RuntimeConnection.forStdio} with the bundled runtime.
Expand Down
68 changes: 68 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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(
() =>
Expand Down
Loading