@@ -41,9 +41,11 @@ import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
4141import { CopilotSession } from "./session.js" ;
4242import { createSessionFsAdapter , type SessionFsProvider } from "./sessionFsProvider.js" ;
4343import { getTraceContext } from "./telemetry.js" ;
44+ import { ToolSet } from "./toolSet.js" ;
4445import type {
4546 AutoModeSwitchRequest ,
4647 AutoModeSwitchResponse ,
48+ CopilotClientMode ,
4749 CopilotClientOptions ,
4850 CustomAgentConfig ,
4951 ExitPlanModeRequest ,
@@ -134,6 +136,38 @@ function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[]
134136 } ) ;
135137}
136138
139+ function toolFilterListToArray ( value : string [ ] | ToolSet | undefined ) : string [ ] | undefined {
140+ if ( value === undefined ) {
141+ return undefined ;
142+ }
143+ return value instanceof ToolSet ? value . toArray ( ) : value ;
144+ }
145+
146+ /**
147+ * Catches misuse of `availableTools`/`excludedTools` at the SDK boundary so
148+ * users get an actionable error rather than a silently-empty filter.
149+ *
150+ * The runtime treats a bare `"*"` as a literal name match for a tool whose
151+ * name is the single character `*`, which the runtime's charset guard would
152+ * reject at registration — so the filter effectively matches nothing. We
153+ * surface that here as an error pointing the developer at the source-qualified
154+ * forms produced by {@link ToolSet}.
155+ */
156+ function validateToolFilterList ( field : string , list : string [ ] | undefined ) : void {
157+ if ( ! list ) {
158+ return ;
159+ }
160+ for ( const entry of list ) {
161+ if ( entry === "*" ) {
162+ throw new Error (
163+ `Invalid ${ field } entry '*': there is no bare wildcard. ` +
164+ "Use one or more of `new ToolSet().addBuiltIn('*')`, `.addMcp('*')`, " +
165+ "or `.addCustom('*')` to target a specific source."
166+ ) ;
167+ }
168+ }
169+ }
170+
137171function isCanvasProviderRequestParams ( params : unknown ) : params is CanvasProviderRequestParams {
138172 if ( ! params || typeof params !== "object" ) {
139173 return false ;
@@ -298,6 +332,7 @@ export class CopilotClient {
298332 baseDirectory ?: string ;
299333 sessionIdleTimeoutSeconds : number ;
300334 enableRemoteSessions : boolean ;
335+ mode : CopilotClientMode ;
301336 } ;
302337 private isExternalServer : boolean = false ;
303338 private forceStopping : boolean = false ;
@@ -445,7 +480,29 @@ export class CopilotClient {
445480 baseDirectory : options . baseDirectory ,
446481 sessionIdleTimeoutSeconds : options . sessionIdleTimeoutSeconds ?? 0 ,
447482 enableRemoteSessions : options . enableRemoteSessions ?? false ,
483+ mode : options . mode ?? "copilot-cli" ,
448484 } ;
485+
486+ // Empty mode: validate at construction time that the app supplied a
487+ // per-session persistence location. The runtime is mode-agnostic, so
488+ // without this check it would silently fall back to ~/.copilot, which
489+ // defeats the point of empty mode for multi-tenant scenarios.
490+ if ( this . options . mode === "empty" ) {
491+ const hasPersistence =
492+ this . options . baseDirectory !== undefined ||
493+ this . sessionFsConfig !== null ||
494+ // External runtimes manage their own persistence layer; the SDK
495+ // can't enforce it from here.
496+ conn . kind === "uri" ||
497+ conn . kind === "parent-process" ;
498+ if ( ! hasPersistence ) {
499+ throw new Error (
500+ "CopilotClient was created with mode: 'empty' but neither " +
501+ "'baseDirectory' nor 'sessionFs' was set. Empty mode requires " +
502+ "an explicit per-session persistence location; pick one."
503+ ) ;
504+ }
505+ }
449506 }
450507
451508 private connectionExtraArgs : string [ ] = [ ] ;
@@ -820,6 +877,48 @@ export class CopilotClient {
820877 * });
821878 * ```
822879 */
880+ /**
881+ * Normalizes session-level tool filter options. Converts {@link ToolSet}
882+ * instances to plain string arrays, rejects misuse (bare `"*"`) and the
883+ * missing-availableTools case in `mode = "empty"`, and applies the
884+ * mode-aware default for `toolFilterMode`.
885+ *
886+ * @internal
887+ */
888+ private resolveToolFilterOptions ( config : {
889+ availableTools ?: string [ ] | ToolSet ;
890+ excludedTools ?: string [ ] | ToolSet ;
891+ toolFilterMode ?: "allowPrecedence" | "denyPrecedence" ;
892+ } ) : {
893+ availableTools : string [ ] | undefined ;
894+ excludedTools : string [ ] | undefined ;
895+ toolFilterMode : "allowPrecedence" | "denyPrecedence" | undefined ;
896+ } {
897+ const availableTools = toolFilterListToArray ( config . availableTools ) ;
898+ const excludedTools = toolFilterListToArray ( config . excludedTools ) ;
899+ validateToolFilterList ( "availableTools" , availableTools ) ;
900+ validateToolFilterList ( "excludedTools" , excludedTools ) ;
901+
902+ if ( this . options . mode === "empty" ) {
903+ if ( availableTools === undefined ) {
904+ throw new Error (
905+ "CopilotClient is in mode: 'empty' but the session config did not " +
906+ "specify 'availableTools'. Empty mode requires every session to " +
907+ "explicitly opt into the tools it wants — e.g. " +
908+ "`new ToolSet().addBuiltIn(BuiltInTools.Isolated)`."
909+ ) ;
910+ }
911+ }
912+
913+ // Empty mode flips the default to deny-precedence so apps can compose
914+ // include + exclude lists naturally (e.g. "everything matching X
915+ // except Y"). Callers can still override this explicitly.
916+ const toolFilterMode =
917+ config . toolFilterMode ?? ( this . options . mode === "empty" ? "denyPrecedence" : undefined ) ;
918+
919+ return { availableTools, excludedTools, toolFilterMode } ;
920+ }
921+
823922 async createSession ( config : SessionConfig ) : Promise < CopilotSession > {
824923 if ( ! this . connection ) {
825924 await this . start ( ) ;
@@ -869,6 +968,8 @@ export class CopilotClient {
869968 this . sessions . set ( sessionId , session ) ;
870969 this . setupSessionFs ( session , config ) ;
871970
971+ const toolFilterOptions = this . resolveToolFilterOptions ( config ) ;
972+
872973 try {
873974 const response = await this . connection ! . sendRequest ( "session.create" , {
874975 ...( await getTraceContext ( this . onGetTraceContext ) ) ,
@@ -892,8 +993,9 @@ export class CopilotClient {
892993 description : cmd . description ,
893994 } ) ) ,
894995 systemMessage : wireSystemMessage ,
895- availableTools : config . availableTools ,
896- excludedTools : config . excludedTools ,
996+ availableTools : toolFilterOptions . availableTools ,
997+ excludedTools : toolFilterOptions . excludedTools ,
998+ toolFilterMode : toolFilterOptions . toolFilterMode ,
897999 provider : config . provider ,
8981000 enableSessionTelemetry : config . enableSessionTelemetry ,
8991001 modelCapabilities : config . modelCapabilities ,
@@ -1008,6 +1110,8 @@ export class CopilotClient {
10081110 this . sessions . set ( sessionId , session ) ;
10091111 this . setupSessionFs ( session , config ) ;
10101112
1113+ const toolFilterOptions = this . resolveToolFilterOptions ( config ) ;
1114+
10111115 try {
10121116 const response = await this . connection ! . sendRequest ( "session.resume" , {
10131117 ...( await getTraceContext ( this . onGetTraceContext ) ) ,
@@ -1016,8 +1120,9 @@ export class CopilotClient {
10161120 model : config . model ,
10171121 reasoningEffort : config . reasoningEffort ,
10181122 systemMessage : wireSystemMessage ,
1019- availableTools : config . availableTools ,
1020- excludedTools : config . excludedTools ,
1123+ availableTools : toolFilterOptions . availableTools ,
1124+ excludedTools : toolFilterOptions . excludedTools ,
1125+ toolFilterMode : toolFilterOptions . toolFilterMode ,
10211126 enableSessionTelemetry : config . enableSessionTelemetry ,
10221127 tools : config . tools ?. map ( ( tool ) => ( {
10231128 name : tool . name ,
0 commit comments