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
102 changes: 102 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -131,6 +132,89 @@ 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.

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<Error[]>`

Stop the server and close all sessions. Returns a list of any errors encountered during cleanup.
Expand Down Expand Up @@ -1201,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
53 changes: 42 additions & 11 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "./generated/rpc.js";
import type {
ConnectClientInfo,
ExtensionLaunchProviderHandler,
GitHubTelemetryNotification,
GitHubTokenAcquireRequest,
GitHubTokenAcquireResult,
Expand All @@ -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";
Expand Down Expand Up @@ -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<void>;
private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {};
Expand All @@ -504,7 +508,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 @@ -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();

Expand Down Expand Up @@ -951,6 +956,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 @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1033,6 +1043,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 @@ -1218,6 +1229,7 @@ export class CopilotClient {
this.runtimePort = null;
this.stderrBuffer = "";
this.processExitPromise = null;
this.extensionLaunchProviderConnection = undefined;

return errors;
}
Expand Down Expand Up @@ -1265,6 +1277,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 @@ -1332,6 +1345,7 @@ export class CopilotClient {
this.runtimePort = null;
this.stderrBuffer = "";
this.processExitPromise = null;
this.extensionLaunchProviderConnection = undefined;
}

/**
Expand Down Expand Up @@ -1527,7 +1541,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 @@ -1683,6 +1697,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 @@ -1835,7 +1850,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 @@ -1928,6 +1943,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 @@ -2806,8 +2822,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 @@ -3067,7 +3088,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 @@ -3080,8 +3114,8 @@ export class CopilotClient {
}
);

const connection = this.connection;
const markDisconnected = () => {
launchProviderConnection?.dispose();
if (this.connection !== connection) {
return;
}
Expand All @@ -3094,11 +3128,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 {
Expand Down
Loading
Loading