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
105 changes: 104 additions & 1 deletion nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ new CopilotClient(options?: CopilotClientOptions)
- `mode?: "empty" | "copilot-cli"` - Defaulting strategy. Use `"empty"` for multi-user server mode; defaults to `"copilot-cli"`.
- `workingDirectory?: string` - Working directory for the runtime process (default: current process cwd).
- `baseDirectory?: string` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When not set, the runtime defaults to `~/.copilot`. Ignored when connecting via `RuntimeConnection.forUri`.
- `extensionLaunchProvider?: ExtensionLaunchProvider` - Experimental connection-level resolver for extension launch profiles. The client installs the reverse-RPC handler and registers the provider during startup before sessions can be created.
- `extensionLaunchProvider?: ExtensionLaunchProvider` - Experimental, connection-owned extension launch admission. Requires explicit runtime contract version 1; see [Extension launch admission](#extension-launch-admission-experimental).
- `logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"` - Log level. When omitted, the runtime uses its own default (currently `"info"`).
- `env?: Record<string, string | undefined>` - Environment variables for the runtime process. When omitted, inherits `process.env`.
- `gitHubToken?: string` - GitHub token for authentication. When provided, takes priority over other auth methods.
Expand All @@ -150,6 +150,90 @@ 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.

Canvas embedding is limited to **existing, already-persisted chats**. The host
must establish durability through its ordinary chat/session lifecycle before
approving a launch. This SDK contract does not persist a new or zero-turn chat;
canvas-first persistence is deferred.

```typescript
// persistedSessionId comes from the host's already-persisted chat selection.
const client = new CopilotClient({
extensionLaunchProvider: {
async resolve(request, cancellation) {
if (request.sessionId !== persistedSessionId || request.defaultLaunch === undefined) {
return { launch: null };
}
// approveRevision is the embedding application's source-admission routine.
if (!(await approveRevision(request, cancellation))) {
return { launch: null };
}
return { launch: request.defaultLaunch };
},
},
});
const session = await client.resumeSession(persistedSessionId, {
requestExtensions: true,
enableScriptSafety: true,
});
```

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.

For an already-durable session, approve the source revision before returning the
launch recipe: top-level extension code can have effects before resume returns,
`joinSession`, or canvas open. The SDK does not infer durability from a session ID
or a successful resume. 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 a durable session preference. 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 contract 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.

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, cancellation, or initial script-safety behavior.
High-level parity in the other SDKs is a separate follow-up.

##### `stop(): Promise<Error[]>`

Stop the server and close all sessions. Returns a list of any errors encountered during cleanup.
Expand Down Expand Up @@ -1310,6 +1394,25 @@ For native Vitest selectors on E2Es, use the
[prepared-runtime instructions](../CONTRIBUTING.md#testing-an-unreleased-runtime-api);
the SDK facade does not forward selectors.

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 API fragments; it does not
invent a CLI release or change the downloaded schemas. Session events use the
unmodified release schema; no no-turn persistence API or event is projected.

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
55 changes: 39 additions & 16 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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";
Expand Down Expand Up @@ -477,6 +478,7 @@ export class CopilotClient {
private sessionFsConfig: SessionFsConfig | null = null;
private requestHandler: CopilotRequestHandler | null = null;
private extensionLaunchProvider?: ExtensionLaunchProvider;
private extensionLaunchProviderConnection?: ExtensionLaunchProviderConnection;
private builtinPluginDirectories: string[] = [];
private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise<void>;
private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {};
Expand All @@ -490,7 +492,7 @@ export class CopilotClient {
* @throws Error if the client is not connected
*/
get rpc(): ReturnType<typeof createServerRpc> {
if (!this.connection) {
if (!this.connection || this.connectionClosed) {
throw new Error("Client is not connected. Call start() first.");
}
if (!this._rpc) {
Expand Down Expand Up @@ -821,7 +823,6 @@ export class CopilotClient {

private setupClientGlobalHandlers(): void {
const handlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {};
handlers.extensionLaunchProvider = this.extensionLaunchProvider;
if (this.requestHandler) {
this.requestAdapter = createCopilotRequestAdapter(this.requestHandler, () => {
if (!this.connection) {
Expand Down Expand Up @@ -940,6 +941,9 @@ export class CopilotClient {
}

private async doStart(): Promise<void> {
if (this.connectionClosed) {
await this.forceStop();
}
this.forceStopping = false;
this.connectionClosed = false;
this.processTransportError = null;
Expand All @@ -955,14 +959,11 @@ export class CopilotClient {

// Connect to the server
await this.connectToServer();
const launchProviderConnection = this.extensionLaunchProviderConnection;

// Verify protocol version compatibility
await this.verifyProtocolVersion();

if (this.extensionLaunchProvider) {
await this.rpc.registerExtensionLaunchProvider();
}

if (this.builtinPluginDirectories.length > 0) {
try {
await this.connection!.sendRequest("plugins.builtin.set", {
Expand Down Expand Up @@ -991,6 +992,7 @@ export class CopilotClient {
await this.connection!.sendRequest("llmInference.setProvider", {});
}

await launchProviderConnection?.register();
this.state = "connected";
} catch (error) {
const startupError = this.processTransportError ?? error;
Expand Down Expand Up @@ -1026,6 +1028,7 @@ export class CopilotClient {
*/
async stop(): Promise<Error[]> {
const errors: Error[] = [];
this.extensionLaunchProviderConnection?.dispose();

// Disconnect all active sessions with retry logic
const activeSessions = [...this.sessions.values()];
Expand Down Expand Up @@ -1212,6 +1215,7 @@ export class CopilotClient {
this.runtimePort = null;
this.stderrBuffer = "";
this.processExitPromise = null;
this.extensionLaunchProviderConnection = undefined;

return errors;
}
Expand Down Expand Up @@ -1259,6 +1263,7 @@ export class CopilotClient {
*/
async forceStop(): Promise<void> {
this.forceStopping = true;
this.extensionLaunchProviderConnection?.dispose();

// Clear sessions immediately without trying to destroy them
for (const session of this.sessions.values()) {
Expand Down Expand Up @@ -1327,6 +1332,7 @@ export class CopilotClient {
this.runtimePort = null;
this.stderrBuffer = "";
this.processExitPromise = null;
this.extensionLaunchProviderConnection = undefined;
}

/**
Expand Down Expand Up @@ -1522,7 +1528,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();
}

Expand Down Expand Up @@ -1678,6 +1684,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),
Expand Down Expand Up @@ -1849,7 +1856,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();
}

Expand Down Expand Up @@ -1943,6 +1950,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,
Expand Down Expand Up @@ -2822,8 +2830,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();
}
}
}

Expand Down Expand Up @@ -3083,7 +3096,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
Expand All @@ -3096,8 +3122,8 @@ export class CopilotClient {
}
);

const connection = this.connection;
const markDisconnected = () => {
launchProviderConnection?.dispose();
if (this.connection !== connection) {
return;
}
Expand All @@ -3111,11 +3137,8 @@ export class CopilotClient {
this.requestAdapter?.cancelPending();
};
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 {
Expand Down
Loading
Loading