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
111 changes: 107 additions & 4 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
import { CopilotSession } from "./session.js";
import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js";
import { getTraceContext } from "./telemetry.js";
import { ToolSet } from "./toolSet.js";
import type {
AutoModeSwitchRequest,
AutoModeSwitchResponse,
CopilotClientMode,
CopilotClientOptions,
CustomAgentConfig,
ExitPlanModeRequest,
Expand Down Expand Up @@ -134,6 +136,38 @@ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[]
});
}

function toolFilterListToArray(value: string[] | ToolSet | undefined): string[] | undefined {
if (value === undefined) {
return undefined;
}
return value instanceof ToolSet ? value.toArray() : value;
}

/**
* Catches misuse of `availableTools`/`excludedTools` at the SDK boundary so
* users get an actionable error rather than a silently-empty filter.
*
* The runtime treats a bare `"*"` as a literal name match for a tool whose
* name is the single character `*`, which the runtime's charset guard would
* reject at registration — so the filter effectively matches nothing. We
* surface that here as an error pointing the developer at the source-qualified
* forms produced by {@link ToolSet}.
*/
function validateToolFilterList(field: string, list: string[] | undefined): void {
if (!list) {
return;
}
for (const entry of list) {
if (entry === "*") {
throw new Error(
`Invalid ${field} entry '*': there is no bare wildcard. ` +
"Use one or more of `new ToolSet().addBuiltIn('*')`, `.addMcp('*')`, " +
"or `.addCustom('*')` to target a specific source."
);
}
}
}

function isCanvasProviderRequestParams(params: unknown): params is CanvasProviderRequestParams {
if (!params || typeof params !== "object") {
return false;
Expand Down Expand Up @@ -298,6 +332,7 @@ export class CopilotClient {
baseDirectory?: string;
sessionIdleTimeoutSeconds: number;
enableRemoteSessions: boolean;
mode: CopilotClientMode;
};
private isExternalServer: boolean = false;
private forceStopping: boolean = false;
Expand Down Expand Up @@ -445,7 +480,29 @@ export class CopilotClient {
baseDirectory: options.baseDirectory,
sessionIdleTimeoutSeconds: options.sessionIdleTimeoutSeconds ?? 0,
enableRemoteSessions: options.enableRemoteSessions ?? false,
mode: options.mode ?? "copilot-cli",
};

// Empty mode: validate at construction time that the app supplied a
// per-session persistence location. The runtime is mode-agnostic, so
// without this check it would silently fall back to ~/.copilot, which
// defeats the point of empty mode for multi-tenant scenarios.
if (this.options.mode === "empty") {
const hasPersistence =
this.options.baseDirectory !== undefined ||
this.sessionFsConfig !== null ||
// External runtimes manage their own persistence layer; the SDK
// can't enforce it from here.
conn.kind === "uri" ||
conn.kind === "parent-process";
if (!hasPersistence) {
throw new Error(
"CopilotClient was created with mode: 'empty' but neither " +
"'baseDirectory' nor 'sessionFs' was set. Empty mode requires " +
"an explicit per-session persistence location; pick one."
);
}
}
}

private connectionExtraArgs: string[] = [];
Expand Down Expand Up @@ -820,6 +877,46 @@ export class CopilotClient {
* });
* ```
*/
/**
* Normalizes session-level tool filter options. Converts {@link ToolSet}
* instances to plain string arrays, rejects misuse (bare `"*"`) and the
* missing-availableTools case in `mode = "empty"`.
*
* The SDK always sends `toolFilterPrecedence: "excluded"` so callers can
* compose include + exclude lists naturally (e.g. "everything matching X
* except Y") regardless of mode. Allowlist-precedence is intentionally not
* exposed — it's available on the runtime side as a CLI-only concession to
* legacy behavior, but SDK consumers always get the composable semantics.
*
* @internal
*/
private resolveToolFilterOptions(config: {
availableTools?: string[] | ToolSet;
excludedTools?: string[] | ToolSet;
}): {
availableTools: string[] | undefined;
excludedTools: string[] | undefined;
toolFilterPrecedence: "excluded";
} {
const availableTools = toolFilterListToArray(config.availableTools);
const excludedTools = toolFilterListToArray(config.excludedTools);
validateToolFilterList("availableTools", availableTools);
validateToolFilterList("excludedTools", excludedTools);

if (this.options.mode === "empty") {
if (availableTools === undefined) {
throw new Error(
"CopilotClient is in mode: 'empty' but the session config did not " +
"specify 'availableTools'. Empty mode requires every session to " +
"explicitly opt into the tools it wants — e.g. " +
"`new ToolSet().addBuiltIn(BuiltInTools.Isolated)`."
);
}
}

return { availableTools, excludedTools, toolFilterPrecedence: "excluded" };
}

async createSession(config: SessionConfig): Promise<CopilotSession> {
if (!this.connection) {
await this.start();
Expand Down Expand Up @@ -869,6 +966,8 @@ export class CopilotClient {
this.sessions.set(sessionId, session);
this.setupSessionFs(session, config);

const toolFilterOptions = this.resolveToolFilterOptions(config);

try {
const response = await this.connection!.sendRequest("session.create", {
...(await getTraceContext(this.onGetTraceContext)),
Expand All @@ -892,8 +991,9 @@ export class CopilotClient {
description: cmd.description,
})),
systemMessage: wireSystemMessage,
availableTools: config.availableTools,
excludedTools: config.excludedTools,
availableTools: toolFilterOptions.availableTools,
excludedTools: toolFilterOptions.excludedTools,
toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence,
provider: config.provider,
enableSessionTelemetry: config.enableSessionTelemetry,
modelCapabilities: config.modelCapabilities,
Expand Down Expand Up @@ -1008,6 +1108,8 @@ export class CopilotClient {
this.sessions.set(sessionId, session);
this.setupSessionFs(session, config);

const toolFilterOptions = this.resolveToolFilterOptions(config);

try {
const response = await this.connection!.sendRequest("session.resume", {
...(await getTraceContext(this.onGetTraceContext)),
Expand All @@ -1016,8 +1118,9 @@ export class CopilotClient {
model: config.model,
reasoningEffort: config.reasoningEffort,
systemMessage: wireSystemMessage,
availableTools: config.availableTools,
excludedTools: config.excludedTools,
availableTools: toolFilterOptions.availableTools,
excludedTools: toolFilterOptions.excludedTools,
toolFilterPrecedence: toolFilterOptions.toolFilterPrecedence,
enableSessionTelemetry: config.enableSessionTelemetry,
tools: config.tools?.map((tool) => ({
name: tool.name,
Expand Down
13 changes: 13 additions & 0 deletions nodejs/src/generated/rpc.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

export { CopilotClient } from "./client.js";
export { RuntimeConnection } from "./types.js";
export { BuiltInTools, ToolSet } from "./toolSet.js";
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
export {
Canvas,
Expand Down Expand Up @@ -54,6 +55,7 @@ export type {
AutoModeSwitchHandler,
AutoModeSwitchRequest,
AutoModeSwitchResponse,
CopilotClientMode,
CopilotClientOptions,
StdioRuntimeConnection,
TcpRuntimeConnection,
Expand Down
140 changes: 140 additions & 0 deletions nodejs/src/toolSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

/**
* Builder for the {@link SessionConfigBase.availableTools} list using
* source-qualified filter patterns (`builtin:*`, `mcp:<name>`, `custom:*`, etc.).
*
* See plan: client-level Mode = "empty" with explicit tool selection.
*/

/**
* Tool name character set enforced by the runtime at every registration
* boundary. Mirrors the runtime's `VALID_TOOL_NAME_REGEX`. Used to validate
* names passed to the `ToolSet` builder so misuse is caught at the SDK
* boundary with a better error than the runtime would produce.
*/
const VALID_TOOL_NAME = /^[a-zA-Z0-9_-]+$/;

function validateName(kind: "builtin" | "mcp" | "custom", name: string): void {
if (name === "*") {
return;
}
if (!VALID_TOOL_NAME.test(name)) {
throw new Error(
`Invalid ${kind} tool name '${name}': tool names must match /^[a-zA-Z0-9_-]+$/ ` +
`or be the wildcard '*'.`
);
}
}

/**
* Builder that produces a list of source-qualified tool filter strings for
* {@link SessionConfigBase.availableTools}.
*
* Tools are classified by the runtime at registration time (not from name
* parsing), so `addBuiltIn("foo")` matches only tools the runtime registered
* as built-in, even if an MCP server or custom-agent extension happens to
* register a tool with the same wire name.
*
* @example
* ```typescript
* const tools = new ToolSet()
* .addBuiltIn(BuiltInTools.Isolated)
* .addMcp("*")
* .addCustom("*");
*
* const session = await client.createSession({
* availableTools: tools,
* // ...
* });
* ```
*/
export class ToolSet {
private readonly items: string[] = [];

/**
* Adds one or more built-in tool patterns.
*
* @param name A specific built-in tool name (e.g. `"bash"`) or `"*"` to match all
* built-in tools.
*/
addBuiltIn(name: string): ToolSet;
/**
* Adds a list of built-in tool patterns (e.g. {@link BuiltInTools.Isolated}).
*/
addBuiltIn(names: readonly string[]): ToolSet;
addBuiltIn(nameOrNames: string | readonly string[]): ToolSet {
const names = typeof nameOrNames === "string" ? [nameOrNames] : nameOrNames;
for (const name of names) {
validateName("builtin", name);
this.items.push(`builtin:${name}`);
}
return this;
}

/**
* Adds a custom tool pattern. Matches tools registered via the SDK's
* `tools` option or via custom agents.
*
* @param name A specific custom tool name or `"*"` to match all custom tools.
*/
addCustom(name: string): ToolSet {
validateName("custom", name);
this.items.push(`custom:${name}`);
return this;
}

/**
* Adds an MCP tool pattern. Matches tools advertised by any configured
* MCP server.
*
* @param toolName The runtime's canonical wire name for the MCP tool
* (e.g. `"github-list_issues"`), or `"*"` to match all MCP tools from
* any server.
*/
addMcp(toolName: string): ToolSet {
validateName("mcp", toolName);
this.items.push(`mcp:${toolName}`);
return this;
}

/**
* Returns a defensive copy of the accumulated filter strings, suitable for
* passing as {@link SessionConfigBase.availableTools}.
*/
toArray(): string[] {
return [...this.items];
}
}

/**
* Curated sets of built-in tool names for common scenarios. Each constant is
* meant to be passed to {@link ToolSet.addBuiltIn}.
*/
export const BuiltInTools = {
/**
* Built-in tools that operate only within the bounds of a single session —
* no host filesystem access outside the session, no cross-session state,
* no host environment access, no network. Safe to enable in `Mode = "empty"`
* scenarios (e.g. multi-tenant servers) without leaking host capabilities.
*
* **Contract:** tools in this set MUST NOT be extended (even behind options
* or args) to read or write state outside the session boundary. Adding
* cross-session or host-state behavior to one of these tools is a
* breaking change that requires removing it from this set.
*/
Isolated: [
"ask_user",
"task_complete",
"exit_plan_mode",
"task",
"read_agent",
"write_agent",
"list_agents",
"send_inbox",
"context_board",
"skill",
] as readonly string[],
} as const;
Loading
Loading