diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 4a65780bd..1aaee445b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -162,6 +162,28 @@ public CopilotClient(CopilotClientOptions? options = null) _logger = _options.Logger ?? NullLogger.Instance; _onListModels = _options.OnListModels; + + // 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 (_options.Mode == CopilotClientMode.Empty) + { + var hasPersistence = + !string.IsNullOrEmpty(_options.BaseDirectory) || + _options.SessionFs is not null || + // External runtimes manage their own persistence layer; the SDK + // can't enforce it from here. + _connection is UriRuntimeConnection; + if (!hasPersistence) + { + throw new ArgumentException( + "CopilotClient was created with Mode = CopilotClientMode.Empty but neither " + + "BaseDirectory nor SessionFs was set. Empty mode requires an explicit " + + "per-session persistence location; pick one.", + nameof(options)); + } + } } /// @@ -491,6 +513,200 @@ private static (SystemMessageConfig? wireConfig, Dictionary + /// Catches misuse of / + /// at the SDK boundary so + /// callers 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. + /// + private static void ValidateToolFilterList(string field, IList? list) + { + if (list is null) return; + foreach (var entry in list) + { + if (entry == "*") + { + throw new ArgumentException( + $"Invalid {field} entry '*': there is no bare wildcard. " + + "Use `new ToolSet().AddBuiltIn(\"*\")`, `.AddMcp(\"*\")`, or " + + "`.AddCustom(\"*\")` to target a specific source.", + nameof(list)); + } + } + } + + /// + /// Resolves / + /// for the wire payload, + /// validating empty-mode requirements. toolFilterPrecedence is + /// always excluded so SDK consumers get composable allowlist / + /// denylist semantics. + /// + private (IList? AvailableTools, IList? ExcludedTools, OptionsUpdateToolFilterPrecedence ToolFilterPrecedence) ResolveToolFilterOptions(SessionConfigBase config) + { + ValidateToolFilterList(nameof(SessionConfigBase.AvailableTools), config.AvailableTools); + ValidateToolFilterList(nameof(SessionConfigBase.ExcludedTools), config.ExcludedTools); + + if (_options.Mode == CopilotClientMode.Empty && config.AvailableTools is null) + { + throw new ArgumentException( + "CopilotClient is in Mode = CopilotClientMode.Empty but the session config did " + + "not specify AvailableTools. Empty mode requires every session to explicitly " + + "opt into the tools it wants — e.g. " + + "`AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated)`.", + nameof(config)); + } + + return (config.AvailableTools, config.ExcludedTools, OptionsUpdateToolFilterPrecedence.Excluded); + } + + /// + /// Applies mode-specific defaults to a session config in place. Caller + /// values win — only fields left unset by the caller are filled in. + /// + private void ApplyConfigDefaultsForMode(SessionConfigBase config) + { + if (_options.Mode == CopilotClientMode.Empty) + { + config.EnableSessionTelemetry ??= false; + } + } + + /// + /// Returns the to send to the runtime, + /// adjusted for the current mode. In empty mode the + /// environment_context section is stripped unless the caller has + /// already taken control of it; append-mode messages are promoted to + /// customize so the env-context strip can apply alongside the caller's + /// content (the runtime appends + /// in both modes). + /// + private SystemMessageConfig? GetSystemMessageConfigForMode(SystemMessageConfig? supplied) + { + if (_options.Mode != CopilotClientMode.Empty) + { + return supplied; + } + + if (supplied is null) + { + return new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = new Dictionary + { + [SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove }, + }, + }; + } + + switch (supplied.Mode) + { + case SystemMessageMode.Replace: + return supplied; + case SystemMessageMode.Customize: + if (supplied.Sections is not null && supplied.Sections.ContainsKey(SystemMessageSection.EnvironmentContext)) + { + return supplied; + } + var mergedSections = supplied.Sections is null + ? new Dictionary() + : new Dictionary(supplied.Sections); + mergedSections[SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove }; + return new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Content = supplied.Content, + Sections = mergedSections, + }; + case SystemMessageMode.Append: + case null: + // Promote to customize so we can also strip environment_context. + // The runtime appends Content to additional instructions in both + // customize and append modes, so the caller's text is preserved. + return new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Content = supplied.Content, + Sections = new Dictionary + { + [SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove }, + }, + }; + default: + return supplied; + } + } + + /// + /// Applies the post-create / post-resume session.options.update + /// patch for the current mode. In empty mode this defaults the four + /// overridable feature flags to safe values (caller values from + /// win); installedPlugins=[] is + /// unconditional under empty mode so apps that need plugins must switch + /// modes. In copilot-cli mode only explicitly-set fields are forwarded. + /// + private async Task UpdateSessionOptionsForModeAsync(CopilotSession session, SessionConfigBase config, CancellationToken cancellationToken) + { + var hasAnyPatch = false; + bool? skipCustomInstructions = null; + bool? customAgentsLocalOnly = null; + bool? coauthorEnabled = null; + bool? manageScheduleEnabled = null; + IList? installedPlugins = null; + + if (_options.Mode == CopilotClientMode.Empty) + { + skipCustomInstructions = config.SkipCustomInstructions ?? true; + customAgentsLocalOnly = config.CustomAgentsLocalOnly ?? true; + coauthorEnabled = config.CoauthorEnabled ?? false; + manageScheduleEnabled = config.ManageScheduleEnabled ?? false; + installedPlugins = new List(); + hasAnyPatch = true; + } + else + { + if (config.SkipCustomInstructions is not null) { skipCustomInstructions = config.SkipCustomInstructions; hasAnyPatch = true; } + if (config.CustomAgentsLocalOnly is not null) { customAgentsLocalOnly = config.CustomAgentsLocalOnly; hasAnyPatch = true; } + if (config.CoauthorEnabled is not null) { coauthorEnabled = config.CoauthorEnabled; hasAnyPatch = true; } + if (config.ManageScheduleEnabled is not null) { manageScheduleEnabled = config.ManageScheduleEnabled; hasAnyPatch = true; } + } + + if (!hasAnyPatch) return; + + try + { +#pragma warning disable GHCP001 + await session.Rpc.Options.UpdateAsync( + skipCustomInstructions: skipCustomInstructions, + customAgentsLocalOnly: customAgentsLocalOnly, + coauthorEnabled: coauthorEnabled, + manageScheduleEnabled: manageScheduleEnabled, + installedPlugins: installedPlugins, + cancellationToken: cancellationToken).ConfigureAwait(false); +#pragma warning restore GHCP001 + } + catch + { + // The runtime session exists but the post-create options + // patch failed — best-effort destroy so we don't leak it + // (in empty mode it would otherwise stay alive with + // permissive defaults). + try + { + await session.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Swallow: original error is what the caller needs. + } + throw; + } + } + /// /// Creates a new Copilot session with the specified configuration. /// @@ -523,6 +739,10 @@ public async Task CreateSessionAsync(SessionConfig config, Cance var connection = await EnsureConnectedAsync(cancellationToken); var totalTimestamp = Stopwatch.GetTimestamp(); + ApplyConfigDefaultsForMode(config); + config.SystemMessage = GetSystemMessageConfigForMode(config.SystemMessage); + var toolFilter = ResolveToolFilterOptions(config); + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPreMcpToolCall != null || @@ -590,8 +810,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, - config.AvailableTools, - config.ExcludedTools, + toolFilter.AvailableTools, + toolFilter.ExcludedTools, config.Provider, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, @@ -624,7 +844,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance Canvases: config.Canvases, RequestCanvasRenderer: config.RequestCanvasRenderer, RequestExtensions: config.RequestExtensions, - ExtensionInfo: config.ExtensionInfo); + ExtensionInfo: config.ExtensionInfo, + ToolFilterPrecedence: toolFilter.ToolFilterPrecedence); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -637,6 +858,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); session.SetOpenCanvases(response.OpenCanvases); + + await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -691,6 +914,10 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var connection = await EnsureConnectedAsync(cancellationToken); var totalTimestamp = Stopwatch.GetTimestamp(); + ApplyConfigDefaultsForMode(config); + config.SystemMessage = GetSystemMessageConfigForMode(config.SystemMessage); + var toolFilter = ResolveToolFilterOptions(config); + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPreMcpToolCall != null || @@ -756,8 +983,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.ReasoningEffort, config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), wireSystemMessage, - config.AvailableTools, - config.ExcludedTools, + toolFilter.AvailableTools, + toolFilter.ExcludedTools, config.Provider, config.EnableSessionTelemetry, config.OnPermissionRequest != null ? true : null, @@ -792,7 +1019,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes RequestCanvasRenderer: config.RequestCanvasRenderer, RequestExtensions: config.RequestExtensions, ExtensionInfo: config.ExtensionInfo, - OpenCanvases: config.OpenCanvases); + OpenCanvases: config.OpenCanvases, + ToolFilterPrecedence: toolFilter.ToolFilterPrecedence); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -805,6 +1033,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.WorkspacePath = response.WorkspacePath; session.SetCapabilities(response.Capabilities); session.SetOpenCanvases(response.OpenCanvases); + + await UpdateSessionOptionsForModeAsync(session, config, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -1439,6 +1669,15 @@ private static bool IsUnsupportedConnectMethod(RemoteRpcException ex) startInfo.Environment["COPILOT_HOME"] = options.BaseDirectory; } + // In empty mode, disable the system keychain. Keytar reads from a + // process-wide store that's shared across sessions, which is unsafe + // for multi-tenant hosts. The runtime falls back to file-based + // credential storage scoped to COPILOT_HOME. + if (options.Mode == CopilotClientMode.Empty) + { + startInfo.Environment["COPILOT_DISABLE_KEYTAR"] = "1"; + } + // Set telemetry environment variables if configured if (options.Telemetry is { } telemetry) { @@ -1887,7 +2126,8 @@ internal record CreateSessionRequest( IList? Canvases = null, bool? RequestCanvasRenderer = null, bool? RequestExtensions = null, - ExtensionInfo? ExtensionInfo = null); + ExtensionInfo? ExtensionInfo = null, + OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -1959,7 +2199,8 @@ internal record ResumeSessionRequest( bool? RequestCanvasRenderer = null, bool? RequestExtensions = null, ExtensionInfo? ExtensionInfo = null, - IList? OpenCanvases = null); + IList? OpenCanvases = null, + OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( diff --git a/dotnet/src/ToolSet.cs b/dotnet/src/ToolSet.cs new file mode 100644 index 000000000..5045741b7 --- /dev/null +++ b/dotnet/src/ToolSet.cs @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.RegularExpressions; + +namespace GitHub.Copilot; + +/// +/// Builder for / +/// using source-qualified filter +/// patterns (builtin:*, mcp:<name>, custom:*, etc.). +/// +/// +/// +/// 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. +/// +/// +/// inherits from List<string>, so instances +/// can be assigned directly to +/// or . +/// +/// +/// +/// +/// var session = await client.CreateSessionAsync(new SessionConfig +/// { +/// AvailableTools = new ToolSet() +/// .AddBuiltIn(BuiltInTools.Isolated) +/// .AddMcp("*") +/// .AddCustom("*"), +/// }); +/// +/// +public sealed class ToolSet : List +{ + private static readonly Regex s_validToolName = new(@"^[a-zA-Z0-9_-]+$", RegexOptions.Compiled); + + /// + /// Adds one or more built-in tool patterns. + /// + /// A specific built-in tool name (e.g. "bash") or + /// "*" to match all built-in tools. + /// This for chaining. + public ToolSet AddBuiltIn(string name) + { + ValidateName("builtin", name); + Add($"builtin:{name}"); + return this; + } + + /// + /// Adds a list of built-in tool patterns + /// (e.g. ). + /// + /// Built-in tool names to add. + /// This for chaining. + public ToolSet AddBuiltIn(IEnumerable names) + { + ArgumentNullException.ThrowIfNull(names); + foreach (var name in names) + { + AddBuiltIn(name); + } + return this; + } + + /// + /// Adds a custom tool pattern. Matches tools registered via the SDK's + /// option or via custom agents. + /// + /// A specific custom tool name or "*" to match + /// all custom tools. + /// This for chaining. + public ToolSet AddCustom(string name) + { + ValidateName("custom", name); + Add($"custom:{name}"); + return this; + } + + /// + /// Adds an MCP tool pattern. Matches tools advertised by any configured + /// MCP server. + /// + /// The runtime's canonical wire name for the MCP + /// tool (e.g. "github-list_issues"), or "*" to match all + /// MCP tools from any server. + /// This for chaining. + public ToolSet AddMcp(string toolName) + { + ValidateName("mcp", toolName); + Add($"mcp:{toolName}"); + return this; + } + + private static void ValidateName(string kind, string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException( + $"Invalid {kind} tool name: must not be null or empty.", + nameof(name)); + } + if (name == "*") + { + return; + } + if (!s_validToolName.IsMatch(name)) + { + throw new ArgumentException( + $"Invalid {kind} tool name '{name}': tool names must match /^[a-zA-Z0-9_-]+$/ " + + "or be the wildcard '*'.", + nameof(name)); + } + } +} + +/// +/// Curated sets of built-in tool names for common scenarios. Each constant is +/// meant to be passed to . +/// +public static class 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 + /// 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. + /// + /// + public static IReadOnlyList Isolated { get; } = + [ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", + ]; +} diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index e46a7a888..d05316215 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -206,6 +206,48 @@ internal UriRuntimeConnection() { } public string? ConnectionToken { get; set; } } +/// +/// Selects the defaulting strategy used by . +/// +public enum CopilotClientMode +{ + /// + /// Disables optional features by default. The app must explicitly opt into + /// anything it needs. Required for any scenario where CLI-like ambient + /// behavior is unsafe (e.g., multi-user servers). + /// + /// When this mode is selected: + /// + /// + /// The client constructor requires + /// or + /// to be set. + /// must be supplied on + /// every session — no tools are exposed by default. + /// session.create always sets + /// toolFilterPrecedence: "excluded" so the allowlist and denylist + /// compose naturally. + /// The SDK injects safe defaults for ambient session features + /// (telemetry, custom instructions, plugins, environment context, etc.). + /// COPILOT_DISABLE_KEYTAR=1 is set on the spawned runtime so + /// credentials are persisted to COPILOT_HOME rather than a + /// process-wide system keychain. + /// + /// + Empty, + + /// + /// Uses defaults equivalent to GitHub Copilot CLI. The default. Useful when + /// building a coding agent that shares sessions with Copilot CLI. + /// + /// Do not use this mode for server-based multi-user applications — + /// the default coding agent has tools and capabilities that operate across + /// sessions and can access the host OS environment. + /// + /// + CopilotCli, +} + /// /// Configuration options for creating a instance. /// @@ -237,8 +279,23 @@ private CopilotClientOptions(CopilotClientOptions? other) SessionFs = other.SessionFs; SessionIdleTimeoutSeconds = other.SessionIdleTimeoutSeconds; EnableRemoteSessions = other.EnableRemoteSessions; + Mode = other.Mode; } + /// + /// Selects the SDK defaulting strategy. See . + /// + /// + /// When set to , the SDK validates that + /// the app has supplied the required configuration + /// ( or , plus + /// on each session) and + /// translates session creation requests into runtime options that flip + /// tool filter precedence to excluded-wins so exclusions are + /// expressible. + /// + public CopilotClientMode Mode { get; set; } = CopilotClientMode.CopilotCli; + /// /// How to connect to the runtime. When null, the default is /// with the bundled runtime. @@ -2306,6 +2363,10 @@ protected SessionConfigBase(SessionConfigBase? other) OnUserInputRequest = other.OnUserInputRequest; Provider = other.Provider; EnableSessionTelemetry = other.EnableSessionTelemetry; + SkipCustomInstructions = other.SkipCustomInstructions; + CustomAgentsLocalOnly = other.CustomAgentsLocalOnly; + CoauthorEnabled = other.CoauthorEnabled; + ManageScheduleEnabled = other.ManageScheduleEnabled; ReasoningEffort = other.ReasoningEffort; CreateSessionFsProvider = other.CreateSessionFsProvider; GitHubToken = other.GitHubToken; @@ -2391,6 +2452,42 @@ protected SessionConfigBase(SessionConfigBase? other) /// public bool? EnableSessionTelemetry { get; set; } + /// + /// When , suppresses loading of custom instruction files + /// (e.g. .github/copilot-instructions.md, AGENTS.md) from the working directory. + /// When , the SDK chooses based on + /// : true under + /// (instructions are not loaded + /// unless the app explicitly opts in), null otherwise. + /// + public bool? SkipCustomInstructions { get; set; } + + /// + /// When , custom-agent discovery is restricted to the + /// session's local working directory (no organisation-level discovery). + /// When , the SDK chooses based on + /// : true under + /// , null otherwise. + /// + public bool? CustomAgentsLocalOnly { get; set; } + + /// + /// When , allows the runtime to append a + /// Co-authored-by trailer when it commits on behalf of the user. + /// When , the SDK chooses based on + /// : false under + /// , null otherwise. + /// + public bool? CoauthorEnabled { get; set; } + + /// + /// When , enables the manage_schedule tool + /// (host scheduler integration). When , the SDK + /// chooses based on : false + /// under , null otherwise. + /// + public bool? ManageScheduleEnabled { get; set; } + /// Handler for permission requests from the server. public Func>? OnPermissionRequest { get; set; } diff --git a/dotnet/test/E2E/ModeEmptyE2ETests.cs b/dotnet/test/E2E/ModeEmptyE2ETests.cs new file mode 100644 index 000000000..df1bbc857 --- /dev/null +++ b/dotnet/test/E2E/ModeEmptyE2ETests.cs @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class ModeEmptyE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "mode_empty", output) +{ + private static CopilotClientOptions EmptyModeOptions(E2ETestContext ctx) => new() + { + Mode = CopilotClientMode.Empty, + BaseDirectory = ctx.HomeDir, + }; + + [Fact] + public async Task Empty_Mode_Isolated_Set_Shell_Tool_Is_Not_Exposed() + { + await using var client = Ctx.CreateClient(options: EmptyModeOptions(Ctx)); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." }); + + var exchanges = await Ctx.GetExchangesAsync(); + var toolNames = GetToolNames(exchanges[^1]); + + Assert.DoesNotContain("bash", toolNames); + Assert.DoesNotContain("powershell", toolNames); + Assert.DoesNotContain("edit", toolNames); + Assert.DoesNotContain("grep", toolNames); + Assert.DoesNotContain("web_fetch", toolNames); + + Assert.Contains(toolNames, name => BuiltInTools.Isolated.Contains(name)); + } + + [Fact] + public async Task Empty_Mode_Builtin_Star_Exposes_All_Built_In_Tools() + { + await using var client = Ctx.CreateClient(options: EmptyModeOptions(Ctx)); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn("*"), + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." }); + + var exchanges = await Ctx.GetExchangesAsync(); + var toolNames = GetToolNames(exchanges[^1]); + + var shellToolName = OperatingSystem.IsWindows() ? "powershell" : "bash"; + Assert.Contains(shellToolName, toolNames); + } + + [Fact] + public async Task Empty_Mode_Excluded_Tools_Subtracts_From_Available_Tools() + { + var shellToolName = OperatingSystem.IsWindows() ? "powershell" : "bash"; + await using var client = Ctx.CreateClient(options: EmptyModeOptions(Ctx)); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn("*"), + ExcludedTools = [$"builtin:{shellToolName}"], + }); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say hi." }); + + var exchanges = await Ctx.GetExchangesAsync(); + var toolNames = GetToolNames(exchanges[^1]); + + Assert.DoesNotContain(shellToolName, toolNames); + Assert.NotEmpty(toolNames); + } + + [Fact] + public async Task Empty_Mode_Strips_Environment_Context_From_The_System_Message_By_Default() + { + await using var client = Ctx.CreateClient(options: EmptyModeOptions(Ctx)); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Content = "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + }, + }); + + var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Name an element." }); + Assert.Contains("ARGON", reply?.Data.Content ?? string.Empty); + + var exchanges = await Ctx.GetExchangesAsync(); + var systemMessage = GetSystemMessage(exchanges[^1]); + Assert.DoesNotMatch(@"(?i)Current working directory:", systemMessage); + Assert.DoesNotMatch(@"(?i)Operating System:", systemMessage); + } + + [Fact] + public async Task Empty_Mode_System_Message_Replace_Llm_Follows_Caller_Content_Verbatim() + { + await using var client = Ctx.CreateClient(options: EmptyModeOptions(Ctx)); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + }, + }); + + var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello." }); + Assert.Contains("KRYPTON", reply?.Data.Content ?? string.Empty); + } + + [Fact] + public async Task Empty_Mode_Append_Caller_Instruction_Takes_Effect_And_Env_Context_Stripped() + { + await using var client = Ctx.CreateClient(options: EmptyModeOptions(Ctx)); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + AvailableTools = new ToolSet().AddBuiltIn(BuiltInTools.Isolated), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + }, + }); + + var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Name a noble gas." }); + Assert.Contains("XENON", reply?.Data.Content ?? string.Empty); + + var exchanges = await Ctx.GetExchangesAsync(); + var systemMessage = GetSystemMessage(exchanges[^1]); + Assert.DoesNotMatch(@"(?i)Current working directory:", systemMessage); + Assert.DoesNotMatch(@"(?i)Operating System:", systemMessage); + } +} diff --git a/dotnet/test/Unit/ToolSetTests.cs b/dotnet/test/Unit/ToolSetTests.cs new file mode 100644 index 000000000..39726808b --- /dev/null +++ b/dotnet/test/Unit/ToolSetTests.cs @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public class ToolSetTests +{ + private static readonly string[] BashAndView = ["bash", "view"]; + private static readonly string[] ExpectedBashAndView = ["builtin:bash", "builtin:view"]; + private static readonly string[] AllWildcards = ["builtin:*", "custom:*", "mcp:*"]; + private static readonly string[] BannedTools = ["bash", "powershell", "edit", "grep", "web_fetch"]; + private static readonly string[] ExpectedIsolatedTools = ["ask_user", "task_complete"]; + + [Fact] + public void ToolSet_Emits_Source_Qualified_Strings() + { + var items = new ToolSet() + .AddBuiltIn("bash") + .AddBuiltIn("*") + .AddCustom("my_tool") + .AddCustom("*") + .AddMcp("github-list_issues") + .AddMcp("*") + .ToList(); + + Assert.Equal( + [ + "builtin:bash", + "builtin:*", + "custom:my_tool", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + ], items); + } + + [Fact] + public void ToolSet_AddBuiltIn_Accepts_Enumerable() + { + var items = new ToolSet().AddBuiltIn(BashAndView).ToList(); + Assert.Equal(ExpectedBashAndView, items); + } + + [Theory] + [InlineData("has:colon")] + [InlineData("has space")] + [InlineData("")] + public void ToolSet_Rejects_Invalid_Names(string bad) + { + Assert.Throws(() => new ToolSet().AddBuiltIn(bad)); + Assert.Throws(() => new ToolSet().AddCustom(bad)); + Assert.Throws(() => new ToolSet().AddMcp(bad)); + } + + [Fact] + public void ToolSet_Accepts_Wildcard() + { + var items = new ToolSet().AddBuiltIn("*").AddCustom("*").AddMcp("*").ToList(); + Assert.Equal(AllWildcards, items); + } + + [Fact] + public void BuiltInTools_Isolated_Does_Not_Contain_Banned_Tools() + { + foreach (var banned in BannedTools) + { + Assert.DoesNotContain(banned, BuiltInTools.Isolated); + } + } + + [Fact] + public void BuiltInTools_Isolated_Contains_Expected_Tools() + { + foreach (var expected in ExpectedIsolatedTools) + { + Assert.Contains(expected, BuiltInTools.Isolated); + } + } + + [Fact] + public void CopilotClient_Mode_Empty_Throws_Without_Base_Directory() + { + var ex = Assert.Throws(() => new CopilotClient(new CopilotClientOptions + { + Mode = CopilotClientMode.Empty, + })); + Assert.Contains("Empty", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CopilotClient_Mode_Empty_Accepts_Base_Directory() + { + var dir = Path.Combine(Path.GetTempPath(), "copilot-empty-mode-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + using var client = new CopilotClient(new CopilotClientOptions + { + Mode = CopilotClientMode.Empty, + BaseDirectory = dir, + }); + Assert.NotNull(client); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void CopilotClient_Default_Mode_Is_CopilotCli() + { + using var client = new CopilotClient(new CopilotClientOptions()); + Assert.NotNull(client); + } +} diff --git a/go/client.go b/go/client.go index ae89128a1..94ef50f5e 100644 --- a/go/client.go +++ b/go/client.go @@ -237,6 +237,7 @@ func NewClient(options *ClientOptions) *Client { } client.options = opts + validateNewClientForMode(&client.options) return client } @@ -597,6 +598,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses return nil, err } + c.applyConfigDefaultsForMode(config) + req := createSessionRequest{} req.Model = config.Model req.ClientName = config.ClientName @@ -606,12 +609,22 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.EnableConfigDiscovery = Bool(true) } req.Tools = config.Tools - wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + systemMessage := c.systemMessageForMode(config.SystemMessage) + wireSystemMessage, transformCallbacks := extractTransformCallbacks(systemMessage) req.SystemMessage = wireSystemMessage - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + availableTools, excludedTools, precedence, ferr := c.resolveToolFilterOptions(config.AvailableTools, config.ExcludedTools) + if ferr != nil { + return nil, ferr + } + req.AvailableTools = availableTools + req.ExcludedTools = excludedTools + req.ToolFilterPrecedence = precedence req.Provider = config.Provider req.EnableSessionTelemetry = config.EnableSessionTelemetry + req.SkipCustomInstructions = config.SkipCustomInstructions + req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly + req.CoauthorEnabled = config.CoauthorEnabled + req.ManageScheduleEnabled = config.ManageScheduleEnabled req.ModelCapabilities = config.ModelCapabilities req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers @@ -758,6 +771,15 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses session.workspacePath = response.WorkspacePath session.setCapabilities(response.Capabilities) + if err := c.updateSessionOptionsForMode(ctx, session, optBackInFields{ + SkipCustomInstructions: config.SkipCustomInstructions, + CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, + CoauthorEnabled: config.CoauthorEnabled, + ManageScheduleEnabled: config.ManageScheduleEnabled, + }); err != nil { + return nil, err + } + return session, nil } @@ -793,19 +815,31 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, return nil, err } + c.applyResumeDefaultsForMode(config) + var req resumeSessionRequest req.SessionID = sessionID req.ClientName = config.ClientName req.Model = config.Model req.ReasoningEffort = config.ReasoningEffort - wireSystemMessage, transformCallbacks := extractTransformCallbacks(config.SystemMessage) + systemMessage := c.systemMessageForMode(config.SystemMessage) + wireSystemMessage, transformCallbacks := extractTransformCallbacks(systemMessage) req.SystemMessage = wireSystemMessage req.Tools = config.Tools req.Provider = config.Provider req.EnableSessionTelemetry = config.EnableSessionTelemetry + req.SkipCustomInstructions = config.SkipCustomInstructions + req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly + req.CoauthorEnabled = config.CoauthorEnabled + req.ManageScheduleEnabled = config.ManageScheduleEnabled req.ModelCapabilities = config.ModelCapabilities - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools + availableTools, excludedTools, precedence, ferr := c.resolveToolFilterOptions(config.AvailableTools, config.ExcludedTools) + if ferr != nil { + return nil, ferr + } + req.AvailableTools = availableTools + req.ExcludedTools = excludedTools + req.ToolFilterPrecedence = precedence if config.Streaming != nil { req.Streaming = config.Streaming } @@ -955,6 +989,15 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, session.setCapabilities(response.Capabilities) session.setOpenCanvases(response.OpenCanvases) + if err := c.updateSessionOptionsForMode(ctx, session, optBackInFields{ + SkipCustomInstructions: config.SkipCustomInstructions, + CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, + CoauthorEnabled: config.CoauthorEnabled, + ManageScheduleEnabled: config.ManageScheduleEnabled, + }); err != nil { + return nil, err + } + return session, nil } @@ -1536,6 +1579,10 @@ func (c *Client) startCLIServer(ctx context.Context) error { c.process.Env = setEnvValue(c.process.Env, "COPILOT_HOME", c.options.BaseDirectory) } + if c.options.Mode == ModeEmpty { + c.process.Env = setEnvValue(c.process.Env, "COPILOT_DISABLE_KEYTAR", "1") + } + if c.options.Telemetry != nil { t := c.options.Telemetry c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_ENABLED", "true") diff --git a/go/internal/e2e/mode_empty_e2e_test.go b/go/internal/e2e/mode_empty_e2e_test.go new file mode 100644 index 000000000..86e1246c6 --- /dev/null +++ b/go/internal/e2e/mode_empty_e2e_test.go @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package e2e + +import ( + "context" + "regexp" + "runtime" + "slices" + "strings" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +// E2E coverage for Mode = ModeEmpty + ToolSet patterns. The runtime is +// mode-agnostic — these tests verify the SDK's translation reaches the +// runtime by inspecting captured chat-completion requests via the proxy. +func TestModeEmptyE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient(func(o *copilot.ClientOptions) { + o.Mode = copilot.ModeEmpty + o.BaseDirectory = ctx.HomeDir + }) + t.Cleanup(func() { client.ForceStop() }) + + getToolsExposedToLLM := func(t *testing.T) []string { + t.Helper() + exchanges := ctx.WaitForExchanges(t, 1) + last := exchanges[len(exchanges)-1] + names := make([]string, 0, len(last.Request.Tools)) + for _, tool := range last.Request.Tools { + if tool.Type == "function" && tool.Function.Name != "" { + names = append(names, tool.Function.Name) + } + } + return names + } + + getSystemMessageSentToLLM := func(t *testing.T) string { + t.Helper() + exchanges := ctx.WaitForExchanges(t, 1) + last := exchanges[len(exchanges)-1] + for _, m := range last.Request.Messages { + if m.Role == "system" { + return m.Content + } + } + return "" + } + + shellToolName := "bash" + if runtime.GOOS == "windows" { + shellToolName = "powershell" + } + + t.Run("empty mode isolated set shell tool is not exposed", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + _, _ = session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Say hi."}) + + toolNames := getToolsExposedToLLM(t) + for _, banned := range []string{"bash", "powershell", "edit", "grep", "web_fetch"} { + if slices.Contains(toolNames, banned) { + t.Errorf("isolated set must not expose %q, got tools %v", banned, toolNames) + } + } + anyIsolated := false + for _, name := range copilot.BuiltInToolsIsolated { + if slices.Contains(toolNames, name) { + anyIsolated = true + break + } + } + if !anyIsolated { + t.Errorf("expected at least one isolated tool to be registered, got %v", toolNames) + } + }) + + t.Run("empty mode builtin star exposes all built in tools", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn("*").ToSlice(), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + _, _ = session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Say hi."}) + + toolNames := getToolsExposedToLLM(t) + if !slices.Contains(toolNames, shellToolName) { + t.Errorf("builtin:* should expose %q, got %v", shellToolName, toolNames) + } + }) + + t.Run("empty mode excluded tools subtracts from available tools", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn("*").ToSlice(), + ExcludedTools: []string{"builtin:" + shellToolName}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + _, _ = session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Say hi."}) + + toolNames := getToolsExposedToLLM(t) + if slices.Contains(toolNames, shellToolName) { + t.Errorf("excluded shell tool %q leaked through builtin:*, got %v", shellToolName, toolNames) + } + if len(toolNames) == 0 { + t.Errorf("expected other built-ins to remain after subtraction, got empty list") + } + }) + + t.Run("empty mode strips environment context from the system message by default", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "customize", + Content: "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + reply, err := session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Name an element."}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if data, ok := reply.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(data.Content, "ARGON") { + t.Errorf("expected response to contain ARGON, got %+v", reply.Data) + } + + sys := getSystemMessageSentToLLM(t) + if regexp.MustCompile(`(?i)current working directory:`).MatchString(sys) { + t.Errorf("system message should not contain 'Current working directory:': %q", sys) + } + if regexp.MustCompile(`(?i)operating system:`).MatchString(sys) { + t.Errorf("system message should not contain 'Operating System:': %q", sys) + } + }) + + t.Run("empty mode system message replace llm follows caller content verbatim", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + reply, err := session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Hello."}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if data, ok := reply.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(data.Content, "KRYPTON") { + t.Errorf("expected response to contain KRYPTON, got %+v", reply.Data) + } + }) + + t.Run("empty mode append caller instruction takes effect and env context stripped", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: copilot.NewToolSet().AddBuiltIn(copilot.BuiltInToolsIsolated...).ToSlice(), + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "append", + Content: "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer func() { _ = session.Disconnect() }() + + sendCtx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + reply, err := session.SendAndWait(sendCtx, copilot.MessageOptions{Prompt: "Name a noble gas."}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if data, ok := reply.Data.(*copilot.AssistantMessageData); !ok || !strings.Contains(data.Content, "XENON") { + t.Errorf("expected response to contain XENON, got %+v", reply.Data) + } + + sys := getSystemMessageSentToLLM(t) + if regexp.MustCompile(`(?i)current working directory:`).MatchString(sys) { + t.Errorf("system message should not contain 'Current working directory:': %q", sys) + } + if regexp.MustCompile(`(?i)operating system:`).MatchString(sys) { + t.Errorf("system message should not contain 'Operating System:': %q", sys) + } + }) +} diff --git a/go/mode_empty.go b/go/mode_empty.go new file mode 100644 index 000000000..36e689b24 --- /dev/null +++ b/go/mode_empty.go @@ -0,0 +1,216 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package copilot + +import ( + "context" + "errors" + "fmt" + + "github.com/github/copilot-sdk/go/rpc" +) + +// validateNewClientForMode checks the cross-cutting requirements that +// [ModeEmpty] places on [ClientOptions]. Called from [NewClient]. +func validateNewClientForMode(opts *ClientOptions) { + if opts == nil || opts.Mode != ModeEmpty { + return + } + // Empty mode requires durable, app-owned storage. Either: + // - the app supplied a BaseDirectory the runtime can write to, + // - the app supplied a SessionFs implementation, + // - or the app is connecting to an externally-managed runtime via + // UriConnection (in which case the host owns storage). + if opts.BaseDirectory != "" { + return + } + if opts.SessionFs != nil { + return + } + if _, ok := opts.Connection.(UriConnection); ok { + return + } + panic("Client is in Mode=ModeEmpty but neither BaseDirectory, SessionFs, nor a UriConnection was supplied. " + + "Empty mode requires explicit, per-tenant storage; set ClientOptions.BaseDirectory or .SessionFs, " + + "or connect to an externally-managed runtime via UriConnection.") +} + +// validateToolFilterList rejects bare "*" entries with an actionable error +// pointing at the [ToolSet] builder. Called for both availableTools and +// excludedTools. +func validateToolFilterList(field string, list []string) error { + for _, entry := range list { + if entry == "*" { + return fmt.Errorf( + "invalid %s entry %q: there is no bare wildcard. "+ + "Use one or more of NewToolSet().AddBuiltIn(\"*\"), .AddMcp(\"*\"), or .AddCustom(\"*\") "+ + "to target a specific source", + field, entry) + } + } + return nil +} + +// resolveToolFilterOptions validates the configured tool filters and applies +// empty-mode invariants. Returns the (possibly-mutated) request fields to set. +func (c *Client) resolveToolFilterOptions(availableTools, excludedTools []string) ( + []string, []string, *rpc.OptionsUpdateToolFilterPrecedence, error, +) { + if err := validateToolFilterList("availableTools", availableTools); err != nil { + return nil, nil, nil, err + } + if err := validateToolFilterList("excludedTools", excludedTools); err != nil { + return nil, nil, nil, err + } + if c.options.Mode == ModeEmpty && availableTools == nil { + return nil, nil, nil, errors.New( + "Client is in Mode=ModeEmpty but the session config did not specify AvailableTools. " + + "Empty mode requires every session to explicitly opt into the tools it wants — " + + "e.g. NewToolSet().AddBuiltIn(BuiltInToolsIsolated...).ToSlice()") + } + precedence := rpc.OptionsUpdateToolFilterPrecedenceExcluded + return availableTools, excludedTools, &precedence, nil +} + +// systemMessageForMode applies empty-mode environment_context stripping to +// the caller-supplied system message config. App values win (we only inject +// when the app hasn't already specified an environment_context override). +func (c *Client) systemMessageForMode(supplied *SystemMessageConfig) *SystemMessageConfig { + if c.options.Mode != ModeEmpty { + return supplied + } + removeAction := SectionOverride{Action: SectionActionRemove} + if supplied == nil { + return &SystemMessageConfig{ + Mode: "customize", + Sections: map[string]SectionOverride{"environment_context": removeAction}, + } + } + switch supplied.Mode { + case "replace": + return supplied + case "customize": + if _, ok := supplied.Sections["environment_context"]; ok { + return supplied + } + out := *supplied + out.Sections = make(map[string]SectionOverride, len(supplied.Sections)+1) + for k, v := range supplied.Sections { + out.Sections[k] = v + } + out.Sections["environment_context"] = removeAction + return &out + case "append", "": + // Promote append/unspecified to customize so we can also strip + // environment_context. The runtime appends Content to additional + // instructions in both modes, so caller text is preserved verbatim. + return &SystemMessageConfig{ + Mode: "customize", + Content: supplied.Content, + Sections: map[string]SectionOverride{"environment_context": removeAction}, + } + default: + return supplied + } +} + +// applyConfigDefaultsForMode fills in empty-mode defaults on the session +// config in place. App-supplied values win. +func (c *Client) applyConfigDefaultsForMode(config *SessionConfig) { + if c.options.Mode != ModeEmpty { + return + } + if config.EnableSessionTelemetry == nil { + f := false + config.EnableSessionTelemetry = &f + } +} + +func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) { + if c.options.Mode != ModeEmpty { + return + } + if config.EnableSessionTelemetry == nil { + f := false + config.EnableSessionTelemetry = &f + } +} + +// updateSessionOptionsForMode applies the per-mode safe-defaults patch via +// session.options.update after create/resume succeeds. In empty mode the +// four overridable feature flags default to safe values; caller values win. +// installedPlugins=[] is unconditional in empty mode. +func (c *Client) updateSessionOptionsForMode(ctx context.Context, session *Session, base optBackInFields) error { + patch := &rpc.SessionUpdateOptionsParams{} + hasAny := false + if c.options.Mode == ModeEmpty { + if base.SkipCustomInstructions != nil { + patch.SkipCustomInstructions = base.SkipCustomInstructions + } else { + t := true + patch.SkipCustomInstructions = &t + } + if base.CustomAgentsLocalOnly != nil { + patch.CustomAgentsLocalOnly = base.CustomAgentsLocalOnly + } else { + t := true + patch.CustomAgentsLocalOnly = &t + } + if base.CoauthorEnabled != nil { + patch.CoauthorEnabled = base.CoauthorEnabled + } else { + f := false + patch.CoauthorEnabled = &f + } + if base.ManageScheduleEnabled != nil { + patch.ManageScheduleEnabled = base.ManageScheduleEnabled + } else { + f := false + patch.ManageScheduleEnabled = &f + } + patch.InstalledPlugins = []rpc.SessionInstalledPlugin{} + hasAny = true + } else { + if base.SkipCustomInstructions != nil { + patch.SkipCustomInstructions = base.SkipCustomInstructions + hasAny = true + } + if base.CustomAgentsLocalOnly != nil { + patch.CustomAgentsLocalOnly = base.CustomAgentsLocalOnly + hasAny = true + } + if base.CoauthorEnabled != nil { + patch.CoauthorEnabled = base.CoauthorEnabled + hasAny = true + } + if base.ManageScheduleEnabled != nil { + patch.ManageScheduleEnabled = base.ManageScheduleEnabled + hasAny = true + } + } + if !hasAny { + return nil + } + if _, err := session.RPC.Options.Update(ctx, patch); err != nil { + // The runtime session exists but the post-create options patch + // failed — best-effort disconnect so we don't leak it (in empty + // mode it would otherwise keep running with permissive defaults). + _ = session.Disconnect() + c.sessionsMux.Lock() + delete(c.sessions, session.SessionID) + c.sessionsMux.Unlock() + return fmt.Errorf("failed to apply mode-specific session options: %w", err) + } + return nil +} + +// optBackInFields is the subset of SessionConfig / ResumeSessionConfig shared +// by [Client.updateSessionOptionsForMode]. +type optBackInFields struct { + SkipCustomInstructions *bool + CustomAgentsLocalOnly *bool + CoauthorEnabled *bool + ManageScheduleEnabled *bool +} diff --git a/go/toolset.go b/go/toolset.go new file mode 100644 index 000000000..c64e8b7fd --- /dev/null +++ b/go/toolset.go @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package copilot + +import ( + "fmt" + "regexp" +) + +// ClientMode controls the default surface presented to sessions created by the +// [Client]. The zero value is [ModeCopilotCli], matching the legacy CLI defaults. +// +// Set [ClientOptions.Mode] to [ModeEmpty] to opt in to multi-tenant safe +// defaults: no built-in tools by default (callers must specify +// [SessionConfig.AvailableTools] explicitly), no environment_context section +// in the system message, telemetry off, custom instructions and remote-custom +// agents disabled, etc. +type ClientMode string + +const ( + // ModeCopilotCli is the default mode; sessions inherit the full Copilot + // CLI experience (all built-in tools, host environment_context, etc.). + ModeCopilotCli ClientMode = "copilot-cli" + // ModeEmpty is the multi-tenant safe-default mode. Sessions start with + // no built-in tools, no environment context, and various features + // (custom instructions, remote agents, telemetry, plugins) off by + // default. Callers can opt back in field-by-field. + ModeEmpty ClientMode = "empty" +) + +// ToolSet builds a list of source-qualified tool filter patterns +// (`builtin:*`, `mcp:`, `custom:*`, ...) for use with +// [SessionConfig.AvailableTools] or [SessionConfig.ExcludedTools]. +// +// 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. +// +// ToolSet's zero value is ready to use. Convert to []string via [ToolSet.ToSlice] +// before passing to [SessionConfig] fields, e.g. +// `(&ToolSet{}).AddBuiltIn(...).ToSlice()`. +type ToolSet struct { + items []string +} + +// NewToolSet returns an empty [ToolSet]. +func NewToolSet() *ToolSet { return &ToolSet{} } + +var toolNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// AddBuiltIn adds one or more built-in tool patterns. Pass a specific tool +// name (e.g. "bash") or "*" to match all built-in tools. +func (s *ToolSet) AddBuiltIn(names ...string) *ToolSet { + for _, n := range names { + validateToolName("builtin", n) + s.items = append(s.items, "builtin:"+n) + } + return s +} + +// AddCustom adds a custom-tool pattern. Matches tools registered via +// [SessionConfig.Tools] or via custom agents. +func (s *ToolSet) AddCustom(name string) *ToolSet { + validateToolName("custom", name) + s.items = append(s.items, "custom:"+name) + return s +} + +// AddMcp adds an MCP tool pattern. Matches tools advertised by any configured +// MCP server. +func (s *ToolSet) AddMcp(toolName string) *ToolSet { + validateToolName("mcp", toolName) + s.items = append(s.items, "mcp:"+toolName) + return s +} + +// ToSlice returns a defensive copy of the accumulated filter strings. +func (s *ToolSet) ToSlice() []string { + out := make([]string, len(s.items)) + copy(out, s.items) + return out +} + +func validateToolName(kind, name string) { + if name == "" { + panic(fmt.Sprintf("invalid %s tool name: must not be empty", kind)) + } + if name == "*" { + return + } + if !toolNameRegex.MatchString(name) { + panic(fmt.Sprintf( + "invalid %s tool name %q: tool names must match /^[a-zA-Z0-9_-]+$/ or be the wildcard %q", + kind, name, "*")) + } +} + +// BuiltInToolsIsolated lists 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 [ModeEmpty] 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. +var BuiltInToolsIsolated = []string{ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", +} diff --git a/go/toolset_test.go b/go/toolset_test.go new file mode 100644 index 000000000..6992e1200 --- /dev/null +++ b/go/toolset_test.go @@ -0,0 +1,249 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package copilot + +import ( + "reflect" + "slices" + "strings" + "testing" +) + +func TestToolSet_emitsSourceQualifiedStrings(t *testing.T) { + items := NewToolSet(). + AddBuiltIn("bash"). + AddBuiltIn("*"). + AddCustom("my_tool"). + AddCustom("*"). + AddMcp("github-list_issues"). + AddMcp("*"). + ToSlice() + want := []string{ + "builtin:bash", + "builtin:*", + "custom:my_tool", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + } + if !reflect.DeepEqual(items, want) { + t.Errorf("got %v, want %v", items, want) + } +} + +func TestToolSet_addBuiltInVariadic(t *testing.T) { + items := NewToolSet().AddBuiltIn("bash", "view").ToSlice() + want := []string{"builtin:bash", "builtin:view"} + if !reflect.DeepEqual(items, want) { + t.Errorf("got %v, want %v", items, want) + } +} + +func TestToolSet_toSliceReturnsDefensiveCopy(t *testing.T) { + set := NewToolSet().AddBuiltIn("bash") + a := set.ToSlice() + a[0] = "builtin:tampered" + if got := set.ToSlice(); !reflect.DeepEqual(got, []string{"builtin:bash"}) { + t.Errorf("internal state mutated: %v", got) + } +} + +func TestToolSet_rejectsInvalidNames(t *testing.T) { + cases := []struct { + name string + fn func() + }{ + {"colon in builtin", func() { NewToolSet().AddBuiltIn("has:colon") }}, + {"space in mcp", func() { NewToolSet().AddMcp("has space") }}, + {"empty custom", func() { NewToolSet().AddCustom("") }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic, got none") + } + }() + c.fn() + }) + } +} + +func TestBuiltInToolsIsolated_membership(t *testing.T) { + for _, banned := range []string{"bash", "edit", "grep", "web_fetch"} { + if slices.Contains(BuiltInToolsIsolated, banned) { + t.Errorf("isolated set must not contain %q", banned) + } + } + for _, expected := range []string{"ask_user", "task_complete"} { + if !slices.Contains(BuiltInToolsIsolated, expected) { + t.Errorf("isolated set must contain %q", expected) + } + } +} + +func TestNewClient_modeEmptyRejectsWithoutStorage(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic, got none") + } + msg, ok := r.(string) + if !ok { + t.Fatalf("expected string panic, got %T", r) + } + if !strings.Contains(strings.ToLower(msg), "empty") { + t.Errorf("panic message should mention empty mode, got %q", msg) + } + }() + NewClient(&ClientOptions{Mode: ModeEmpty}) +} + +func TestNewClient_modeEmptyAcceptsBaseDirectory(t *testing.T) { + c := NewClient(&ClientOptions{ + Mode: ModeEmpty, + BaseDirectory: t.TempDir(), + }) + if c.options.Mode != ModeEmpty { + t.Errorf("expected ModeEmpty, got %q", c.options.Mode) + } +} + +func TestNewClient_modeEmptyAcceptsUriConnection(t *testing.T) { + c := NewClient(&ClientOptions{ + Mode: ModeEmpty, + Connection: UriConnection{URL: "8080"}, + }) + if c.options.Mode != ModeEmpty { + t.Errorf("expected ModeEmpty, got %q", c.options.Mode) + } +} + +func TestNewClient_modeCopilotCliIsDefault(t *testing.T) { + c := NewClient(nil) + if c.options.Mode != "" && c.options.Mode != ModeCopilotCli { + t.Errorf("expected default mode to be empty/copilot-cli, got %q", c.options.Mode) + } +} + +func TestValidateToolFilterList_rejectsBareWildcard(t *testing.T) { + err := validateToolFilterList("availableTools", []string{"builtin:bash", "*"}) + if err == nil { + t.Fatal("expected error for bare wildcard") + } + if !strings.Contains(err.Error(), "bare wildcard") { + t.Errorf("expected message about bare wildcard, got %q", err.Error()) + } +} + +func TestValidateToolFilterList_allowsSourceQualifiedWildcards(t *testing.T) { + if err := validateToolFilterList("availableTools", []string{"builtin:*", "mcp:*", "custom:*"}); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveToolFilterOptions_emptyModeRequiresAvailableTools(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + _, _, _, err := c.resolveToolFilterOptions(nil, nil) + if err == nil { + t.Fatal("expected error in empty mode without available tools") + } +} + +func TestResolveToolFilterOptions_setsExcludedPrecedence(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + _, _, precedence, err := c.resolveToolFilterOptions(nil, []string{"builtin:bash"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if precedence == nil || *precedence != "excluded" { + t.Errorf("expected precedence 'excluded', got %v", precedence) + } +} + +func TestSystemMessageForMode_emptyModeStripsEnvContextWhenNil(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + got := c.systemMessageForMode(nil) + if got == nil || got.Mode != "customize" { + t.Fatalf("expected customize mode, got %+v", got) + } + if action, ok := got.Sections["environment_context"]; !ok || action.Action != SectionActionRemove { + t.Errorf("expected environment_context: remove, got %+v", got.Sections) + } +} + +func TestSystemMessageForMode_emptyModePromotesAppendToCustomize(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + got := c.systemMessageForMode(&SystemMessageConfig{Mode: "append", Content: "extra"}) + if got.Mode != "customize" { + t.Errorf("expected customize, got %q", got.Mode) + } + if got.Content != "extra" { + t.Errorf("expected content preserved, got %q", got.Content) + } + if action, ok := got.Sections["environment_context"]; !ok || action.Action != SectionActionRemove { + t.Errorf("expected environment_context removed") + } +} + +func TestSystemMessageForMode_emptyModePreservesReplace(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + in := &SystemMessageConfig{Mode: "replace", Content: "whole prompt"} + got := c.systemMessageForMode(in) + if got != in { + t.Errorf("expected verbatim passthrough for replace, got %+v", got) + } +} + +func TestSystemMessageForMode_emptyModeRespectsCallerSection(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + in := &SystemMessageConfig{ + Mode: "customize", + Sections: map[string]SectionOverride{ + "environment_context": {Action: SectionActionReplace, Content: "custom"}, + }, + } + got := c.systemMessageForMode(in) + if got != in { + t.Errorf("expected caller's section override preserved verbatim") + } +} + +func TestSystemMessageForMode_copilotCliPassthrough(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + in := &SystemMessageConfig{Mode: "append", Content: "x"} + got := c.systemMessageForMode(in) + if got != in { + t.Errorf("non-empty mode must not alter system message") + } +} + +func TestApplyConfigDefaultsForMode_emptyDefaultsTelemetryFalse(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableSessionTelemetry == nil || *cfg.EnableSessionTelemetry != false { + t.Errorf("expected telemetry default false in empty mode, got %v", cfg.EnableSessionTelemetry) + } +} + +func TestApplyConfigDefaultsForMode_emptyHonorsCallerTelemetry(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeEmpty, BaseDirectory: t.TempDir()}) + trueVal := true + cfg := &SessionConfig{EnableSessionTelemetry: &trueVal} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableSessionTelemetry == nil || *cfg.EnableSessionTelemetry != true { + t.Errorf("caller-supplied telemetry must win") + } +} + +func TestApplyConfigDefaultsForMode_copilotCliLeavesNil(t *testing.T) { + c := NewClient(&ClientOptions{Mode: ModeCopilotCli}) + cfg := &SessionConfig{} + c.applyConfigDefaultsForMode(cfg) + if cfg.EnableSessionTelemetry != nil { + t.Errorf("non-empty mode must not default telemetry") + } +} diff --git a/go/types.go b/go/types.go index 52fd27eee..3a46accef 100644 --- a/go/types.go +++ b/go/types.go @@ -130,6 +130,15 @@ type ClientOptions struct { // directory are accessible from GitHub web and mobile. // Ignored when connecting to an existing runtime via [UriConnection]. EnableRemoteSessions bool + // Mode controls the default tool surface and feature flags presented to + // sessions created by this client. The zero value ([ModeCopilotCli]) + // matches legacy CLI defaults. Set to [ModeEmpty] to opt in to + // multi-tenant safe defaults — see [ClientMode] for details. + // + // When Mode is [ModeEmpty], NewClient requires either BaseDirectory, + // SessionFs, or a [UriConnection] so the runtime has persistent storage + // for session state. + Mode ClientMode } // CloudSessionRepository is GitHub repository metadata associated with a cloud session. @@ -920,6 +929,19 @@ type SessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // SkipCustomInstructions, when non-nil, controls whether the runtime loads + // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. + SkipCustomInstructions *bool + // CustomAgentsLocalOnly, when non-nil, restricts custom agents to those + // defined locally. See also [ClientOptions.Mode] = [ModeEmpty]. + CustomAgentsLocalOnly *bool + // CoauthorEnabled, when non-nil, controls whether the `coauthor` tool is + // exposed. See also [ClientOptions.Mode] = [ModeEmpty]. + CoauthorEnabled *bool + // ManageScheduleEnabled, when non-nil, controls whether the + // `manage_schedule` tool is exposed. See also [ClientOptions.Mode] = + // [ModeEmpty]. + ManageScheduleEnabled *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -1149,6 +1171,19 @@ type ResumeSessionConfig struct { // regardless of this setting. This is independent of the OpenTelemetry // configuration in ClientOptions.Telemetry. EnableSessionTelemetry *bool + // SkipCustomInstructions, when non-nil, controls whether the runtime loads + // custom instruction files. See also [ClientOptions.Mode] = [ModeEmpty]. + SkipCustomInstructions *bool + // CustomAgentsLocalOnly, when non-nil, restricts custom agents to those + // defined locally. See also [ClientOptions.Mode] = [ModeEmpty]. + CustomAgentsLocalOnly *bool + // CoauthorEnabled, when non-nil, controls whether the `coauthor` tool is + // exposed. See also [ClientOptions.Mode] = [ModeEmpty]. + CoauthorEnabled *bool + // ManageScheduleEnabled, when non-nil, controls whether the + // `manage_schedule` tool is exposed. See also [ClientOptions.Mode] = + // [ModeEmpty]. + ManageScheduleEnabled *bool // ModelCapabilities overrides individual model capabilities resolved by the runtime. // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride @@ -1460,47 +1495,52 @@ type SessionLifecycleHandler func(event SessionLifecycleEvent) // createSessionRequest is the request for session.create type createSessionRequest struct { - Model string `json:"model,omitempty"` - SessionID string `json:"sessionId,omitempty"` - ClientName string `json:"clientName,omitempty"` - ReasoningEffort string `json:"reasoningEffort,omitempty"` - Tools []Tool `json:"tools,omitempty"` - SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` - AvailableTools []string `json:"availableTools"` - ExcludedTools []string `json:"excludedTools,omitempty"` - Provider *ProviderConfig `json:"provider,omitempty"` - EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` - ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` - RequestPermission *bool `json:"requestPermission,omitempty"` - RequestUserInput *bool `json:"requestUserInput,omitempty"` - RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` - RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` - Hooks *bool `json:"hooks,omitempty"` - WorkingDirectory string `json:"workingDirectory,omitempty"` - Streaming *bool `json:"streaming,omitempty"` - IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` - MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` - EnvValueMode string `json:"envValueMode,omitempty"` - CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` - DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` - Agent string `json:"agent,omitempty"` - ConfigDir string `json:"configDir,omitempty"` - EnableConfigDiscovery *bool `json:"enableConfigDiscovery,omitempty"` - SkillDirectories []string `json:"skillDirectories,omitempty"` - InstructionDirectories []string `json:"instructionDirectories,omitempty"` - DisabledSkills []string `json:"disabledSkills,omitempty"` - InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` - Commands []wireCommand `json:"commands,omitempty"` - RequestElicitation *bool `json:"requestElicitation,omitempty"` - GitHubToken string `json:"gitHubToken,omitempty"` - RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` - Cloud *CloudSessionOptions `json:"cloud,omitempty"` - Canvases []CanvasDeclaration `json:"canvases,omitempty"` - RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` - RequestExtensions *bool `json:"requestExtensions,omitempty"` - ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` - Traceparent string `json:"traceparent,omitempty"` - Tracestate string `json:"tracestate,omitempty"` + Model string `json:"model,omitempty"` + SessionID string `json:"sessionId,omitempty"` + ClientName string `json:"clientName,omitempty"` + ReasoningEffort string `json:"reasoningEffort,omitempty"` + Tools []Tool `json:"tools,omitempty"` + SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` + AvailableTools []string `json:"availableTools"` + ExcludedTools []string `json:"excludedTools,omitempty"` + ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` + Provider *ProviderConfig `json:"provider,omitempty"` + EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` + SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` + CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` + CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` + ManageScheduleEnabled *bool `json:"manageScheduleEnabled,omitempty"` + ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` + RequestPermission *bool `json:"requestPermission,omitempty"` + RequestUserInput *bool `json:"requestUserInput,omitempty"` + RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` + RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` + Hooks *bool `json:"hooks,omitempty"` + WorkingDirectory string `json:"workingDirectory,omitempty"` + Streaming *bool `json:"streaming,omitempty"` + IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` + MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + EnvValueMode string `json:"envValueMode,omitempty"` + CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` + DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` + Agent string `json:"agent,omitempty"` + ConfigDir string `json:"configDir,omitempty"` + EnableConfigDiscovery *bool `json:"enableConfigDiscovery,omitempty"` + SkillDirectories []string `json:"skillDirectories,omitempty"` + InstructionDirectories []string `json:"instructionDirectories,omitempty"` + DisabledSkills []string `json:"disabledSkills,omitempty"` + InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` + Commands []wireCommand `json:"commands,omitempty"` + RequestElicitation *bool `json:"requestElicitation,omitempty"` + GitHubToken string `json:"gitHubToken,omitempty"` + RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` + Cloud *CloudSessionOptions `json:"cloud,omitempty"` + Canvases []CanvasDeclaration `json:"canvases,omitempty"` + RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` + RequestExtensions *bool `json:"requestExtensions,omitempty"` + ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` + Traceparent string `json:"traceparent,omitempty"` + Tracestate string `json:"tracestate,omitempty"` } // wireCommand is the wire representation of a command (name + description only, no handler). @@ -1518,49 +1558,54 @@ type createSessionResponse struct { // resumeSessionRequest is the request for session.resume type resumeSessionRequest struct { - SessionID string `json:"sessionId"` - ClientName string `json:"clientName,omitempty"` - Model string `json:"model,omitempty"` - ReasoningEffort string `json:"reasoningEffort,omitempty"` - Tools []Tool `json:"tools,omitempty"` - SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` - AvailableTools []string `json:"availableTools"` - ExcludedTools []string `json:"excludedTools,omitempty"` - Provider *ProviderConfig `json:"provider,omitempty"` - EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` - ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` - RequestPermission *bool `json:"requestPermission,omitempty"` - RequestUserInput *bool `json:"requestUserInput,omitempty"` - RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` - RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` - Hooks *bool `json:"hooks,omitempty"` - WorkingDirectory string `json:"workingDirectory,omitempty"` - ConfigDir string `json:"configDir,omitempty"` - EnableConfigDiscovery *bool `json:"enableConfigDiscovery,omitempty"` - DisableResume *bool `json:"disableResume,omitempty"` - ContinuePendingWork *bool `json:"continuePendingWork,omitempty"` - Streaming *bool `json:"streaming,omitempty"` - IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` - MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` - EnvValueMode string `json:"envValueMode,omitempty"` - CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` - DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` - Agent string `json:"agent,omitempty"` - SkillDirectories []string `json:"skillDirectories,omitempty"` - InstructionDirectories []string `json:"instructionDirectories,omitempty"` - DisabledSkills []string `json:"disabledSkills,omitempty"` - InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` - Commands []wireCommand `json:"commands,omitempty"` - RequestElicitation *bool `json:"requestElicitation,omitempty"` - GitHubToken string `json:"gitHubToken,omitempty"` - RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` - Canvases []CanvasDeclaration `json:"canvases,omitempty"` - OpenCanvases []rpc.OpenCanvasInstance `json:"openCanvases,omitempty"` - RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` - RequestExtensions *bool `json:"requestExtensions,omitempty"` - ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` - Traceparent string `json:"traceparent,omitempty"` - Tracestate string `json:"tracestate,omitempty"` + SessionID string `json:"sessionId"` + ClientName string `json:"clientName,omitempty"` + Model string `json:"model,omitempty"` + ReasoningEffort string `json:"reasoningEffort,omitempty"` + Tools []Tool `json:"tools,omitempty"` + SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` + AvailableTools []string `json:"availableTools"` + ExcludedTools []string `json:"excludedTools,omitempty"` + ToolFilterPrecedence *rpc.OptionsUpdateToolFilterPrecedence `json:"toolFilterPrecedence,omitempty"` + Provider *ProviderConfig `json:"provider,omitempty"` + EnableSessionTelemetry *bool `json:"enableSessionTelemetry,omitempty"` + SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` + CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` + CoauthorEnabled *bool `json:"coauthorEnabled,omitempty"` + ManageScheduleEnabled *bool `json:"manageScheduleEnabled,omitempty"` + ModelCapabilities *rpc.ModelCapabilitiesOverride `json:"modelCapabilities,omitempty"` + RequestPermission *bool `json:"requestPermission,omitempty"` + RequestUserInput *bool `json:"requestUserInput,omitempty"` + RequestExitPlanMode *bool `json:"requestExitPlanMode,omitempty"` + RequestAutoModeSwitch *bool `json:"requestAutoModeSwitch,omitempty"` + Hooks *bool `json:"hooks,omitempty"` + WorkingDirectory string `json:"workingDirectory,omitempty"` + ConfigDir string `json:"configDir,omitempty"` + EnableConfigDiscovery *bool `json:"enableConfigDiscovery,omitempty"` + DisableResume *bool `json:"disableResume,omitempty"` + ContinuePendingWork *bool `json:"continuePendingWork,omitempty"` + Streaming *bool `json:"streaming,omitempty"` + IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` + MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + EnvValueMode string `json:"envValueMode,omitempty"` + CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` + DefaultAgent *DefaultAgentConfig `json:"defaultAgent,omitempty"` + Agent string `json:"agent,omitempty"` + SkillDirectories []string `json:"skillDirectories,omitempty"` + InstructionDirectories []string `json:"instructionDirectories,omitempty"` + DisabledSkills []string `json:"disabledSkills,omitempty"` + InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` + Commands []wireCommand `json:"commands,omitempty"` + RequestElicitation *bool `json:"requestElicitation,omitempty"` + GitHubToken string `json:"gitHubToken,omitempty"` + RemoteSession rpc.RemoteSessionMode `json:"remoteSession,omitempty"` + Canvases []CanvasDeclaration `json:"canvases,omitempty"` + OpenCanvases []rpc.OpenCanvasInstance `json:"openCanvases,omitempty"` + RequestCanvasRenderer *bool `json:"requestCanvasRenderer,omitempty"` + RequestExtensions *bool `json:"requestExtensions,omitempty"` + ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` + Traceparent string `json:"traceparent,omitempty"` + Tracestate string `json:"tracestate,omitempty"` } // resumeSessionResponse is the response from session.resume diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 11e6131cb..a18915014 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,14 +31,16 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; -import type { OpenCanvasInstance } from "./generated/rpc.js"; +import type { OpenCanvasInstance, SessionUpdateOptionsParams } from "./generated/rpc.js"; 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, @@ -52,6 +54,8 @@ import type { ResumeSessionConfig, SectionTransformFn, SessionConfig, + SessionConfigBase, + SystemMessageConfig, SessionCapabilities, SessionEvent, SessionFsConfig, @@ -129,6 +133,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." + ); + } + } +} + /** * Extract transform callbacks from a system message config and prepare the wire payload. * Function-valued actions are replaced with `{ action: "transform" }` for serialization, @@ -267,6 +303,7 @@ export class CopilotClient { baseDirectory?: string; sessionIdleTimeoutSeconds: number; enableRemoteSessions: boolean; + mode: CopilotClientMode; }; private isExternalServer: boolean = false; private forceStopping: boolean = false; @@ -414,7 +451,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[] = []; @@ -789,11 +848,154 @@ 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" }; + } + + /** Mode-specific defaults spread under the caller's config (app values win). */ + private configDefaultsForMode(): Partial { + if (this.options.mode === "empty") { + return { enableSessionTelemetry: false }; + } + return {}; + } + + /** + * Returns the systemMessage config to use, adjusted for the current mode. + * In empty mode we ensure the environment_context section is removed + * unless the app has already taken control of it. `append` (and + * unspecified) mode is promoted to `customize` so we can also strip + * environment_context; the caller's `content` is preserved verbatim + * because the runtime appends it as additional instructions in both + * customize and append modes. + */ + private getSystemMessageConfigForMode( + supplied: SystemMessageConfig | undefined + ): SystemMessageConfig | undefined { + if (this.options.mode !== "empty") return supplied; + if (!supplied) { + return { + mode: "customize", + sections: { environment_context: { action: "remove" } }, + }; + } + switch (supplied.mode) { + case "replace": + return supplied; + case "customize": + if (supplied.sections?.environment_context) return supplied; + return { + ...supplied, + sections: { + ...supplied.sections, + environment_context: { action: "remove" }, + }, + }; + case "append": + case undefined: + // Promote to customize so we can also strip environment_context. + // The runtime appends `content` to additional instructions in + // both customize and append modes, so the caller's text is + // preserved verbatim. + return { + mode: "customize", + content: supplied.content, + sections: { environment_context: { action: "remove" } }, + }; + } + } + + /** + * Mode-specific options applied via session.options.update after create/resume. + * + * In empty mode, defaults the four overridable feature flags to safe values + * (caller values from `config` win). `installedPlugins=[]` is unconditional + * in empty mode — apps that need custom plugins should switch modes. + */ + private async updateSessionOptionsForMode( + session: CopilotSession, + config: SessionConfigBase + ): Promise { + const patch: SessionUpdateOptionsParams = {}; + if (this.options.mode === "empty") { + patch.skipCustomInstructions = config.skipCustomInstructions ?? true; + patch.customAgentsLocalOnly = config.customAgentsLocalOnly ?? true; + patch.coauthorEnabled = config.coauthorEnabled ?? false; + patch.manageScheduleEnabled = config.manageScheduleEnabled ?? false; + patch.installedPlugins = []; + } else { + if (config.skipCustomInstructions !== undefined) + patch.skipCustomInstructions = config.skipCustomInstructions; + if (config.customAgentsLocalOnly !== undefined) + patch.customAgentsLocalOnly = config.customAgentsLocalOnly; + if (config.coauthorEnabled !== undefined) + patch.coauthorEnabled = config.coauthorEnabled; + if (config.manageScheduleEnabled !== undefined) + patch.manageScheduleEnabled = config.manageScheduleEnabled; + } + if (Object.keys(patch).length === 0) { + return; + } + try { + await session.rpc.options.update(patch); + } catch (e) { + // The runtime session exists but the post-create options + // patch failed — best-effort disconnect so we don't leak + // it (in empty mode it would otherwise keep running with + // permissive defaults). + try { + await session.disconnect(); + } catch { + // Swallow: original error is the one the caller needs. + } + throw e; + } + } + async createSession(config: SessionConfig): Promise { if (!this.connection) { await this.start(); } + config = { ...this.configDefaultsForMode(), ...config }; + config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage); + const sessionId = config.sessionId ?? randomUUID(); // Create and register the session before issuing the RPC so that @@ -838,6 +1040,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)), @@ -861,8 +1065,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, @@ -898,6 +1103,8 @@ export class CopilotClient { }; session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); + + await this.updateSessionOptionsForMode(session, config); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -963,7 +1170,9 @@ export class CopilotClient { session.registerHooks(config.hooks); } - // Extract transform callbacks from system message config before serialization. + config = { ...this.configDefaultsForMode(), ...config }; + config.systemMessage = this.getSystemMessageConfigForMode(config.systemMessage); + const { wirePayload: wireSystemMessage, transformCallbacks } = extractTransformCallbacks( config.systemMessage ); @@ -977,6 +1186,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)), @@ -985,8 +1196,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, @@ -1042,6 +1254,8 @@ export class CopilotClient { session["_workspacePath"] = workspacePath; session.setCapabilities(capabilities); session.setOpenCanvases(openCanvases ?? []); + + await this.updateSessionOptionsForMode(session, config); } catch (e) { this.sessions.delete(sessionId); throw e; @@ -1589,6 +1803,14 @@ export class CopilotClient { envWithoutNodeDebug.COPILOT_HOME = this.options.baseDirectory; } + // In empty mode, disable the system keychain. Keytar reads from a + // process-wide store that's shared across sessions, which is unsafe + // for multi-tenant hosts. The runtime falls back to file-based + // credential storage scoped to COPILOT_HOME. + if (this.options.mode === "empty") { + envWithoutNodeDebug.COPILOT_DISABLE_KEYTAR = "1"; + } + if (!this.resolvedCliPath) { throw new Error( "Path to Copilot CLI is required. Please supply it via " + diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index c39621c0b..7181d4147 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -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, @@ -50,6 +51,7 @@ export type { AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, + CopilotClientMode, CopilotClientOptions, StdioRuntimeConnection, TcpRuntimeConnection, diff --git a/nodejs/src/toolSet.ts b/nodejs/src/toolSet.ts new file mode 100644 index 000000000..559e9234e --- /dev/null +++ b/nodejs/src/toolSet.ts @@ -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:`, `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; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 333079e1f..7aeb0b162 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -13,6 +13,7 @@ import type { SessionEvent as GeneratedSessionEvent } from "./generated/session- import type { CopilotSession } from "./session.js"; import type { RemoteSessionMode } from "./generated/rpc.js"; import type { OpenCanvasInstance } from "./generated/rpc.js"; +import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type SessionEvent = GeneratedSessionEvent; export type { SessionFsProvider } from "./sessionFsProvider.js"; @@ -167,6 +168,20 @@ export interface ParentProcessRuntimeConnection { /** @internal */ export type InternalRuntimeConnection = RuntimeConnection | ParentProcessRuntimeConnection; +/** + * Controls SDK defaults for ambient features. + * + * - `"copilot-cli"` (default): Defaults equivalent to Copilot CLI. Useful when + * building a coding agent that shares sessions with Copilot CLI. Do not use + * this mode for server-based multi-user applications — the default coding + * agent has tools and capabilities that operate across sessions and can + * access the host OS environment. + * - `"empty"`: Disables optional features by default. The app must explicitly + * opt into anything it needs. Required for any scenario where CLI-like + * ambient behavior is unsafe (e.g. multi-user servers). + */ +export type CopilotClientMode = "empty" | "copilot-cli"; + export interface CopilotClientOptions { /** * How to connect to the Copilot runtime. When omitted, defaults to @@ -174,6 +189,20 @@ export interface CopilotClientOptions { */ connection?: RuntimeConnection; + /** + * Selects the SDK defaulting strategy. See {@link CopilotClientMode}. + * + * When set to `"empty"`, the SDK validates that the app has supplied the + * required configuration ({@link CopilotClientOptions.baseDirectory} or + * {@link CopilotClientOptions.sessionFs}, plus + * {@link SessionConfigBase.availableTools} on each session) and translates + * session creation requests into runtime options that flip tool filter + * precedence to deny-wins so exclusions are expressible. + * + * @default "copilot-cli" + */ + mode?: CopilotClientMode; + /** * Working directory for the runtime process. * If not set, inherits the current process's working directory. @@ -1580,15 +1609,26 @@ export interface SessionConfigBase { /** * List of tool names to allow. When specified, only these tools will be available. - * Takes precedence over excludedTools. + * + * Supports source-qualified filter patterns (`builtin:*`, `builtin:`, + * `mcp:*`, `mcp:`, `custom:*`, `custom:`) as well as the bare + * name form (exact match across any source). Build this list with + * {@link ToolSet} for type safety and readable intent. + * + * Composes with {@link excludedTools}: a tool is enabled when it matches + * `availableTools` (or `availableTools` is unset) AND it does not match + * `excludedTools`. This lets you express "everything matching X except Y". */ - availableTools?: string[]; + availableTools?: string[] | ToolSet; /** - * List of tool names to disable. All other tools remain available. - * Ignored if availableTools is specified. + * List of tool names to disable. Supports the same pattern syntax as + * {@link availableTools}. + * + * Always takes precedence over {@link availableTools}: a tool listed here + * is disabled even if it also matches `availableTools`. */ - excludedTools?: string[]; + excludedTools?: string[] | ToolSet; /** * Custom provider configuration (BYOK - Bring Your Own Key). @@ -1606,6 +1646,43 @@ export interface SessionConfigBase { */ enableSessionTelemetry?: boolean; + /** + * When true, the runtime skips loading custom-instruction sources + * (e.g. `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`). + * + * Defaults to `false` (custom instructions are loaded). Under + * {@link CopilotClientOptions.mode} = `"empty"`, defaults to `true`; apps + * can pass `false` here to opt back in. + */ + skipCustomInstructions?: boolean; + + /** + * When true, custom agents default to local-only execution and are not + * dispatched to remote workers. + * + * Defaults to `false`. Under {@link CopilotClientOptions.mode} = `"empty"`, + * defaults to `true`; apps can pass `false` here to opt back in. + */ + customAgentsLocalOnly?: boolean; + + /** + * When true, the runtime instructs the agent to include a `Co-authored-by` + * trailer in commit messages it composes. + * + * Defaults to `true`. Under {@link CopilotClientOptions.mode} = `"empty"`, + * defaults to `false`; apps can pass `true` here to opt back in. + */ + coauthorEnabled?: boolean; + + /** + * When true, the `manage_schedule` tool is exposed to the agent. + * + * Defaults to whatever the runtime exposes (typically gated to staff + * users). Under {@link CopilotClientOptions.mode} = `"empty"`, defaults to + * `false`; apps can pass `true` here to opt back in. + */ + manageScheduleEnabled?: boolean; + /** * Optional handler for permission requests from the server. * When omitted, permission requests are surfaced as events and left pending for diff --git a/nodejs/test/e2e/mode_empty.e2e.test.ts b/nodejs/test/e2e/mode_empty.e2e.test.ts new file mode 100644 index 000000000..7c775c565 --- /dev/null +++ b/nodejs/test/e2e/mode_empty.e2e.test.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import fs, { realpathSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { approveAll, BuiltInTools, ToolSet } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +/** + * E2E coverage for the Mode = "empty" SDK surface and source-qualified tool + * filter patterns. The runtime is mode-agnostic — these tests verify that the + * SDK's translation reaches the runtime correctly by inspecting: + * - the resulting CapiProxy chat-completion request (the LLM only sees tools + * that the runtime exposed for the session), and + * - end-to-end behavior (asking the agent to use a tool that should or + * shouldn't be enabled). + */ +describe("Mode = empty + ToolSet patterns", async () => { + // Empty mode requires baseDirectory at construction time; the harness + // already creates a per-test home dir but doesn't surface it directly, + // so spin up our own and feed it to the client constructor. + const emptyModeBaseDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-empty-mode-"))); + const { copilotClient: client, openAiEndpoint } = await createSdkTestContext({ + copilotClientOptions: { mode: "empty", baseDirectory: emptyModeBaseDir }, + }); + + async function getToolsExposedToLLM(): Promise { + const exchanges = await openAiEndpoint.getExchanges(); + expect(exchanges.length).toBeGreaterThanOrEqual(1); + const tools = exchanges[exchanges.length - 1].request.tools ?? []; + return tools.flatMap((t) => + t.type === "function" && t.function?.name ? [t.function.name] : [] + ); + } + + async function getSystemMessageSentToLLM(): Promise { + const exchanges = await openAiEndpoint.getExchanges(); + expect(exchanges.length).toBeGreaterThanOrEqual(1); + const messages = exchanges[exchanges.length - 1].request.messages ?? []; + const sys = messages.find((m) => m.role === "system"); + const content = sys?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((p) => (typeof p === "object" && p && "text" in p ? p.text : "")) + .join("\n"); + } + return ""; + } + + it("empty mode isolated set shell tool is not exposed", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + await session.sendAndWait({ prompt: "Say hi." }); + + const toolNames = await getToolsExposedToLLM(); + // Isolated should not contain shell / fs editing / web fetch / grep. + expect(toolNames).not.toContain("bash"); + expect(toolNames).not.toContain("edit"); + expect(toolNames).not.toContain("grep"); + expect(toolNames).not.toContain("web_fetch"); + // Sanity: at least one of the isolated tools is registered. + const anyIsolated = BuiltInTools.Isolated.some((name) => toolNames.includes(name)); + expect(anyIsolated).toBe(true); + + await session.disconnect(); + }); + + it("empty mode builtin star exposes all built in tools", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("*"), + }); + await session.sendAndWait({ prompt: "Say hi." }); + + const toolNames = await getToolsExposedToLLM(); + // The shell tool name differs by platform (bash vs powershell); + // either way, it's a canonical built-in excluded from Isolated, and + // builtin:* should bring it back. + const shellToolName = process.platform === "win32" ? "powershell" : "bash"; + expect(toolNames).toContain(shellToolName); + + await session.disconnect(); + }); + + it("empty mode excluded tools subtracts from available tools", async () => { + const shellToolName = process.platform === "win32" ? "powershell" : "bash"; + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("*"), + excludedTools: [`builtin:${shellToolName}`], + }); + await session.sendAndWait({ prompt: "Say hi." }); + + const toolNames = await getToolsExposedToLLM(); + // The platform shell is in builtin:* but explicitly excluded → must not be exposed. + expect(toolNames).not.toContain(shellToolName); + // Other built-ins are still there (proves the subtraction is targeted). + expect(toolNames.length).toBeGreaterThan(0); + + await session.disconnect(); + }); + + it("empty mode strips environment_context from the system message by default", async () => { + // We can't directly observe section presence, but we can detect it + // indirectly: in default empty mode the SDK injects the customize-mode + // override `environment_context: { action: "remove" }`. We also append + // a deterministic instruction. If the env_context strip didn't fire, + // the runtime would still inject OS/cwd lines into the system message + // and the model would be free to mention them; with the strip in place + // the model has no env info to lean on and follows our instruction. + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "customize", + content: + "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + }, + }); + const reply = await session.sendAndWait({ prompt: "Name an element." }); + expect(reply?.data.content).toContain("ARGON"); + + const systemMessage = await getSystemMessageSentToLLM(); + expect(systemMessage).not.toMatch(/Current working directory:/i); + expect(systemMessage).not.toMatch(/Operating System:/i); + + await session.disconnect(); + }); + + it("empty mode system message replace llm follows caller content verbatim", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "replace", + content: + "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + }, + }); + const reply = await session.sendAndWait({ prompt: "Hello." }); + expect(reply?.data.content).toContain("KRYPTON"); + + await session.disconnect(); + }); + + it("empty mode append caller instruction takes effect and env context stripped", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "append", + content: + "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + }, + }); + const reply = await session.sendAndWait({ prompt: "Name a noble gas." }); + expect(reply?.data.content).toContain("XENON"); + + const systemMessage = await getSystemMessageSentToLLM(); + expect(systemMessage).not.toMatch(/Current working directory:/i); + expect(systemMessage).not.toMatch(/Operating System:/i); + + await session.disconnect(); + }); +}); diff --git a/nodejs/test/toolSet.test.ts b/nodejs/test/toolSet.test.ts new file mode 100644 index 000000000..ed0d05771 --- /dev/null +++ b/nodejs/test/toolSet.test.ts @@ -0,0 +1,480 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { + approveAll, + BuiltInTools, + CopilotClient, + RuntimeConnection, + ToolSet, +} from "../src/index.js"; + +describe("ToolSet builder", () => { + it("emits source-qualified strings", () => { + const items = new ToolSet() + .addBuiltIn("bash") + .addBuiltIn("*") + .addCustom("my_tool") + .addCustom("*") + .addMcp("github-list_issues") + .addMcp("*") + .toArray(); + expect(items).toEqual([ + "builtin:bash", + "builtin:*", + "custom:my_tool", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + ]); + }); + + it("supports array form of addBuiltIn", () => { + const items = new ToolSet().addBuiltIn(["bash", "view"]).toArray(); + expect(items).toEqual(["builtin:bash", "builtin:view"]); + }); + + it("toArray returns a defensive copy", () => { + const set = new ToolSet().addBuiltIn("bash"); + const a = set.toArray(); + a.push("builtin:tampered"); + expect(set.toArray()).toEqual(["builtin:bash"]); + }); + + it("rejects invalid tool names with a clear message", () => { + expect(() => new ToolSet().addBuiltIn("has:colon")).toThrowError(/match/i); + expect(() => new ToolSet().addMcp("has space")).toThrowError(/match/i); + expect(() => new ToolSet().addCustom("")).toThrowError(/match/i); + }); + + it("BuiltInTools.Isolated contains expected within-session-only tools", () => { + // Spot-check: shell / fs / network / cross-session tools must NOT appear. + expect(BuiltInTools.Isolated).not.toContain("bash"); + expect(BuiltInTools.Isolated).not.toContain("edit"); + expect(BuiltInTools.Isolated).not.toContain("grep"); + expect(BuiltInTools.Isolated).not.toContain("web_fetch"); + // And a couple of expected members. + expect(BuiltInTools.Isolated).toContain("ask_user"); + expect(BuiltInTools.Isolated).toContain("task_complete"); + }); +}); + +describe("CopilotClient mode = 'empty'", () => { + it("rejects construction without baseDirectory or sessionFs", () => { + expect( + () => + new CopilotClient({ + mode: "empty", + connection: RuntimeConnection.forStdio(), + }) + ).toThrowError(/empty mode|baseDirectory|sessionFs/i); + }); + + it("accepts construction with baseDirectory", () => { + const c = new CopilotClient({ + mode: "empty", + baseDirectory: "/tmp/copilot-test", + connection: RuntimeConnection.forStdio(), + }); + expect(c).toBeInstanceOf(CopilotClient); + }); + + it("accepts construction with sessionFs", () => { + const c = new CopilotClient({ + mode: "empty", + sessionFs: { + initialCwd: "/tmp/copilot-test-cwd", + sessionStatePath: "/tmp/copilot-test-state", + conventions: "posix", + createProvider: (() => ({}) as any) as any, + }, + connection: RuntimeConnection.forStdio(), + }); + expect(c).toBeInstanceOf(CopilotClient); + }); + + it("rejects createSession without availableTools", async () => { + const client = new CopilotClient({ + mode: "empty", + baseDirectory: "/tmp/copilot-test", + }); + await client.start(); + onTestFinished(() => client.forceStop()); + // Stub the wire so we don't actually need a runtime; the empty-mode + // guard runs before the RPC is issued so this still fails fast. + vi.spyOn((client as any).connection!, "sendRequest").mockResolvedValue({ + sessionId: "irrelevant", + }); + + await expect( + client.createSession({ onPermissionRequest: approveAll }) + ).rejects.toThrowError(/empty.*availableTools/i); + }); +}); + +describe("Tool filter wiring", () => { + async function setupClient(mode?: "empty" | "copilot-cli") { + const client = new CopilotClient({ + mode, + baseDirectory: mode === "empty" ? "/tmp/copilot-test" : undefined, + }); + await client.start(); + onTestFinished(() => client.forceStop()); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create" || method === "session.resume") { + return { sessionId: params.sessionId }; + } + if (method === "session.options.update") { + return { success: true }; + } + throw new Error(`Unexpected method: ${method}`); + }); + return { client, spy }; + } + + it("converts ToolSet to plain string[] on the wire", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("bash").addMcp("*"), + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.availableTools).toEqual(["builtin:bash", "mcp:*"]); + }); + + it("forwards plain string[] unchanged", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["view", "builtin:bash"], + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.availableTools).toEqual(["view", "builtin:bash"]); + }); + + it("rejects bare '*' in availableTools with actionable error", async () => { + const { client } = await setupClient(); + await expect( + client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["*"], + }) + ).rejects.toThrowError(/bare wildcard|addBuiltIn|addMcp|addCustom/); + }); + + it("rejects bare '*' in excludedTools", async () => { + const { client } = await setupClient(); + await expect( + client.createSession({ + onPermissionRequest: approveAll, + excludedTools: ["*"], + }) + ).rejects.toThrowError(/bare wildcard/); + }); + + it("always sends toolFilterPrecedence: excluded in copilot-cli mode", async () => { + const { client, spy } = await setupClient("copilot-cli"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.toolFilterPrecedence).toBe("excluded"); + }); + + it("always sends toolFilterPrecedence: excluded in empty mode", async () => { + const { client, spy } = await setupClient("empty"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const payload = spy.mock.calls.find(([m]) => m === "session.create")![1] as any; + expect(payload.toolFilterPrecedence).toBe("excluded"); + }); + + it("applies the same filter normalization on session.resume", async () => { + const { client, spy } = await setupClient("empty"); + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn("bash"), + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(["view", "task_complete"]), + }); + const payload = spy.mock.calls.find(([m]) => m === "session.resume")![1] as any; + expect(payload.availableTools).toEqual(["builtin:view", "builtin:task_complete"]); + expect(payload.toolFilterPrecedence).toBe("excluded"); + }); +}); + +describe("Empty-mode safe defaults", () => { + async function setupClient(mode: "empty" | "copilot-cli" = "empty") { + const client = new CopilotClient({ + mode, + baseDirectory: mode === "empty" ? "/tmp/copilot-test" : undefined, + }); + await client.start(); + onTestFinished(() => client.forceStop()); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create" || method === "session.resume") { + return { sessionId: params.sessionId }; + } + if (method === "session.options.update") { + return { success: true }; + } + throw new Error(`Unexpected method: ${method}`); + }); + return { client, spy }; + } + + function createPayload(spy: ReturnType) { + return (spy as any).mock.calls.find(([m]: [string]) => m === "session.create")![1] as any; + } + + function patchCall(spy: ReturnType) { + return (spy as any).mock.calls.find( + ([m]: [string]) => m === "session.options.update" + )![1] as any; + } + + it("forces enableSessionTelemetry=false when app didn't opt in", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + expect(createPayload(spy).enableSessionTelemetry).toBe(false); + }); + + it("respects app-supplied enableSessionTelemetry=true override", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + enableSessionTelemetry: true, + }); + expect(createPayload(spy).enableSessionTelemetry).toBe(true); + }); + + it("injects environment_context removal when app didn't pass systemMessage", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const payload = createPayload(spy); + expect(payload.systemMessage).toEqual({ + mode: "customize", + sections: { environment_context: { action: "remove" } }, + }); + }); + + it("passes through app-supplied systemMessage in replace mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { mode: "replace", content: "you are a haiku bot" }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "replace", + content: "you are a haiku bot", + }); + }); + + it("promotes append-mode systemMessage to customize with env_context removal in empty mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { mode: "append", content: "extra rules" }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "customize", + content: "extra rules", + sections: { environment_context: { action: "remove" } }, + }); + }); + + it("promotes default-mode (append) systemMessage in empty mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { content: "extra rules" }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "customize", + content: "extra rules", + sections: { environment_context: { action: "remove" } }, + }); + }); + + it("adds environment_context removal to customize mode when app didn't set it", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: { + mode: "customize", + sections: { tool_use: { action: "remove" } }, + }, + }); + expect(createPayload(spy).systemMessage).toEqual({ + mode: "customize", + sections: { + tool_use: { action: "remove" }, + environment_context: { action: "remove" }, + }, + }); + }); + + it("leaves customize-mode systemMessage alone when app set environment_context", async () => { + const { client, spy } = await setupClient(); + const supplied = { + mode: "customize" as const, + sections: { + environment_context: { action: "replace" as const, content: "custom env" }, + }, + }; + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + systemMessage: supplied, + }); + expect(createPayload(spy).systemMessage).toEqual(supplied); + }); + + it("sends session.options.update with safe defaults after session.create", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const patch = patchCall(spy); + expect(patch).toMatchObject({ + skipCustomInstructions: true, + customAgentsLocalOnly: true, + coauthorEnabled: false, + manageScheduleEnabled: false, + installedPlugins: [], + }); + expect(patch.sessionId).toBeDefined(); + }); + + it("sends the patch AFTER session.create succeeds (order matters)", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const methods = spy.mock.calls.map(([m]) => m); + const createIdx = methods.indexOf("session.create"); + const patchIdx = methods.indexOf("session.options.update"); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(patchIdx).toBeGreaterThan(createIdx); + }); + + it("does NOT send patch or systemMessage override in copilot-cli mode", async () => { + const { client, spy } = await setupClient("copilot-cli"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + }); + const methods = spy.mock.calls.map(([m]) => m); + expect(methods).not.toContain("session.options.update"); + expect(createPayload(spy).systemMessage).toBeUndefined(); + expect(createPayload(spy).enableSessionTelemetry).toBeUndefined(); + }); + + it("tears the session down if the post-create patch fails", async () => { + const client = new CopilotClient({ mode: "empty", baseDirectory: "/tmp/copilot-test" }); + await client.start(); + onTestFinished(() => client.forceStop()); + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.options.update") { + throw new Error("update rejected"); + } + throw new Error(`Unexpected method: ${method}`); + } + ); + await expect( + client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }) + ).rejects.toThrowError(/update rejected/); + // Session must not remain registered after the failed patch. + expect((client as any).sessions.size).toBe(0); + }); + + it("also applies overrides on session.resume", async () => { + const { client, spy } = await setupClient(); + // First create so we have a session id to resume. + const session = await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + spy.mockClear(); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + }); + const resumePayload = spy.mock.calls.find(([m]) => m === "session.resume")![1] as any; + expect(resumePayload.enableSessionTelemetry).toBe(false); + expect(resumePayload.systemMessage).toEqual({ + mode: "customize", + sections: { environment_context: { action: "remove" } }, + }); + const patch = spy.mock.calls.find(([m]) => m === "session.options.update")![1] as any; + expect(patch.skipCustomInstructions).toBe(true); + }); + + it("respects app-supplied overrides for the four post-create flags in empty mode", async () => { + const { client, spy } = await setupClient(); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: new ToolSet().addBuiltIn(BuiltInTools.Isolated), + skipCustomInstructions: false, + customAgentsLocalOnly: false, + coauthorEnabled: true, + manageScheduleEnabled: true, + }); + const patch = patchCall(spy); + expect(patch).toMatchObject({ + skipCustomInstructions: false, + customAgentsLocalOnly: false, + coauthorEnabled: true, + manageScheduleEnabled: true, + installedPlugins: [], + }); + }); + + it("forwards the four flags in copilot-cli mode when the app sets them", async () => { + const { client, spy } = await setupClient("copilot-cli"); + await client.createSession({ + onPermissionRequest: approveAll, + availableTools: ["builtin:bash"], + skipCustomInstructions: true, + manageScheduleEnabled: true, + }); + const patch = patchCall(spy); + expect(patch).toMatchObject({ + skipCustomInstructions: true, + manageScheduleEnabled: true, + }); + expect(patch.customAgentsLocalOnly).toBeUndefined(); + expect(patch.coauthorEnabled).toBeUndefined(); + expect(patch.installedPlugins).toBeUndefined(); + }); +}); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 3808431d4..af5db5747 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -4,6 +4,11 @@ JSON-RPC based SDK for programmatic control of GitHub Copilot CLI """ +from ._mode import ( + BUILTIN_TOOLS_ISOLATED, + CopilotClientMode, + ToolSet, +) from .canvas import ( CanvasAction, CanvasDeclaration, @@ -144,6 +149,7 @@ "AutoModeSwitchHandler", "AutoModeSwitchRequest", "AutoModeSwitchResponse", + "BUILTIN_TOOLS_ISOLATED", "CanvasAction", "CanvasDeclaration", "CanvasError", @@ -157,6 +163,7 @@ "CommandContext", "CommandDefinition", "CopilotClient", + "CopilotClientMode", "CopilotSession", "CreateSessionFsHandler", "ElicitationContext", @@ -252,6 +259,7 @@ "ToolInvocation", "ToolResult", "ToolResultType", + "ToolSet", "UriRuntimeConnection", "UserInputHandler", "UserInputRequest", diff --git a/python/copilot/_mode.py b/python/copilot/_mode.py new file mode 100644 index 000000000..23e392239 --- /dev/null +++ b/python/copilot/_mode.py @@ -0,0 +1,253 @@ +""" +Mode = "empty" support: ToolSet builder, BUILTIN_TOOLS_ISOLATED, and helpers +that translate Mode = "empty" into runtime-level session options. + +The runtime is mode-agnostic; the SDK is what turns ``mode="empty"`` into the +right combination of options on the wire (no environment_context, telemetry +off, custom instructions off, etc.). Callers can opt back in field-by-field. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from typing import Any, Literal + +CopilotClientMode = Literal["copilot-cli", "empty"] + +_TOOL_NAME_REGEX = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def _validate_tool_name(kind: str, name: str) -> None: + if not name: + raise ValueError(f"invalid {kind} tool name: must not be empty") + if name == "*": + return + if not _TOOL_NAME_REGEX.match(name): + raise ValueError( + f"invalid {kind} tool name {name!r}: tool names must match " + r"/^[a-zA-Z0-9_-]+$/ or be the wildcard '*'" + ) + + +class ToolSet: + """Builder for source-qualified tool filter patterns. + + ``ToolSet`` accumulates entries like ``builtin:bash``, ``mcp:*``, or + ``custom:my_tool`` for use in + :class:`CopilotClient.create_session`'s ``available_tools`` / + ``excluded_tools`` parameters. + + Tool classification (``builtin``/``mcp``/``custom``) is determined by the + runtime at registration time — not by name parsing — so + ``add_builtin("foo")`` only matches tools the runtime registered as + built-in. + """ + + def __init__(self) -> None: + self._items: list[str] = [] + + def add_builtin(self, name: str | Iterable[str]) -> ToolSet: + """Add a built-in tool pattern (``"bash"``/``"*"``/an iterable of names).""" + if isinstance(name, str): + _validate_tool_name("builtin", name) + self._items.append(f"builtin:{name}") + else: + for n in name: + _validate_tool_name("builtin", n) + self._items.append(f"builtin:{n}") + return self + + def add_custom(self, name: str) -> ToolSet: + """Add a custom-tool pattern (e.g. ``"my_tool"`` or ``"*"``).""" + _validate_tool_name("custom", name) + self._items.append(f"custom:{name}") + return self + + def add_mcp(self, tool_name: str) -> ToolSet: + """Add an MCP tool pattern (e.g. ``"github-list_issues"`` or ``"*"``).""" + _validate_tool_name("mcp", tool_name) + self._items.append(f"mcp:{tool_name}") + return self + + def to_list(self) -> list[str]: + """Return a defensive copy of the accumulated filter strings.""" + return list(self._items) + + def __iter__(self): + return iter(self.to_list()) + + def __len__(self) -> int: + return len(self._items) + + +#: Built-in tools that operate only within a single session — no host FS +#: access outside the session, no cross-session state, no host environment +#: access, no network. Safe to enable in ``mode="empty"`` scenarios 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. +BUILTIN_TOOLS_ISOLATED: list[str] = [ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", +] + + +def _normalize_tool_filter(value: Any) -> list[str] | None: + """Accept ``ToolSet``, ``list[str]``, or ``None``; return a list or ``None``. + + Reject plain ``str`` explicitly — ``list("foo")`` would silently shred it + into characters, sending an invalid tool filter list on the wire. + """ + if value is None: + return None + if isinstance(value, ToolSet): + return value.to_list() + if isinstance(value, str): + raise TypeError( + "tool filter must be a ToolSet or list[str], not str. " + 'Pass a single-element list (e.g. ["builtin:bash"]) or a ' + "ToolSet (e.g. ToolSet().add_builtin('bash'))." + ) + return list(value) + + +def _validate_tool_filter_list(field: str, items: list[str] | None) -> None: + """Reject bare ``"*"`` entries (must use ``builtin:*``/``mcp:*``/``custom:*``).""" + if items is None: + return + for entry in items: + if entry == "*": + raise ValueError( + f"invalid {field} entry '*': there is no bare wildcard. " + "Use ToolSet().add_builtin('*'), .add_mcp('*'), or " + ".add_custom('*') to target a specific source." + ) + + +def _system_message_for_mode( + mode: CopilotClientMode | None, + supplied: Any, +) -> Any: + """Apply empty-mode environment_context stripping to a system message dict. + + The caller passes the already-normalized wire payload (a ``dict`` with + ``mode`` / ``content`` / ``sections``) or ``None``. The caller's value + wins if it already specifies an ``environment_context`` override. + """ + if mode != "empty": + return supplied + remove_action = {"action": "remove"} + if supplied is None: + return {"mode": "customize", "sections": {"environment_context": remove_action}} + supplied_mode = supplied.get("mode", "") + if supplied_mode == "replace": + return supplied + if supplied_mode == "customize": + sections = supplied.get("sections") or {} + if "environment_context" in sections: + return supplied + merged = {**supplied, "sections": {**sections, "environment_context": remove_action}} + return merged + # append (or unspecified): promote to customize so we can also strip + # environment_context. The runtime appends ``content`` in both modes, so + # the caller's text is preserved verbatim. + out: dict[str, Any] = { + "mode": "customize", + "sections": {"environment_context": remove_action}, + } + if "content" in supplied and supplied["content"] is not None: + out["content"] = supplied["content"] + return out + + +def _enable_session_telemetry_default( + mode: CopilotClientMode | None, + supplied: bool | None, +) -> bool | None: + """Empty mode defaults telemetry to False; caller value wins.""" + if mode == "empty" and supplied is None: + return False + return supplied + + +def _post_create_options_patch( + mode: CopilotClientMode | None, + skip_custom_instructions: bool | None, + custom_agents_local_only: bool | None, + coauthor_enabled: bool | None, + manage_schedule_enabled: bool | None, +) -> dict[str, Any] | None: + """Build the patch sent via ``session.options.update`` after create/resume. + + In empty mode the four overridable flags default to safe values + (caller-supplied values win); ``installedPlugins=[]`` is unconditional. + Returns ``None`` if no patch should be sent. + """ + if mode == "empty": + patch: dict[str, Any] = { + "skipCustomInstructions": ( + skip_custom_instructions if skip_custom_instructions is not None else True + ), + "customAgentsLocalOnly": ( + custom_agents_local_only if custom_agents_local_only is not None else True + ), + "coauthorEnabled": coauthor_enabled if coauthor_enabled is not None else False, + "manageScheduleEnabled": ( + manage_schedule_enabled if manage_schedule_enabled is not None else False + ), + "installedPlugins": [], + } + return patch + patch = {} + if skip_custom_instructions is not None: + patch["skipCustomInstructions"] = skip_custom_instructions + if custom_agents_local_only is not None: + patch["customAgentsLocalOnly"] = custom_agents_local_only + if coauthor_enabled is not None: + patch["coauthorEnabled"] = coauthor_enabled + if manage_schedule_enabled is not None: + patch["manageScheduleEnabled"] = manage_schedule_enabled + return patch or None + + +def _require_storage_for_empty_mode( + *, + mode: CopilotClientMode | None, + base_directory: str | None, + session_fs_set: bool, + is_uri_connection: bool, +) -> None: + if mode != "empty": + return + if base_directory or session_fs_set or is_uri_connection: + return + raise ValueError( + "CopilotClient(mode='empty') requires base_directory, session_fs, " + "or a UriRuntimeConnection. Empty mode needs explicit per-tenant " + "storage and won't fall back to ~/.copilot." + ) + + +def _require_available_tools_for_empty_mode( + mode: CopilotClientMode | None, + available_tools: list[str] | None, +) -> None: + if mode == "empty" and available_tools is None: + raise ValueError( + "CopilotClient is in mode='empty' but create_session was called " + "without available_tools. Empty mode requires every session to " + "explicitly opt into the tools it wants — e.g. " + "ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED)." + ) diff --git a/python/copilot/client.py b/python/copilot/client.py index 4386adb08..2e96b2faa 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -34,6 +34,17 @@ from ._diagnostics import log_timing from ._jsonrpc import JsonRpcClient, JsonRpcError, ProcessExitedError +from ._mode import ( + CopilotClientMode, + ToolSet, + _enable_session_telemetry_default, + _normalize_tool_filter, + _post_create_options_patch, + _require_available_tools_for_empty_mode, + _require_storage_for_empty_mode, + _system_message_for_mode, + _validate_tool_filter_list, +) from ._sdk_protocol_version import get_sdk_protocol_version from ._telemetry import get_trace_context from .canvas import ( @@ -303,6 +314,7 @@ class _CopilotClientOptions: session_idle_timeout_seconds: int | None = None enable_remote_sessions: bool = False on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None + mode: CopilotClientMode = "copilot-cli" # ============================================================================ @@ -1051,6 +1063,7 @@ def __init__( session_idle_timeout_seconds: int | None = None, enable_remote_sessions: bool = False, on_list_models: Callable[[], list[ModelInfo] | Awaitable[list[ModelInfo]]] | None = None, + mode: CopilotClientMode = "copilot-cli", ): """ Initialize a new CopilotClient. @@ -1120,10 +1133,17 @@ def __init__( session_idle_timeout_seconds=session_idle_timeout_seconds, enable_remote_sessions=enable_remote_sessions, on_list_models=on_list_models, + mode=mode, ) connection = ( options.connection if options.connection is not None else RuntimeConnection.for_stdio() ) + _require_storage_for_empty_mode( + mode=options.mode, + base_directory=options.base_directory, + session_fs_set=options.session_fs is not None, + is_uri_connection=isinstance(connection, UriRuntimeConnection), + ) self._options: _CopilotClientOptions = options self._connection: RuntimeConnection = connection @@ -1521,13 +1541,17 @@ async def create_session( reasoning_effort: ReasoningEffort | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, - available_tools: list[str] | None = None, - excluded_tools: list[str] | None = None, + available_tools: list[str] | ToolSet | None = None, + excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, enable_session_telemetry: bool | None = None, + skip_custom_instructions: bool | None = None, + custom_agents_local_only: bool | None = None, + coauthor_enabled: bool | None = None, + manage_schedule_enabled: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -1660,6 +1684,18 @@ async def create_session( definition["skipPermission"] = True tool_defs.append(definition) + # Empty-mode validation and normalization + mode = self._options.mode + _require_available_tools_for_empty_mode(mode, _normalize_tool_filter(available_tools)) + available_tools = _normalize_tool_filter(available_tools) + excluded_tools = _normalize_tool_filter(excluded_tools) + _validate_tool_filter_list("available_tools", available_tools) + _validate_tool_filter_list("excluded_tools", excluded_tools) + # Mode "empty" strips environment_context from the system message. + system_message = _system_message_for_mode(mode, system_message) + # Mode "empty" defaults telemetry to off; caller wins. + enable_session_telemetry = _enable_session_telemetry_default(mode, enable_session_telemetry) + payload: dict[str, Any] = {} if model: payload["model"] = model @@ -1678,6 +1714,9 @@ async def create_session( payload["availableTools"] = available_tools if excluded_tools is not None: payload["excludedTools"] = excluded_tools + # Always emit "excluded" precedence so caller-supplied excludedTools win + # over any built-in availableTools defaults the runtime applies. + payload["toolFilterPrecedence"] = "excluded" # Enable permission request callback if handler provided payload["requestPermission"] = bool(on_permission_request) @@ -1893,6 +1932,15 @@ async def create_session( ) raise + await self._apply_post_create_options_patch( + session, + mode, + skip_custom_instructions, + custom_agents_local_only, + coauthor_enabled, + manage_schedule_enabled, + ) + log_timing( logger, logging.DEBUG, @@ -1912,13 +1960,17 @@ async def resume_session( reasoning_effort: ReasoningEffort | None = None, tools: list[Tool] | None = None, system_message: SystemMessageConfig | None = None, - available_tools: list[str] | None = None, - excluded_tools: list[str] | None = None, + available_tools: list[str] | ToolSet | None = None, + excluded_tools: list[str] | ToolSet | None = None, on_user_input_request: UserInputHandler | None = None, hooks: SessionHooks | None = None, working_directory: str | None = None, provider: ProviderConfig | None = None, enable_session_telemetry: bool | None = None, + skip_custom_instructions: bool | None = None, + custom_agents_local_only: bool | None = None, + coauthor_enabled: bool | None = None, + manage_schedule_enabled: bool | None = None, model_capabilities: ModelCapabilitiesOverride | None = None, streaming: bool | None = None, include_sub_agent_streaming_events: bool | None = None, @@ -2055,6 +2107,16 @@ async def resume_session( definition["skipPermission"] = True tool_defs.append(definition) + # Empty-mode validation and normalization + mode = self._options.mode + _require_available_tools_for_empty_mode(mode, _normalize_tool_filter(available_tools)) + available_tools = _normalize_tool_filter(available_tools) + excluded_tools = _normalize_tool_filter(excluded_tools) + _validate_tool_filter_list("available_tools", available_tools) + _validate_tool_filter_list("excluded_tools", excluded_tools) + system_message = _system_message_for_mode(mode, system_message) + enable_session_telemetry = _enable_session_telemetry_default(mode, enable_session_telemetry) + payload: dict[str, Any] = {"sessionId": session_id} if client_name: @@ -2072,6 +2134,7 @@ async def resume_session( payload["availableTools"] = available_tools if excluded_tools is not None: payload["excludedTools"] = excluded_tools + payload["toolFilterPrecedence"] = "excluded" if provider: payload["provider"] = self._convert_provider_to_wire_format(provider) if enable_session_telemetry is not None: @@ -2267,6 +2330,15 @@ async def resume_session( ) raise + await self._apply_post_create_options_patch( + session, + mode, + skip_custom_instructions, + custom_agents_local_only, + coauthor_enabled, + manage_schedule_enabled, + ) + log_timing( logger, logging.DEBUG, @@ -2871,6 +2943,11 @@ async def _start_cli_server(self) -> None: if opts.github_token: env["COPILOT_SDK_AUTH_TOKEN"] = opts.github_token + # Mode "empty": disable the runtime's system keychain probe so per-tenant + # credentials don't leak through a shared keytar store. + if opts.mode == "empty": + env["COPILOT_DISABLE_KEYTAR"] = "1" + if self._effective_connection_token: env["COPILOT_CONNECTION_TOKEN"] = self._effective_connection_token if opts.base_directory: @@ -3159,6 +3236,59 @@ def handle_notification(method: str, params: dict): loop = asyncio.get_running_loop() self._client.start(loop) + async def _apply_post_create_options_patch( + self, + session: CopilotSession, + mode: CopilotClientMode, + skip_custom_instructions: bool | None, + custom_agents_local_only: bool | None, + coauthor_enabled: bool | None, + manage_schedule_enabled: bool | None, + ) -> None: + """Apply empty-mode safe defaults (or caller-supplied overrides in + copilot-cli mode) via ``session.options.update`` after create/resume. + + If the patch is rejected, tear the session down so empty-mode callers + never end up with a permissive session. + """ + from .generated.rpc import SessionInstalledPlugin, SessionUpdateOptionsParams + + patch = _post_create_options_patch( + mode, + skip_custom_instructions, + custom_agents_local_only, + coauthor_enabled, + manage_schedule_enabled, + ) + if patch is None: + return + + params = SessionUpdateOptionsParams() + if "skipCustomInstructions" in patch: + params.skip_custom_instructions = patch["skipCustomInstructions"] + if "customAgentsLocalOnly" in patch: + params.custom_agents_local_only = patch["customAgentsLocalOnly"] + if "coauthorEnabled" in patch: + params.coauthor_enabled = patch["coauthorEnabled"] + if "manageScheduleEnabled" in patch: + params.manage_schedule_enabled = patch["manageScheduleEnabled"] + if "installedPlugins" in patch: + params.installed_plugins = [ + SessionInstalledPlugin.from_dict(p) if isinstance(p, dict) else p + for p in patch["installedPlugins"] + ] + + try: + await session.rpc.options.update(params) + except BaseException: + with self._sessions_lock: + self._sessions.pop(session.session_id, None) + try: + await session.disconnect() + except BaseException: + pass + raise + async def _set_session_fs_provider(self) -> None: if not self._session_fs_config or not self._client: return diff --git a/python/e2e/test_mode_empty_e2e.py b/python/e2e/test_mode_empty_e2e.py new file mode 100644 index 000000000..c84613c8a --- /dev/null +++ b/python/e2e/test_mode_empty_e2e.py @@ -0,0 +1,221 @@ +""" +E2E coverage for ``mode="empty"`` + ``ToolSet`` patterns. + +Mirrors ``nodejs/test/e2e/mode_empty.e2e.test.ts`` and shares the same +recorded cassettes under ``test/snapshots/mode_empty/``. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +from copilot import BUILTIN_TOOLS_ISOLATED, CopilotClient, RuntimeConnection, ToolSet +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _make_empty_client(ctx: E2ETestContext) -> CopilotClient: + return CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path, args=()), + working_directory=ctx.work_dir, + env=ctx.get_env(), + github_token=( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ), + base_directory=ctx.home_dir, + mode="empty", + ) + + +async def _last_exchange(ctx: E2ETestContext) -> dict: + exchanges = await ctx.get_exchanges() + assert exchanges, "expected at least one chat-completion exchange" + return exchanges[-1] + + +def _tool_names(exchange: dict) -> list[str]: + tools = exchange.get("request", {}).get("tools", []) or [] + return [ + t.get("function", {}).get("name") + for t in tools + if t.get("type") == "function" and t.get("function", {}).get("name") + ] + + +def _system_message(exchange: dict) -> str: + messages = exchange.get("request", {}).get("messages", []) or [] + for m in messages: + if m.get("role") == "system": + content = m.get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and "text" in part + ) + return "" + + +def _shell_tool_name() -> str: + return "powershell" if sys.platform == "win32" else "bash" + + +class TestModeEmpty: + async def test_empty_mode_isolated_set_shell_tool_is_not_exposed(self, ctx: E2ETestContext): + client = _make_empty_client(ctx) + try: + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED), + ) + try: + await session.send_and_wait("Say hi.", timeout=20.0) + tool_names = _tool_names(await _last_exchange(ctx)) + for banned in ("bash", "powershell", "edit", "grep", "web_fetch"): + assert banned not in tool_names, ( + f"isolated set must not expose {banned!r}, got {tool_names}" + ) + assert any(name in tool_names for name in BUILTIN_TOOLS_ISOLATED), ( + f"expected at least one isolated tool to be registered, got {tool_names}" + ) + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_empty_mode_builtin_star_exposes_all_built_in_tools(self, ctx: E2ETestContext): + client = _make_empty_client(ctx) + try: + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_builtin("*"), + ) + try: + await session.send_and_wait("Say hi.", timeout=20.0) + tool_names = _tool_names(await _last_exchange(ctx)) + assert _shell_tool_name() in tool_names, ( + f"builtin:* should expose the shell tool, got {tool_names}" + ) + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_empty_mode_excluded_tools_subtracts_from_available_tools( + self, ctx: E2ETestContext + ): + shell = _shell_tool_name() + client = _make_empty_client(ctx) + try: + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_builtin("*"), + excluded_tools=[f"builtin:{shell}"], + ) + try: + await session.send_and_wait("Say hi.", timeout=20.0) + tool_names = _tool_names(await _last_exchange(ctx)) + assert shell not in tool_names, ( + f"excluded shell must not be exposed, got {tool_names}" + ) + assert len(tool_names) > 0 + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_empty_mode_strips_environment_context_from_the_system_message_by_default( + self, ctx: E2ETestContext + ): + client = _make_empty_client(ctx) + try: + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED), + system_message={ + "mode": "customize", + "content": ( + "If the user asks you to name an element, reply with exactly " + "the single word ARGON in all caps and nothing else." + ), + }, + ) + try: + reply = await session.send_and_wait("Name an element.", timeout=20.0) + assert reply is not None + assert "ARGON" in reply.data.content + system_message = _system_message(await _last_exchange(ctx)) + assert "Current working directory:" not in system_message + assert "Operating System:" not in system_message + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_empty_mode_system_message_replace_llm_follows_caller_content_verbatim( + self, ctx: E2ETestContext + ): + client = _make_empty_client(ctx) + try: + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED), + system_message={ + "mode": "replace", + "content": ( + "You are a test fixture. Whenever the user asks anything, " + "reply with exactly the single word KRYPTON in all caps " + "and nothing else." + ), + }, + ) + try: + reply = await session.send_and_wait("Hello.", timeout=20.0) + assert reply is not None + assert "KRYPTON" in reply.data.content + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped( + self, ctx: E2ETestContext + ): + client = _make_empty_client(ctx) + try: + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + available_tools=ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED), + system_message={ + "mode": "append", + "content": ( + "If the user asks you to name a noble gas, reply with exactly " + "the single word XENON in all caps and nothing else." + ), + }, + ) + try: + reply = await session.send_and_wait("Name a noble gas.", timeout=20.0) + assert reply is not None + assert "XENON" in reply.data.content + system_message = _system_message(await _last_exchange(ctx)) + assert "Current working directory:" not in system_message + assert "Operating System:" not in system_message + finally: + await session.disconnect() + finally: + await client.stop() diff --git a/python/test_tool_set.py b/python/test_tool_set.py new file mode 100644 index 000000000..3d80d29e8 --- /dev/null +++ b/python/test_tool_set.py @@ -0,0 +1,235 @@ +"""Unit tests for the ``ToolSet`` builder and empty-mode helpers.""" + +from __future__ import annotations + +import pytest + +from copilot import BUILTIN_TOOLS_ISOLATED, CopilotClient, ToolSet, UriRuntimeConnection +from copilot._mode import ( + _enable_session_telemetry_default, + _post_create_options_patch, + _require_available_tools_for_empty_mode, + _require_storage_for_empty_mode, + _system_message_for_mode, + _validate_tool_filter_list, +) + + +class TestToolSet: + def test_add_builtin_string(self): + ts = ToolSet().add_builtin("bash") + assert ts.to_list() == ["builtin:bash"] + + def test_add_builtin_wildcard(self): + ts = ToolSet().add_builtin("*") + assert ts.to_list() == ["builtin:*"] + + def test_add_builtin_iterable(self): + ts = ToolSet().add_builtin(["bash", "edit"]) + assert ts.to_list() == ["builtin:bash", "builtin:edit"] + + def test_add_builtin_isolated(self): + ts = ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED) + assert ts.to_list() == [f"builtin:{name}" for name in BUILTIN_TOOLS_ISOLATED] + + def test_add_mcp(self): + ts = ToolSet().add_mcp("github-list_issues") + assert ts.to_list() == ["mcp:github-list_issues"] + + def test_add_mcp_wildcard(self): + assert ToolSet().add_mcp("*").to_list() == ["mcp:*"] + + def test_add_custom(self): + assert ToolSet().add_custom("my_tool").to_list() == ["custom:my_tool"] + + def test_chained(self): + ts = ToolSet().add_builtin(BUILTIN_TOOLS_ISOLATED).add_mcp("*").add_custom("*") + assert ts.to_list()[-2:] == ["mcp:*", "custom:*"] + + def test_rejects_bad_name(self): + with pytest.raises(ValueError, match="tool names must match"): + ToolSet().add_builtin("has space") + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="must not be empty"): + ToolSet().add_custom("") + + def test_rejects_colon(self): + with pytest.raises(ValueError, match="tool names must match"): + ToolSet().add_mcp("server:tool") + + def test_iterable_protocol(self): + ts = ToolSet().add_builtin("bash").add_mcp("*") + assert list(ts) == ["builtin:bash", "mcp:*"] + assert len(ts) == 2 + + +class TestEmptyModeValidation: + def test_empty_mode_requires_storage(self): + with pytest.raises(ValueError, match="requires base_directory"): + _require_storage_for_empty_mode( + mode="empty", + base_directory=None, + session_fs_set=False, + is_uri_connection=False, + ) + + def test_empty_mode_accepts_base_directory(self): + _require_storage_for_empty_mode( + mode="empty", + base_directory="/tmp/x", + session_fs_set=False, + is_uri_connection=False, + ) + + def test_empty_mode_accepts_session_fs(self): + _require_storage_for_empty_mode( + mode="empty", + base_directory=None, + session_fs_set=True, + is_uri_connection=False, + ) + + def test_empty_mode_accepts_uri_connection(self): + _require_storage_for_empty_mode( + mode="empty", + base_directory=None, + session_fs_set=False, + is_uri_connection=True, + ) + + def test_copilot_cli_mode_no_storage_required(self): + _require_storage_for_empty_mode( + mode="copilot-cli", + base_directory=None, + session_fs_set=False, + is_uri_connection=False, + ) + + def test_empty_mode_requires_available_tools(self): + with pytest.raises(ValueError, match="available_tools"): + _require_available_tools_for_empty_mode("empty", None) + + def test_empty_mode_accepts_available_tools(self): + _require_available_tools_for_empty_mode("empty", ["builtin:bash"]) + + def test_copilot_cli_mode_no_tool_filter_required(self): + _require_available_tools_for_empty_mode("copilot-cli", None) + + +class TestToolFilterListValidation: + def test_rejects_bare_wildcard(self): + with pytest.raises(ValueError, match="bare wildcard"): + _validate_tool_filter_list("available_tools", ["*"]) + + def test_accepts_source_qualified_wildcard(self): + _validate_tool_filter_list("available_tools", ["builtin:*", "mcp:*"]) + + def test_accepts_none(self): + _validate_tool_filter_list("available_tools", None) + + +class TestSystemMessageForMode: + def test_copilot_cli_pass_through(self): + assert _system_message_for_mode("copilot-cli", None) is None + msg = {"mode": "append", "content": "hi"} + assert _system_message_for_mode("copilot-cli", msg) is msg + + def test_empty_mode_none_supplied(self): + out = _system_message_for_mode("empty", None) + assert out == { + "mode": "customize", + "sections": {"environment_context": {"action": "remove"}}, + } + + def test_empty_mode_replace_pass_through(self): + msg = {"mode": "replace", "content": "verbatim"} + assert _system_message_for_mode("empty", msg) is msg + + def test_empty_mode_customize_adds_section(self): + msg = {"mode": "customize", "sections": {"identity": {"action": "remove"}}} + out = _system_message_for_mode("empty", msg) + assert out["sections"]["environment_context"] == {"action": "remove"} + assert out["sections"]["identity"] == {"action": "remove"} + + def test_empty_mode_customize_does_not_overwrite_existing(self): + msg = { + "mode": "customize", + "sections": {"environment_context": {"action": "replace", "content": "X"}}, + } + assert _system_message_for_mode("empty", msg) is msg + + def test_empty_mode_append_promoted_to_customize(self): + msg = {"mode": "append", "content": "tip"} + out = _system_message_for_mode("empty", msg) + assert out["mode"] == "customize" + assert out["content"] == "tip" + assert out["sections"]["environment_context"] == {"action": "remove"} + + +class TestTelemetryDefault: + def test_empty_mode_defaults_to_false(self): + assert _enable_session_telemetry_default("empty", None) is False + + def test_empty_mode_caller_wins(self): + assert _enable_session_telemetry_default("empty", True) is True + + def test_copilot_cli_does_not_change(self): + assert _enable_session_telemetry_default("copilot-cli", None) is None + + +class TestPostCreatePatch: + def test_empty_mode_defaults(self): + patch = _post_create_options_patch("empty", None, None, None, None) + assert patch == { + "skipCustomInstructions": True, + "customAgentsLocalOnly": True, + "coauthorEnabled": False, + "manageScheduleEnabled": False, + "installedPlugins": [], + } + + def test_empty_mode_caller_wins(self): + patch = _post_create_options_patch("empty", False, False, True, True) + assert patch == { + "skipCustomInstructions": False, + "customAgentsLocalOnly": False, + "coauthorEnabled": True, + "manageScheduleEnabled": True, + "installedPlugins": [], + } + + def test_copilot_cli_returns_none_when_unset(self): + assert _post_create_options_patch("copilot-cli", None, None, None, None) is None + + def test_copilot_cli_passes_through_explicit_values(self): + patch = _post_create_options_patch("copilot-cli", True, None, False, None) + assert patch == {"skipCustomInstructions": True, "coauthorEnabled": False} + + +class TestClientConstruction: + def test_empty_mode_without_storage_raises(self): + with pytest.raises(ValueError, match="requires base_directory"): + CopilotClient(mode="empty") + + def test_empty_mode_with_base_directory_ok(self, tmp_path): + # Use URI connection to skip bundled-CLI discovery. + client = CopilotClient( + mode="empty", + base_directory=str(tmp_path), + connection=UriRuntimeConnection(url="http://localhost:1234"), + ) + assert client._options.mode == "empty" + + def test_empty_mode_with_uri_connection_ok(self): + client = CopilotClient( + mode="empty", + connection=UriRuntimeConnection(url="http://localhost:1234"), + ) + assert client._options.mode == "empty" + + def test_default_mode_copilot_cli(self): + client = CopilotClient( + connection=UriRuntimeConnection(url="http://localhost:1234"), + ) + assert client._options.mode == "copilot-cli" diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 184f5bf40..c952ec810 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -515,8 +515,8 @@ pub struct AgentInfo { /// and may change or be removed in future SDK or CLI releases. /// /// - #[serde(default)] - pub mcp_servers: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, /// Preferred model id for this agent. When omitted, inherits the outer agent's model. #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -526,14 +526,14 @@ pub struct AgentInfo { #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, /// Skill names preloaded into this agent's context. Omitted means none. - #[serde(default)] - pub skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub skills: Option>, /// Where the agent definition was loaded from #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, /// Allowed tool names for this agent. Empty array means none; omitted means inherit defaults. - #[serde(default)] - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, /// Whether the agent can be selected directly by the user. Agents marked `false` are subagent-only. #[serde(skip_serializing_if = "Option::is_none")] pub user_invocable: Option, @@ -1068,8 +1068,11 @@ pub struct CopilotUserResponse { pub endpoints: Option, #[serde(rename = "is_mcp_enabled", skip_serializing_if = "Option::is_none")] pub is_mcp_enabled: Option, - #[serde(rename = "limited_user_quotas", default)] - pub limited_user_quotas: HashMap, + #[serde( + rename = "limited_user_quotas", + skip_serializing_if = "Option::is_none" + )] + pub limited_user_quotas: Option>, #[serde( rename = "limited_user_reset_date", skip_serializing_if = "Option::is_none" @@ -1077,12 +1080,15 @@ pub struct CopilotUserResponse { pub limited_user_reset_date: Option, #[serde(skip_serializing_if = "Option::is_none")] pub login: Option, - #[serde(rename = "monthly_quotas", default)] - pub monthly_quotas: HashMap, + #[serde(rename = "monthly_quotas", skip_serializing_if = "Option::is_none")] + pub monthly_quotas: Option>, #[serde(rename = "organization_list", skip_serializing_if = "Option::is_none")] pub organization_list: Option, - #[serde(rename = "organization_login_list", default)] - pub organization_login_list: Vec, + #[serde( + rename = "organization_login_list", + skip_serializing_if = "Option::is_none" + )] + pub organization_login_list: Option>, #[serde(rename = "quota_reset_date", skip_serializing_if = "Option::is_none")] pub quota_reset_date: Option, #[serde( @@ -1243,8 +1249,8 @@ pub struct CanvasInvokeActionResult { #[serde(rename_all = "camelCase")] pub struct DiscoveredCanvas { /// Actions the agent or host may invoke on an open instance - #[serde(default)] - pub actions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, /// Provider-local canvas identifier pub canvas_id: String, /// Short, single-sentence description shown to the agent in canvas catalogs. @@ -1515,8 +1521,8 @@ pub struct SlashCommandInput { #[serde(rename_all = "camelCase")] pub struct SlashCommandInfo { /// Canonical aliases without leading slashes - #[serde(default)] - pub aliases: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub aliases: Option>, /// Whether the command may run while an agent turn is active pub allow_during_agent_execution: bool, /// Human-readable command description @@ -2058,8 +2064,8 @@ pub struct ExternalToolTextResultForLlmBinaryResultsForLlm { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// Optional metadata from the producing tool. - #[serde(default)] - pub metadata: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, /// MIME type of the binary data pub mime_type: String, /// Binary result type discriminator. Use "image" for images and "resource" for other binary data. @@ -2078,11 +2084,11 @@ pub struct ExternalToolTextResultForLlmBinaryResultsForLlm { #[serde(rename_all = "camelCase")] pub struct ExternalToolTextResultForLlm { /// Base64-encoded binary results returned to the model - #[serde(default)] - pub binary_results_for_llm: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub binary_results_for_llm: Option>, /// Structured content blocks from the tool - #[serde(default)] - pub contents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub contents: Option>, /// Optional error message for failed executions #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, @@ -2095,8 +2101,8 @@ pub struct ExternalToolTextResultForLlm { /// Text result returned to the model pub text_result_for_llm: String, /// Optional tool-specific telemetry - #[serde(default)] - pub tool_telemetry: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_telemetry: Option>, } /// Audio content block with base64-encoded data @@ -2169,8 +2175,8 @@ pub struct ExternalToolTextResultForLlmContentResourceLinkIcon { #[serde(skip_serializing_if = "Option::is_none")] pub mime_type: Option, /// Available icon sizes (e.g., ['16x16', '32x32']) - #[serde(default)] - pub sizes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option>, /// URL or path to the icon image pub src: String, /// Theme variant this icon is intended for @@ -2193,8 +2199,8 @@ pub struct ExternalToolTextResultForLlmContentResourceLink { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// Icons associated with this resource - #[serde(default)] - pub icons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, /// MIME type of the resource content #[serde(skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -2653,8 +2659,8 @@ pub struct InstalledPluginSourceUrl { #[serde(rename_all = "camelCase")] pub struct InstructionsSources { /// Glob pattern(s) from frontmatter — when set, this instruction applies only to matching files - #[serde(default)] - pub apply_to: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub apply_to: Option>, /// Raw content of the instruction file pub content: String, /// When true, this source starts disabled and must be toggled on by the user @@ -2769,8 +2775,8 @@ pub struct LspInitializeRequest { #[serde(rename_all = "camelCase")] pub struct McpAppsCallToolRequest { /// Tool arguments - #[serde(default)] - pub arguments: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option>, /// **Required.** Server whose ui:// view issued the request. Per SEP-1865 ('callable by the app from this server only'), the call is rejected when this differs from `serverName`, and rejected outright when missing. pub origin_server_name: String, /// MCP server hosting the tool @@ -2863,8 +2869,8 @@ pub struct McpAppsDiagnoseResult { #[serde(rename_all = "camelCase")] pub struct McpAppsHostContextDetails { /// Display modes the host supports - #[serde(default)] - pub available_display_modes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub available_display_modes: Option>, /// Current display mode (SEP-1865) #[serde(skip_serializing_if = "Option::is_none")] pub display_mode: Option, @@ -2961,8 +2967,8 @@ pub struct McpAppsReadResourceRequest { #[serde(rename_all = "camelCase")] pub struct McpAppsResourceContent { /// Resource-level metadata (CSP, permissions, etc.) - #[serde(rename = "_meta", default)] - pub meta: HashMap, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, /// Base64-encoded binary content #[serde(skip_serializing_if = "Option::is_none")] pub blob: Option, @@ -3003,8 +3009,8 @@ pub struct McpAppsReadResourceResult { #[serde(rename_all = "camelCase")] pub struct McpAppsSetHostContextDetails { /// Display modes the host supports - #[serde(default)] - pub available_display_modes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub available_display_modes: Option>, /// Current display mode (SEP-1865) #[serde(skip_serializing_if = "Option::is_none")] pub display_mode: Option, @@ -3321,8 +3327,8 @@ pub struct McpServerConfigHttp { #[serde(skip_serializing_if = "Option::is_none")] pub filter_mapping: Option, /// HTTP headers to include in requests to the remote MCP server. - #[serde(default)] - pub headers: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, /// Whether this server is a built-in fallback used when the user has not configured their own server. #[serde(skip_serializing_if = "Option::is_none")] pub is_default_server: Option, @@ -3342,8 +3348,8 @@ pub struct McpServerConfigHttp { #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Tools to include. Defaults to all tools if not specified. - #[serde(default)] - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, /// Remote transport type. Defaults to "http" when omitted. #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, @@ -3356,8 +3362,8 @@ pub struct McpServerConfigHttp { #[serde(rename_all = "camelCase")] pub struct McpServerConfigStdio { /// Command-line arguments passed to the Stdio MCP server process. - #[serde(default)] - pub args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, /// Set to `true` to use defaults, or provide an object with additional auth or OIDC settings. #[serde(skip_serializing_if = "Option::is_none")] pub auth: Option, @@ -3367,8 +3373,8 @@ pub struct McpServerConfigStdio { #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, /// Environment variables to pass to the Stdio MCP server process. - #[serde(default)] - pub env: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, /// Content filtering mode to apply to all tools, or a map of tool name to content filtering mode. #[serde(skip_serializing_if = "Option::is_none")] pub filter_mapping: Option, @@ -3382,8 +3388,8 @@ pub struct McpServerConfigStdio { #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, /// Tools to include. Defaults to all tools if not specified. - #[serde(default)] - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, } /// MCP servers configured for the session, with their connection status. @@ -3826,8 +3832,8 @@ pub struct Model { #[serde(skip_serializing_if = "Option::is_none")] pub policy: Option, /// Supported reasoning effort levels (only present if model supports reasoning effort) - #[serde(default)] - pub supported_reasoning_efforts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub supported_reasoning_efforts: Option>, } /// Vision-specific limits @@ -3851,8 +3857,11 @@ pub struct ModelCapabilitiesOverrideLimitsVision { #[serde(rename = "max_prompt_images", skip_serializing_if = "Option::is_none")] pub max_prompt_images: Option, /// MIME types the model accepts - #[serde(rename = "supported_media_types", default)] - pub supported_media_types: Vec, + #[serde( + rename = "supported_media_types", + skip_serializing_if = "Option::is_none" + )] + pub supported_media_types: Option>, } /// Token limits for prompts, outputs, and context window @@ -4995,8 +5004,8 @@ pub struct PermissionPathsAllowedCheckResult { #[serde(rename_all = "camelCase")] pub struct PermissionPathsConfig { /// Additional directories to allow tool access to (in addition to the session's working directory). When `unrestricted` is true, these are still pre-populated on the UnrestrictedPathManager so they remain visible via getDirectories() (e.g. for @-mention completion). - #[serde(default)] - pub additional_directories: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_directories: Option>, /// Whether to include the system temp directory in the allowed list (defaults to true). Ignored when `unrestricted` is true. #[serde(skip_serializing_if = "Option::is_none")] pub include_temp_directory: Option, @@ -5143,10 +5152,10 @@ pub struct PermissionsConfigureAdditionalContentExclusionPolicyRuleSource { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PermissionsConfigureAdditionalContentExclusionPolicyRule { - #[serde(default)] - pub if_any_match: Vec, - #[serde(default)] - pub if_none_match: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub if_any_match: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub if_none_match: Option>, pub paths: Vec, /// Schema for the `PermissionsConfigureAdditionalContentExclusionPolicyRuleSource` type. pub source: PermissionsConfigureAdditionalContentExclusionPolicyRuleSource, @@ -5182,8 +5191,8 @@ pub struct PermissionsConfigureAdditionalContentExclusionPolicy { #[serde(rename_all = "camelCase")] pub struct PermissionUrlsConfig { /// Initial list of allowed URL/domain patterns. Patterns may include path components. Ignored when `unrestricted` is true. - #[serde(default)] - pub initial_allowed: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_allowed: Option>, /// If true, the runtime allows access to all URLs without prompting. Initial allow-list is ignored when this is true. #[serde(skip_serializing_if = "Option::is_none")] pub unrestricted: Option, @@ -5201,9 +5210,9 @@ pub struct PermissionUrlsConfig { #[serde(rename_all = "camelCase")] pub struct PermissionsConfigureParams { /// If specified, replaces the host-supplied GitHub Content Exclusion policies on the session (combined with natively-discovered policies when evaluating tool/file access). Omit to leave the current policies unchanged. - #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] pub additional_content_exclusion_policies: - Vec, + Option>, /// If specified, sets whether path/URL read permission requests are auto-approved. Omit to leave the current value unchanged. #[serde(skip_serializing_if = "Option::is_none")] pub approve_all_read_permission_requests: Option, @@ -5290,11 +5299,11 @@ pub struct PermissionsLocationsAddToolApprovalResult { #[serde(rename_all = "camelCase")] pub struct PermissionsModifyRulesParams { /// Rules to add to the scope. Applied before `remove`/`removeAll`. - #[serde(default)] - pub add: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub add: Option>, /// Specific rules to remove from the scope. Ignored when `removeAll` is true. - #[serde(default)] - pub remove: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub remove: Option>, /// When true, removes every rule currently in the scope (after any `add` is applied). Useful for clearing the location scope wholesale. #[serde(skip_serializing_if = "Option::is_none")] pub remove_all: Option, @@ -6100,8 +6109,8 @@ pub struct SendRequest { #[serde(skip_serializing_if = "Option::is_none")] pub agent_mode: Option, /// Optional attachments (files, directories, selections, blobs, GitHub references) to include with the message - #[serde(default)] - pub attachments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, /// If false, this message will not trigger a Premium Request Unit charge. User messages default to billable. #[serde(skip_serializing_if = "Option::is_none")] pub billable: Option, @@ -6117,8 +6126,8 @@ pub struct SendRequest { /// The user message text pub prompt: String, /// Custom HTTP headers to include in outbound model requests for this turn. Merged with session-level provider headers; per-turn headers augment and overwrite session-level headers with the same key. - #[serde(default)] - pub request_headers: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_headers: Option>, /// If set, the request will fail if the named tool is not available when this message is among the user messages at the start of the current exchange #[serde(skip_serializing_if = "Option::is_none")] pub required_tool: Option, @@ -6663,8 +6672,8 @@ pub struct SessionFsSqliteQueryRequest { /// How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected) pub query_type: SessionFsSqliteQueryType, /// Optional named bind parameters - #[serde(default)] - pub params: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option>, } /// Query results including rows, columns, and rows affected, or a filesystem error if execution failed. @@ -7381,8 +7390,8 @@ pub struct SessionsPruneOldRequest { #[serde(skip_serializing_if = "Option::is_none")] pub dry_run: Option, /// Session IDs that should never be considered for pruning - #[serde(default)] - pub exclude_session_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude_session_ids: Option>, /// When true, named sessions (set via /rename) are also eligible for pruning #[serde(skip_serializing_if = "Option::is_none")] pub include_named: Option, @@ -7520,8 +7529,8 @@ pub struct SessionUpdateOptionsParams { /// and may change or be removed in future SDK or CLI releases. /// /// - #[serde(default)] - pub additional_content_exclusion_policies: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_content_exclusion_policies: Option>, /// Runtime context discriminator (e.g., `cli`, `actions`). #[serde(skip_serializing_if = "Option::is_none")] pub agent_context: Option, @@ -7529,8 +7538,8 @@ pub struct SessionUpdateOptionsParams { #[serde(skip_serializing_if = "Option::is_none")] pub ask_user_disabled: Option, /// Allowlist of tool names available to this session. - #[serde(default)] - pub available_tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub available_tools: Option>, /// Identifier of the client driving the session. #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, @@ -7547,11 +7556,11 @@ pub struct SessionUpdateOptionsParams { #[serde(skip_serializing_if = "Option::is_none")] pub custom_agents_local_only: Option, /// Instruction source IDs to exclude from the system prompt. - #[serde(default)] - pub disabled_instruction_sources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_instruction_sources: Option>, /// Skill IDs that should be excluded from this session. - #[serde(default)] - pub disabled_skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_skills: Option>, /// Whether to discover custom instructions on demand after successful file views (AGENTS.md / CLAUDE.md / .github/copilot-instructions.md surfacing). Combined with `skipCustomInstructions` and the runtime-side `ON_DEMAND_INSTRUCTIONS` feature flag. #[serde(skip_serializing_if = "Option::is_none")] pub enable_on_demand_instruction_discovery: Option, @@ -7571,14 +7580,14 @@ pub struct SessionUpdateOptionsParams { #[serde(skip_serializing_if = "Option::is_none")] pub events_log_directory: Option, /// Denylist of tool names for this session. - #[serde(default)] - pub excluded_tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_tools: Option>, /// Map of feature-flag IDs to their boolean enabled state. - #[serde(default)] - pub feature_flags: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub feature_flags: Option>, /// Full set of installed plugins for the session. Replaces the existing list; the runtime invalidates the skills cache only when the list materially changes. - #[serde(default)] - pub installed_plugins: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub installed_plugins: Option>, /// Stable integration identifier used for analytics and rate-limit attribution. #[serde(skip_serializing_if = "Option::is_none")] pub integration_id: Option, @@ -7627,11 +7636,11 @@ pub struct SessionUpdateOptionsParams { #[serde(skip_serializing_if = "Option::is_none")] pub shell_init_profile: Option, /// Per-shell process flags (e.g., `pwsh` arguments). - #[serde(default)] - pub shell_process_flags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub shell_process_flags: Option>, /// Additional directories to search for skills. - #[serde(default)] - pub skill_directories: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_directories: Option>, /// Whether to skip loading custom instruction sources. #[serde(skip_serializing_if = "Option::is_none")] pub skip_custom_instructions: Option, @@ -7821,11 +7830,11 @@ pub struct SkillsDisableRequest { #[serde(rename_all = "camelCase")] pub struct SkillsDiscoverRequest { /// Optional list of project directory paths to scan for project-scoped skills - #[serde(default)] - pub project_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_paths: Option>, /// Optional list of additional skill directory paths to include - #[serde(default)] - pub skill_directories: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_directories: Option>, } /// Name of the skill to enable for the session. @@ -7855,8 +7864,8 @@ pub struct SkillsEnableRequest { #[serde(rename_all = "camelCase")] pub struct SkillsInvokedSkill { /// Tools that should be auto-approved when this skill is active, captured at invocation time - #[serde(default)] - pub allowed_tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option>, /// Full content of the skill file pub content: String, /// Turn number when the skill was invoked @@ -8494,8 +8503,8 @@ pub struct Tool { #[serde(skip_serializing_if = "Option::is_none")] pub namespaced_name: Option, /// JSON Schema for the tool's input parameters - #[serde(default)] - pub parameters: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, } /// Built-in tools available for the requested model, with their parameters and instructions. @@ -8571,8 +8580,8 @@ pub struct UIElicitationArrayAnyOfFieldItems { #[serde(rename_all = "camelCase")] pub struct UIElicitationArrayAnyOfField { /// Default values selected when the form is first shown. - #[serde(default)] - pub default: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option>, /// Help text describing the field. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, @@ -8620,8 +8629,8 @@ pub struct UIElicitationArrayEnumFieldItems { #[serde(rename_all = "camelCase")] pub struct UIElicitationArrayEnumField { /// Default values selected when the form is first shown. - #[serde(default)] - pub default: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option>, /// Help text describing the field. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, @@ -8654,8 +8663,8 @@ pub struct UIElicitationSchema { /// Form field definitions, keyed by field name pub properties: HashMap, /// List of required field names - #[serde(default)] - pub required: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, /// Schema type indicator (always 'object') pub r#type: UIElicitationSchemaType, } @@ -8691,8 +8700,8 @@ pub struct UIElicitationResponse { /// The user's response: accept (submitted), decline (rejected), or cancel (dismissed) pub action: UIElicitationResponseAction, /// The form values submitted by the user (present when action is 'accept') - #[serde(default)] - pub content: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option>, } /// Indicates whether the elicitation response was accepted; false if it was already resolved by another client. @@ -8817,8 +8826,8 @@ pub struct UIElicitationStringEnumField { /// Allowed string values. pub r#enum: Vec, /// Optional display labels for each enum value, in the same order as `enum`. - #[serde(default)] - pub enum_names: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub enum_names: Option>, /// Human-readable label for the field. #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, @@ -9159,8 +9168,8 @@ pub struct UsageMetricsModelMetric { /// Request count and cost metrics for this model pub requests: UsageMetricsModelMetricRequests, /// Token count details per type - #[serde(default)] - pub token_details: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_details: Option>, /// Accumulated nano-AI units cost for this model #[serde(skip_serializing_if = "Option::is_none")] pub total_nano_aiu: Option, @@ -9208,8 +9217,8 @@ pub struct UsageGetMetricsResult { /// ISO 8601 timestamp when the session started pub session_start_time: String, /// Session-wide per-token-type accumulated token counts - #[serde(default)] - pub token_details: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_details: Option>, /// Total time spent in model API calls (milliseconds) pub total_api_duration_ms: i64, /// Session-wide accumulated nano-AI units cost @@ -11301,8 +11310,8 @@ pub struct SessionUiElicitationResult { /// The user's response: accept (submitted), decline (rejected), or cancel (dismissed) pub action: UIElicitationResponseAction, /// The form values submitted by the user (present when action is 'accept') - #[serde(default)] - pub content: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option>, } /// Indicates whether the elicitation response was accepted; false if it was already resolved by another client. @@ -12337,8 +12346,8 @@ pub struct SessionUsageGetMetricsResult { /// ISO 8601 timestamp when the session started pub session_start_time: String, /// Session-wide per-token-type accumulated token counts - #[serde(default)] - pub token_details: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_details: Option>, /// Total time spent in model API calls (milliseconds) pub total_api_duration_ms: i64, /// Session-wide accumulated nano-AI units cost diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index ea9fdbecd..3e07483e6 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -830,8 +830,8 @@ pub struct ShutdownModelMetric { /// Request count and cost metrics pub requests: ShutdownModelMetricRequests, /// Token count details per type - #[serde(default)] - pub token_details: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_details: Option>, /// Accumulated nano-AI units cost for this model /// ///
@@ -882,8 +882,8 @@ pub struct SessionShutdownData { #[serde(skip_serializing_if = "Option::is_none")] pub system_tokens: Option, /// Session-wide per-token-type accumulated token counts - #[serde(default)] - pub token_details: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_details: Option>, /// Tool definitions token count at shutdown #[serde(skip_serializing_if = "Option::is_none")] pub tool_definitions_tokens: Option, @@ -1101,8 +1101,8 @@ pub struct UserMessageData { #[serde(skip_serializing_if = "Option::is_none")] pub agent_mode: Option, /// Files, selections, or GitHub references attached to the message - #[serde(default)] - pub attachments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, /// The user's message text as displayed in the timeline pub content: String, /// CAPI interaction ID for correlating this user message with its turn @@ -1112,8 +1112,8 @@ pub struct UserMessageData { #[serde(skip_serializing_if = "Option::is_none")] pub is_autopilot_continuation: Option, /// Path-backed native document attachments that stayed on the tagged_files path flow because native upload could not read them or would exceed the request size limit - #[serde(default)] - pub native_document_path_fallback_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub native_document_path_fallback_paths: Option>, /// Parent agent task ID for background telemetry correlated to this user turn #[serde(skip_serializing_if = "Option::is_none")] pub parent_agent_task_id: Option, @@ -1121,8 +1121,8 @@ pub struct UserMessageData { #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, /// Normalized document MIME types that were sent natively instead of through tagged_files XML - #[serde(default)] - pub supported_native_document_mime_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub supported_native_document_mime_types: Option>, /// Transformed version of the message sent to the model, with XML wrapping, timestamps, and other augmentations for prompt caching #[serde(skip_serializing_if = "Option::is_none")] pub transformed_content: Option, @@ -1220,8 +1220,8 @@ pub struct AssistantMessageData { /// and may change or be removed in future SDK or CLI releases. /// ///
- #[serde(default)] - pub anthropic_advisor_blocks: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub anthropic_advisor_blocks: Option>, /// Anthropic advisor model ID used for this response, for timeline display on replay /// ///
@@ -1269,8 +1269,8 @@ pub struct AssistantMessageData { #[serde(skip_serializing_if = "Option::is_none")] pub service_request_id: Option, /// Tool invocations requested by the assistant in this message - #[serde(default)] - pub tool_requests: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_requests: Option>, /// Identifier for the agent loop turn that produced this message, matching the corresponding assistant.turn_start event #[serde(skip_serializing_if = "Option::is_none")] pub turn_id: Option, @@ -1422,8 +1422,8 @@ pub struct AssistantUsageData { pub provider_call_id: Option, /// Per-quota resource usage snapshots, keyed by quota identifier #[doc(hidden)] - #[serde(default)] - pub(crate) quota_snapshots: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) quota_snapshots: Option>, /// Reasoning effort level used for model calls, if applicable (e.g. "none", "low", "medium", "high", "xhigh", "max") #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, @@ -1610,8 +1610,8 @@ pub struct ToolExecutionCompleteContentResourceLinkIcon { #[serde(skip_serializing_if = "Option::is_none")] pub mime_type: Option, /// Available icon sizes (e.g., ['16x16', '32x32']) - #[serde(default)] - pub sizes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option>, /// URL or path to the icon image pub src: String, /// Theme variant this icon is intended for @@ -1627,8 +1627,8 @@ pub struct ToolExecutionCompleteContentResourceLink { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// Icons associated with this resource - #[serde(default)] - pub icons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, /// MIME type of the resource content #[serde(skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -1686,14 +1686,14 @@ pub struct ToolExecutionCompleteContentResource { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolExecutionCompleteUIResourceMetaUICsp { - #[serde(default)] - pub base_uri_domains: Vec, - #[serde(default)] - pub connect_domains: Vec, - #[serde(default)] - pub frame_domains: Vec, - #[serde(default)] - pub resource_domains: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_uri_domains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub connect_domains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub frame_domains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_domains: Option>, } /// Schema for the `ToolExecutionCompleteUIResourceMetaUIPermissionsCamera` type. @@ -1785,8 +1785,8 @@ pub struct ToolExecutionCompleteResult { /// Concise tool result text sent to the LLM for chat completion, potentially truncated for token efficiency pub content: String, /// Structured content blocks (text, images, audio, resources) returned by the tool in their native format - #[serde(default)] - pub contents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub contents: Option>, /// Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. #[serde(skip_serializing_if = "Option::is_none")] pub detailed_content: Option, @@ -1803,8 +1803,8 @@ pub struct ToolExecutionCompleteToolDescriptionMetaUI { #[serde(skip_serializing_if = "Option::is_none")] pub resource_uri: Option, /// Who can access this tool - #[serde(default)] - pub visibility: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub visibility: Option>, } /// MCP Apps metadata for UI resource association @@ -1865,8 +1865,8 @@ pub struct ToolExecutionCompleteData { #[serde(skip_serializing_if = "Option::is_none")] pub tool_description: Option, /// Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts) - #[serde(default)] - pub tool_telemetry: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_telemetry: Option>, /// Identifier for the agent loop turn this tool was invoked in, matching the corresponding assistant.turn_start event #[serde(skip_serializing_if = "Option::is_none")] pub turn_id: Option, @@ -1877,8 +1877,8 @@ pub struct ToolExecutionCompleteData { #[serde(rename_all = "camelCase")] pub struct SkillInvokedData { /// Tool names that should be auto-approved when this skill is active - #[serde(default)] - pub allowed_tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option>, /// Full content of the skill file, injected into the conversation for the model pub content: String, /// Description of the skill from its SKILL.md frontmatter @@ -1978,7 +1978,7 @@ pub struct SubagentSelectedData { /// Internal name of the selected custom agent pub agent_name: String, /// List of tool names available to this agent, or null for all tools - pub tools: Vec, + pub tools: Option>, } /// Session event "subagent.deselected". Empty payload; the event signals that the custom agent was deselected, returning to the default agent @@ -2044,8 +2044,8 @@ pub struct SystemMessageMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub prompt_version: Option, /// Template variables used when constructing the prompt - #[serde(default)] - pub variables: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option>, } /// Session event "system.message". System/developer instruction content with role and optional template metadata @@ -2718,8 +2718,8 @@ pub struct UserInputRequestedData { #[serde(skip_serializing_if = "Option::is_none")] pub allow_freeform: Option, /// Predefined choices for the user to select from, if applicable - #[serde(default)] - pub choices: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub choices: Option>, /// The question or prompt to present to the user pub question: String, /// Unique identifier for this input request; used to respond via session.respondToUserInput() @@ -2750,8 +2750,8 @@ pub struct ElicitationRequestedSchema { /// Form field definitions, keyed by field name pub properties: HashMap, /// List of required field names - #[serde(default)] - pub required: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, /// Schema type indicator (always 'object') pub r#type: ElicitationRequestedSchemaType, } @@ -2789,8 +2789,8 @@ pub struct ElicitationCompletedData { #[serde(skip_serializing_if = "Option::is_none")] pub action: Option, /// The submitted form data when action is 'accept'; keys match the requested schema fields - #[serde(default)] - pub content: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option>, /// Request ID of the resolved elicitation request; clients should dismiss any UI for this request pub request_id: RequestId, } @@ -2863,8 +2863,8 @@ pub struct SessionCustomNotificationData { /// Namespace for the custom notification producer pub source: String, /// Optional source-defined string identifiers describing the payload subject - #[serde(default)] - pub subject: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option>, /// Optional source-defined payload schema version #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, @@ -3097,7 +3097,7 @@ pub struct CustomAgentsUpdatedAgent { /// Source location: user, project, inherited, remote, or plugin pub source: String, /// List of tool names available to this agent, or null when all tools are available - pub tools: Vec, + pub tools: Option>, /// Whether the agent can be selected by the user pub user_invocable: bool, } @@ -3221,8 +3221,8 @@ pub struct CanvasRegistryChangedCanvasAction { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// JSON Schema for action input - #[serde(default)] - pub input_schema: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_schema: Option>, /// Action name pub name: String, } @@ -3232,8 +3232,8 @@ pub struct CanvasRegistryChangedCanvasAction { #[serde(rename_all = "camelCase")] pub struct CanvasRegistryChangedCanvas { /// Actions the agent or host may invoke - #[serde(default)] - pub actions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, /// Provider-local canvas identifier pub canvas_id: String, /// Short, single-sentence description shown to the agent in canvas catalogs. @@ -3246,8 +3246,8 @@ pub struct CanvasRegistryChangedCanvas { #[serde(skip_serializing_if = "Option::is_none")] pub extension_name: Option, /// JSON Schema for canvas open input - #[serde(default)] - pub input_schema: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_schema: Option>, } /// Session event "session.canvas.registry_changed". @@ -3274,8 +3274,8 @@ pub struct McpAppToolCallCompleteToolMetaUI { #[serde(skip_serializing_if = "Option::is_none")] pub resource_uri: Option, /// Tool visibility per SEP-1865 (typically a subset of `["model","app"]`) - #[serde(default)] - pub visibility: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub visibility: Option>, } /// The tool's `_meta.ui` block at the time of the call, so consumers can decide whether to forward the result to the model without re-listing tools. @@ -3292,16 +3292,16 @@ pub struct McpAppToolCallCompleteToolMeta { #[serde(rename_all = "camelCase")] pub struct McpAppToolCallCompleteData { /// Arguments passed to the tool by the app view, if any - #[serde(default)] - pub arguments: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option>, /// Wall-clock duration of the underlying tools/call in milliseconds pub duration_ms: f64, /// Set when the underlying tools/call threw an error before returning a CallToolResult #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, /// Standard MCP CallToolResult returned by the server. Present whether or not the call set isError. - #[serde(default)] - pub result: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option>, /// Name of the MCP server hosting the tool pub server_name: String, /// True when the call completed without throwing AND the MCP CallToolResult did not set isError diff --git a/rust/src/lib.rs b/rust/src/lib.rs index d6340edf6..0852d98a8 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -39,6 +39,10 @@ mod wire; /// Auto-generated protocol types from Copilot JSON Schemas. pub mod generated; +/// Client-level mode ([`ClientMode`]) and the [`ToolSet`] builder for +/// source-qualified tool filter patterns. +pub mod mode; + use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -51,6 +55,7 @@ use async_trait::async_trait; pub(crate) use jsonrpc::{ JsonRpcClient, JsonRpcError, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, error_codes, }; +pub use mode::{BUILTIN_TOOLS_ISOLATED, ClientMode, ToolSet}; /// Re-exported JSON-RPC internals for integration tests (requires `test-support` feature). #[cfg(feature = "test-support")] @@ -432,6 +437,10 @@ pub struct ClientOptions { /// at build and runtime); to point the runtime at a different /// binary altogether, use [`CliProgram::Path`] or `COPILOT_CLI_PATH`. pub bundled_cli_extract_dir: Option, + /// SDK-level mode controlling whether sessions get CLI-style defaults + /// (the default) or are stripped to a minimal/safe baseline. See + /// [`ClientMode`] for the contract and trade-offs. + pub mode: ClientMode, } impl std::fmt::Debug for ClientOptions { @@ -674,6 +683,7 @@ impl Default for ClientOptions { base_directory: None, enable_remote_sessions: false, bundled_cli_extract_dir: None, + mode: ClientMode::default(), } } } @@ -844,6 +854,15 @@ impl ClientOptions { self.bundled_cli_extract_dir = Some(dir.into()); self } + + /// Set the SDK [`ClientMode`]. Use [`ClientMode::Empty`] for any + /// scenario where CLI-like ambient behavior is unsafe (e.g. multi-user + /// servers). Empty mode additionally requires [`Self::base_directory`] + /// or [`Self::session_fs`] to be set, validated at [`Client::start`]. + pub fn with_mode(mut self, mode: ClientMode) -> Self { + self.mode = mode; + self + } } /// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`. @@ -917,6 +936,9 @@ struct ClientInner { /// `None` for stdio and for external-server transport without an /// explicit token. effective_connection_token: Option, + /// SDK [`ClientMode`] captured at start time. Drives empty-mode safe + /// defaults inside `create_session` / `resume_session`. + pub(crate) mode: ClientMode, } impl Client { @@ -934,6 +956,16 @@ impl Client { /// backend. pub async fn start(options: ClientOptions) -> Result { let start_time = Instant::now(); + if options.mode == ClientMode::Empty + && options.base_directory.is_none() + && options.session_fs.is_none() + { + return Err(Error::InvalidConfig( + "ClientMode::Empty requires either `base_directory` or \ + `session_fs` to be set (no implicit ~/.copilot fallback)." + .to_string(), + )); + } if let Some(cfg) = &options.session_fs { validate_session_fs_config(cfg)?; } @@ -1049,6 +1081,7 @@ impl Client { session_fs_sqlite_declared, options.on_get_trace_context, effective_connection_token.clone(), + options.mode, )? } Transport::Tcp { @@ -1075,6 +1108,7 @@ impl Client { session_fs_sqlite_declared, options.on_get_trace_context, effective_connection_token.clone(), + options.mode, )? } Transport::Stdio => { @@ -1092,6 +1126,7 @@ impl Client { session_fs_sqlite_declared, options.on_get_trace_context, effective_connection_token.clone(), + options.mode, )? } }; @@ -1139,7 +1174,18 @@ impl Client { writer: impl AsyncWrite + Unpin + Send + 'static, cwd: PathBuf, ) -> Result { - Self::from_transport(reader, writer, None, cwd, None, false, false, None, None) + Self::from_transport( + reader, + writer, + None, + cwd, + None, + false, + false, + None, + None, + ClientMode::default(), + ) } /// Construct a [`Client`] from raw streams with a @@ -1166,6 +1212,7 @@ impl Client { false, Some(provider), None, + ClientMode::default(), ) } @@ -1179,7 +1226,18 @@ impl Client { cwd: PathBuf, token: Option, ) -> Result { - Self::from_transport(reader, writer, None, cwd, None, false, false, None, token) + Self::from_transport( + reader, + writer, + None, + cwd, + None, + false, + false, + None, + token, + ClientMode::default(), + ) } /// Public test-only wrapper around the random connection-token @@ -1203,6 +1261,7 @@ impl Client { session_fs_sqlite_declared: bool, on_get_trace_context: Option>, effective_connection_token: Option, + mode: ClientMode, ) -> Result { let setup_start = Instant::now(); let (request_tx, request_rx) = mpsc::unbounded_channel::(); @@ -1234,6 +1293,7 @@ impl Client { session_fs_sqlite_declared, on_get_trace_context, effective_connection_token, + mode, }), }; client.spawn_lifecycle_dispatcher(); @@ -1321,6 +1381,11 @@ impl Client { if let Some(dir) = &options.base_directory { command.env("COPILOT_HOME", dir); } + // Empty mode disables the process-wide system keychain so the CLI + // falls back to file-based credentials scoped to COPILOT_HOME. + if options.mode == ClientMode::Empty { + command.env("COPILOT_DISABLE_KEYTAR", "1"); + } if let Transport::Tcp { connection_token: Some(token), .. @@ -1502,6 +1567,11 @@ impl Client { &self.inner.cwd } + /// Returns the SDK [`ClientMode`] this client was started with. + pub fn mode(&self) -> ClientMode { + self.inner.mode + } + /// Typed RPC namespace for server-level methods. /// /// Every protocol method lives here under its schema-aligned path — @@ -2620,6 +2690,7 @@ mod tests { session_fs_sqlite_declared: false, on_get_trace_context: None, effective_connection_token: None, + mode: ClientMode::default(), }), } } diff --git a/rust/src/mode.rs b/rust/src/mode.rs new file mode 100644 index 000000000..01d1038b1 --- /dev/null +++ b/rust/src/mode.rs @@ -0,0 +1,461 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +//! Client-level "empty" mode for minimal/safe defaults. +//! +//! See the plan in : +//! [`ClientMode::Empty`] disables ambient CLI-style behavior by default so an +//! app must explicitly opt back into features. This module exposes the public +//! enum, the [`ToolSet`] builder for source-qualified tool filter patterns, +//! and the [`BUILTIN_TOOLS_ISOLATED`] curated allowlist. + +use std::collections::HashMap; + +use crate::types::{SectionOverride, SystemMessageConfig}; + +/// Controls SDK defaults for ambient CLI-style behavior. +/// +/// - [`ClientMode::CopilotCli`] (default): defaults equivalent to Copilot CLI. +/// Useful when building a coding agent that shares sessions with Copilot CLI. +/// **Do not use this mode for server-based multi-user applications** — the +/// default coding agent has tools and capabilities that operate across +/// sessions and can access the host OS environment. +/// - [`ClientMode::Empty`]: disables optional features by default. The app +/// must explicitly opt into anything it needs. Required for any scenario +/// where CLI-like ambient behavior is unsafe (e.g. multi-user servers). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClientMode { + /// Defaults equivalent to Copilot CLI (the default). + #[default] + CopilotCli, + /// Disables optional features by default; app must opt in explicitly. + Empty, +} + +/// Tool name character set enforced by the runtime at every registration +/// boundary. Mirrors the runtime's `VALID_TOOL_NAME_REGEX`. +fn is_valid_tool_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn validate_name(kind: &str, name: &str) -> Result<(), crate::Error> { + if name == "*" { + return Ok(()); + } + if !is_valid_tool_name(name) { + return Err(crate::Error::InvalidConfig(format!( + "Invalid {kind} tool name '{name}': tool names must match \ + /^[a-zA-Z0-9_-]+$/ or be the wildcard '*'." + ))); + } + Ok(()) +} + +/// Builder that produces source-qualified tool filter strings (e.g. +/// `"builtin:bash"`, `"mcp:*"`, `"custom:foo"`) for the session's +/// `available_tools` list. +/// +/// Tools are classified by the runtime at registration time, not from name +/// parsing — so `add_builtin("foo")` matches only tools registered as +/// built-in, even if an MCP server happens to register a tool with the same +/// wire name. +/// +/// # Example +/// +/// ``` +/// # use github_copilot_sdk::mode::{ToolSet, BUILTIN_TOOLS_ISOLATED}; +/// let tools = ToolSet::new() +/// .add_builtin_many(BUILTIN_TOOLS_ISOLATED)? +/// .add_mcp("*")? +/// .add_custom("*")? +/// .to_vec(); +/// # Ok::<(), github_copilot_sdk::Error>(()) +/// ``` +#[derive(Debug, Clone, Default)] +pub struct ToolSet { + items: Vec, +} + +impl ToolSet { + /// Construct an empty tool set. + pub fn new() -> Self { + Self::default() + } + + /// Add a single built-in tool pattern. Pass a specific name (e.g. + /// `"bash"`) or `"*"` to match all built-in tools. + pub fn add_builtin(mut self, name: &str) -> Result { + validate_name("builtin", name)?; + self.items.push(format!("builtin:{name}")); + Ok(self) + } + + /// Add a list of built-in tool patterns (e.g. [`BUILTIN_TOOLS_ISOLATED`]). + pub fn add_builtin_many(mut self, names: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + for name in names { + let name = name.as_ref(); + validate_name("builtin", name)?; + self.items.push(format!("builtin:{name}")); + } + Ok(self) + } + + /// Add a custom tool pattern. Matches tools registered via the SDK's + /// `tools` option or via custom agents. + pub fn add_custom(mut self, name: &str) -> Result { + validate_name("custom", name)?; + self.items.push(format!("custom:{name}")); + Ok(self) + } + + /// Add an MCP tool pattern. Pass the runtime's canonical wire name + /// (e.g. `"github-list_issues"`) or `"*"` to match all MCP tools. + pub fn add_mcp(mut self, tool_name: &str) -> Result { + validate_name("mcp", tool_name)?; + self.items.push(format!("mcp:{tool_name}")); + Ok(self) + } + + /// Returns a defensive copy of the accumulated filter strings. + pub fn to_vec(&self) -> Vec { + self.items.clone() + } + + /// Returns the accumulated filter strings, consuming the builder. + pub fn into_vec(self) -> Vec { + self.items + } + + /// Number of accumulated filter strings. + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns `true` if no filter strings have been added. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +impl From for Vec { + fn from(value: ToolSet) -> Self { + value.into_vec() + } +} + +/// 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 [`ClientMode::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. +pub const BUILTIN_TOOLS_ISOLATED: &[&str] = &[ + "ask_user", + "task_complete", + "exit_plan_mode", + "task", + "read_agent", + "write_agent", + "list_agents", + "send_inbox", + "context_board", + "skill", +]; + +/// Validate a tool filter list (`available_tools` or `excluded_tools`). +/// Rejects the bare `"*"` shorthand with a clear error pointing the developer +/// at the source-qualified forms. +pub(crate) fn validate_tool_filter_list( + field: &str, + list: Option<&[String]>, +) -> Result<(), crate::Error> { + let Some(list) = list else { return Ok(()) }; + for item in list { + if item == "*" { + return Err(crate::Error::InvalidConfig(format!( + "{field} contains a bare '*' which matches no tool. Use \ + source-qualified wildcards instead: \ + ToolSet::new().add_builtin(\"*\").add_mcp(\"*\").add_custom(\"*\")." + ))); + } + } + Ok(()) +} + +/// Returns the system message config to use, adjusted for the current mode. +/// In empty mode we ensure the `environment_context` section is removed +/// unless the app has already taken control of it. +pub(crate) fn system_message_for_mode( + mode: ClientMode, + supplied: Option, +) -> Option { + if mode != ClientMode::Empty { + return supplied; + } + let strip_env = || { + let mut sections = HashMap::new(); + sections.insert( + "environment_context".to_string(), + SectionOverride { + action: Some("remove".to_string()), + content: None, + }, + ); + sections + }; + let Some(supplied) = supplied else { + return Some(SystemMessageConfig { + mode: Some("customize".to_string()), + content: None, + sections: Some(strip_env()), + }); + }; + let mode_str = supplied.mode.as_deref().unwrap_or("append"); + match mode_str { + "replace" => Some(supplied), + "customize" => { + if supplied + .sections + .as_ref() + .is_some_and(|s| s.contains_key("environment_context")) + { + Some(supplied) + } else { + let mut sections = supplied.sections.unwrap_or_default(); + sections.insert( + "environment_context".to_string(), + SectionOverride { + action: Some("remove".to_string()), + content: None, + }, + ); + Some(SystemMessageConfig { + mode: Some("customize".to_string()), + content: supplied.content, + sections: Some(sections), + }) + } + } + // "append" or any unrecognized value: promote to customize so we + // can also strip environment_context; the runtime appends `content` + // to additional instructions either way. + _ => Some(SystemMessageConfig { + mode: Some("customize".to_string()), + content: supplied.content, + sections: Some(strip_env()), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_set_emits_source_qualified_patterns() { + let v = ToolSet::new() + .add_builtin("bash") + .unwrap() + .add_builtin("*") + .unwrap() + .add_custom("foo") + .unwrap() + .add_custom("*") + .unwrap() + .add_mcp("github-list_issues") + .unwrap() + .add_mcp("*") + .unwrap() + .to_vec(); + assert_eq!( + v, + vec![ + "builtin:bash", + "builtin:*", + "custom:foo", + "custom:*", + "mcp:github-list_issues", + "mcp:*", + ] + ); + } + + #[test] + fn tool_set_add_builtin_many() { + let v = ToolSet::new() + .add_builtin_many(BUILTIN_TOOLS_ISOLATED) + .unwrap() + .into_vec(); + assert_eq!(v.len(), BUILTIN_TOOLS_ISOLATED.len()); + assert_eq!(v[0], format!("builtin:{}", BUILTIN_TOOLS_ISOLATED[0])); + } + + #[test] + fn tool_set_rejects_invalid_names() { + for bad in ["bash!", "with space", "colon:name", "", "wild*card"] { + assert!( + ToolSet::new().add_builtin(bad).is_err(), + "expected '{bad}' to be rejected" + ); + assert!(ToolSet::new().add_custom(bad).is_err()); + assert!(ToolSet::new().add_mcp(bad).is_err()); + } + } + + #[test] + fn tool_set_accepts_wildcard_and_underscores_and_dashes() { + assert!(ToolSet::new().add_builtin("*").is_ok()); + assert!(ToolSet::new().add_mcp("github-list_issues").is_ok()); + assert!(ToolSet::new().add_custom("A_b-9").is_ok()); + } + + #[test] + fn into_vec_is_idempotent_with_to_vec() { + let ts = ToolSet::new().add_builtin("bash").unwrap(); + assert_eq!(ts.to_vec(), vec!["builtin:bash"]); + assert_eq!(ts.into_vec(), vec!["builtin:bash"]); + } + + #[test] + fn into_vec_string_conversion() { + let v: Vec = ToolSet::new().add_mcp("*").unwrap().into(); + assert_eq!(v, vec!["mcp:*"]); + } + + #[test] + fn validate_tool_filter_list_rejects_bare_star() { + let bad = vec!["*".to_string()]; + assert!(validate_tool_filter_list("availableTools", Some(&bad)).is_err()); + } + + #[test] + fn validate_tool_filter_list_allows_qualified_star() { + let ok = vec!["builtin:*".to_string(), "mcp:*".to_string()]; + assert!(validate_tool_filter_list("availableTools", Some(&ok)).is_ok()); + } + + #[test] + fn validate_tool_filter_list_none_is_ok() { + assert!(validate_tool_filter_list("availableTools", None).is_ok()); + } + + #[test] + fn builtin_tools_isolated_contents() { + assert!(BUILTIN_TOOLS_ISOLATED.contains(&"ask_user")); + assert!(BUILTIN_TOOLS_ISOLATED.contains(&"task_complete")); + assert!(BUILTIN_TOOLS_ISOLATED.contains(&"skill")); + assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"bash")); + assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"edit")); + assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"web_fetch")); + } + + #[test] + fn client_mode_default_is_copilot_cli() { + assert_eq!(ClientMode::default(), ClientMode::CopilotCli); + } + + #[test] + fn system_message_copilot_cli_passes_through_unchanged() { + let cfg = SystemMessageConfig { + mode: Some("append".to_string()), + content: Some("hello".to_string()), + sections: None, + }; + let out = system_message_for_mode(ClientMode::CopilotCli, Some(cfg.clone())); + let out = out.unwrap(); + assert_eq!(out.mode.as_deref(), Some("append")); + assert_eq!(out.content.as_deref(), Some("hello")); + } + + #[test] + fn system_message_empty_none_injects_strip() { + let out = system_message_for_mode(ClientMode::Empty, None).unwrap(); + assert_eq!(out.mode.as_deref(), Some("customize")); + let sections = out.sections.unwrap(); + let env = sections.get("environment_context").unwrap(); + assert_eq!(env.action.as_deref(), Some("remove")); + } + + #[test] + fn system_message_empty_append_promoted_to_customize() { + let cfg = SystemMessageConfig { + mode: Some("append".to_string()), + content: Some("hi".to_string()), + sections: None, + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap(); + assert_eq!(out.mode.as_deref(), Some("customize")); + assert_eq!(out.content.as_deref(), Some("hi")); + let sections = out.sections.unwrap(); + assert!(sections.contains_key("environment_context")); + } + + #[test] + fn system_message_empty_replace_passes_through() { + let cfg = SystemMessageConfig { + mode: Some("replace".to_string()), + content: Some("verbatim".to_string()), + sections: None, + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg.clone())).unwrap(); + assert_eq!(out.mode.as_deref(), Some("replace")); + assert_eq!(out.content.as_deref(), Some("verbatim")); + assert!(out.sections.is_none()); + } + + #[test] + fn system_message_empty_customize_with_env_context_preserved() { + let mut sections = HashMap::new(); + sections.insert( + "environment_context".to_string(), + SectionOverride { + action: Some("replace".to_string()), + content: Some("custom env".to_string()), + }, + ); + let cfg = SystemMessageConfig { + mode: Some("customize".to_string()), + content: None, + sections: Some(sections), + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap(); + let env = out.sections.unwrap().remove("environment_context").unwrap(); + assert_eq!(env.action.as_deref(), Some("replace")); + assert_eq!(env.content.as_deref(), Some("custom env")); + } + + #[test] + fn system_message_empty_customize_without_env_context_gets_strip() { + let mut sections = HashMap::new(); + sections.insert( + "other_section".to_string(), + SectionOverride { + action: Some("replace".to_string()), + content: Some("body".to_string()), + }, + ); + let cfg = SystemMessageConfig { + mode: Some("customize".to_string()), + content: None, + sections: Some(sections), + }; + let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap(); + let secs = out.sections.unwrap(); + assert!(secs.contains_key("other_section")); + let env = secs.get("environment_context").unwrap(); + assert_eq!(env.action.as_deref(), Some("remove")); + } +} diff --git a/rust/src/session.rs b/rust/src/session.rs index d401a7d45..7527b6c8a 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -801,6 +801,29 @@ impl Client { if let Some(transforms) = config.system_message_transform.clone() { inject_transform_sections(&mut config, transforms.as_ref()); } + let mode = self.inner.mode; + if mode == crate::ClientMode::Empty && config.available_tools.is_none() { + return Err(Error::InvalidConfig( + "ClientMode::Empty requires available_tools to be set on the session config. \ + Use ToolSet to specify which tools the session may use (e.g. \ + ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED))." + .to_string(), + )); + } + crate::mode::validate_tool_filter_list( + "available_tools", + config.available_tools.as_deref(), + )?; + crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?; + config.system_message = + crate::mode::system_message_for_mode(mode, config.system_message.take()); + if mode == crate::ClientMode::Empty && config.enable_session_telemetry.is_none() { + config.enable_session_telemetry = Some(false); + } + let opt_skip_custom_instructions = config.skip_custom_instructions; + let opt_custom_agents_local_only = config.custom_agents_local_only; + let opt_coauthor_enabled = config.coauthor_enabled; + let opt_manage_schedule_enabled = config.manage_schedule_enabled; let (wire, mut runtime) = config.into_wire(session_id.clone())?; let permission_handler = crate::permission::resolve_handler( @@ -907,7 +930,7 @@ impl Client { "Client::create_session complete" ); registration.disarm(); - Ok(Session { + let session = Session { id: session_id, cwd: self.cwd().clone(), workspace_path: create_result.workspace_path, @@ -919,7 +942,17 @@ impl Client { capabilities, open_canvases: Arc::new(parking_lot::RwLock::new(Vec::new())), event_tx, - }) + }; + apply_mode_post_create_patch( + &session, + mode, + opt_skip_custom_instructions, + opt_custom_agents_local_only, + opt_coauthor_enabled, + opt_manage_schedule_enabled, + ) + .await?; + Ok(session) } /// Resume an existing session on the CLI. @@ -941,6 +974,29 @@ impl Client { if let Some(transforms) = config.system_message_transform.clone() { inject_transform_sections_resume(&mut config, transforms.as_ref()); } + let mode = self.inner.mode; + if mode == crate::ClientMode::Empty && config.available_tools.is_none() { + return Err(Error::InvalidConfig( + "ClientMode::Empty requires available_tools to be set on the session config. \ + Use ToolSet to specify which tools the session may use (e.g. \ + ToolSet::new().add_builtin_many(BUILTIN_TOOLS_ISOLATED))." + .to_string(), + )); + } + crate::mode::validate_tool_filter_list( + "available_tools", + config.available_tools.as_deref(), + )?; + crate::mode::validate_tool_filter_list("excluded_tools", config.excluded_tools.as_deref())?; + config.system_message = + crate::mode::system_message_for_mode(mode, config.system_message.take()); + if mode == crate::ClientMode::Empty && config.enable_session_telemetry.is_none() { + config.enable_session_telemetry = Some(false); + } + let opt_skip_custom_instructions = config.skip_custom_instructions; + let opt_custom_agents_local_only = config.custom_agents_local_only; + let opt_coauthor_enabled = config.coauthor_enabled; + let opt_manage_schedule_enabled = config.manage_schedule_enabled; let (wire, mut runtime) = config.into_wire()?; let permission_handler = crate::permission::resolve_handler( @@ -1080,7 +1136,7 @@ impl Client { "Client::resume_session complete" ); registration.disarm(); - Ok(Session { + let session = Session { id: session_id, cwd: self.cwd().clone(), workspace_path: resume_result.workspace_path, @@ -1092,12 +1148,69 @@ impl Client { capabilities, open_canvases, event_tx, - }) + }; + apply_mode_post_create_patch( + &session, + mode, + opt_skip_custom_instructions, + opt_custom_agents_local_only, + opt_coauthor_enabled, + opt_manage_schedule_enabled, + ) + .await?; + Ok(session) } } type CommandHandlerMap = HashMap>; +async fn apply_mode_post_create_patch( + session: &Session, + mode: crate::ClientMode, + opt_skip_custom_instructions: Option, + opt_custom_agents_local_only: Option, + opt_coauthor_enabled: Option, + opt_manage_schedule_enabled: Option, +) -> Result<(), Error> { + use crate::generated::api_types::SessionUpdateOptionsParams; + let mut patch = SessionUpdateOptionsParams::default(); + let should_send = if mode == crate::ClientMode::Empty { + patch.skip_custom_instructions = Some(opt_skip_custom_instructions.unwrap_or(true)); + patch.custom_agents_local_only = Some(opt_custom_agents_local_only.unwrap_or(true)); + patch.coauthor_enabled = Some(opt_coauthor_enabled.unwrap_or(false)); + patch.manage_schedule_enabled = Some(opt_manage_schedule_enabled.unwrap_or(false)); + patch.installed_plugins = Some(Vec::new()); + true + } else { + let mut any = false; + if let Some(v) = opt_skip_custom_instructions { + patch.skip_custom_instructions = Some(v); + any = true; + } + if let Some(v) = opt_custom_agents_local_only { + patch.custom_agents_local_only = Some(v); + any = true; + } + if let Some(v) = opt_coauthor_enabled { + patch.coauthor_enabled = Some(v); + any = true; + } + if let Some(v) = opt_manage_schedule_enabled { + patch.manage_schedule_enabled = Some(v); + any = true; + } + any + }; + if !should_send { + return Ok(()); + } + if let Err(error) = session.rpc().options().update(patch).await { + let _ = session.disconnect().await; + return Err(error); + } + Ok(()) +} + fn build_command_handler_map(commands: Option<&[CommandDefinition]>) -> Arc { let map = match commands { Some(commands) => commands diff --git a/rust/src/session_fs_dispatch.rs b/rust/src/session_fs_dispatch.rs index f77c9aa53..9c5780d37 100644 --- a/rust/src/session_fs_dispatch.rs +++ b/rust/src/session_fs_dispatch.rs @@ -341,7 +341,7 @@ pub(crate) async fn sqlite_query( return; } }; - let sqlite_params = (!params.params.is_empty()).then_some(¶ms.params); + let sqlite_params = params.params.as_ref().filter(|p| !p.is_empty()); let result = match sqlite .sqlite_query(params.query_type, ¶ms.query, sqlite_params) .await diff --git a/rust/src/types.rs b/rust/src/types.rs index 6f5c826c6..f9b29600d 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1234,6 +1234,22 @@ pub struct SessionConfig { /// `systemMessage.transform` RPC callbacks to it during the session. /// Use [`with_system_message_transform`](Self::with_system_message_transform) to install one. pub system_message_transform: Option>, + /// Whether to skip loading custom-instruction sources for this session. + /// Applied via `session.options.update` after create/resume. Defaults to + /// `true` in [`crate::ClientMode::Empty`] when unset. + pub skip_custom_instructions: Option, + /// Whether to constrain custom agents to local-only execution. Applied + /// via `session.options.update` after create/resume. Defaults to `true` + /// in [`crate::ClientMode::Empty`] when unset. + pub custom_agents_local_only: Option, + /// Whether to include the `Co-authored-by` trailer in commit messages. + /// Applied via `session.options.update` after create/resume. Defaults to + /// `false` in [`crate::ClientMode::Empty`] when unset. + pub coauthor_enabled: Option, + /// Whether to expose the `manage_schedule` tool. Applied via + /// `session.options.update` after create/resume. Defaults to `false` in + /// [`crate::ClientMode::Empty`] when unset. + pub manage_schedule_enabled: Option, } impl std::fmt::Debug for SessionConfig { @@ -1369,6 +1385,10 @@ impl Default for SessionConfig { hooks_handler: None, permission_policy: None, system_message_transform: None, + skip_custom_instructions: None, + custom_agents_local_only: None, + coauthor_enabled: None, + manage_schedule_enabled: None, } } } @@ -1456,6 +1476,7 @@ impl SessionConfig { extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, + tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, @@ -1837,6 +1858,30 @@ impl SessionConfig { self.cloud = Some(cloud); self } + + /// Set [`Self::skip_custom_instructions`]. + pub fn with_skip_custom_instructions(mut self, value: bool) -> Self { + self.skip_custom_instructions = Some(value); + self + } + + /// Set [`Self::custom_agents_local_only`]. + pub fn with_custom_agents_local_only(mut self, value: bool) -> Self { + self.custom_agents_local_only = Some(value); + self + } + + /// Set [`Self::coauthor_enabled`]. + pub fn with_coauthor_enabled(mut self, value: bool) -> Self { + self.coauthor_enabled = Some(value); + self + } + + /// Set [`Self::manage_schedule_enabled`]. + pub fn with_manage_schedule_enabled(mut self, value: bool) -> Self { + self.manage_schedule_enabled = Some(value); + self + } } /// Configuration for resuming an existing session via the `session.resume` RPC. @@ -1965,6 +2010,14 @@ pub struct ResumeSessionConfig { pub(crate) permission_policy: Option, /// System-message transform. See [`SessionConfig::system_message_transform`]. pub system_message_transform: Option>, + /// See [`SessionConfig::skip_custom_instructions`]. + pub skip_custom_instructions: Option, + /// See [`SessionConfig::custom_agents_local_only`]. + pub custom_agents_local_only: Option, + /// See [`SessionConfig::coauthor_enabled`]. + pub coauthor_enabled: Option, + /// See [`SessionConfig::manage_schedule_enabled`]. + pub manage_schedule_enabled: Option, } impl std::fmt::Debug for ResumeSessionConfig { @@ -2108,6 +2161,7 @@ impl ResumeSessionConfig { extension_info: self.extension_info, available_tools: self.available_tools, excluded_tools: self.excluded_tools, + tool_filter_precedence: "excluded", mcp_servers: self.mcp_servers, env_value_mode: "direct", enable_config_discovery: self.enable_config_discovery, @@ -2205,6 +2259,10 @@ impl ResumeSessionConfig { hooks_handler: None, permission_policy: None, system_message_transform: None, + skip_custom_instructions: None, + custom_agents_local_only: None, + coauthor_enabled: None, + manage_schedule_enabled: None, } } @@ -2531,6 +2589,30 @@ impl ResumeSessionConfig { self.continue_pending_work = Some(continue_pending); self } + + /// Set [`Self::skip_custom_instructions`]. + pub fn with_skip_custom_instructions(mut self, value: bool) -> Self { + self.skip_custom_instructions = Some(value); + self + } + + /// Set [`Self::custom_agents_local_only`]. + pub fn with_custom_agents_local_only(mut self, value: bool) -> Self { + self.custom_agents_local_only = Some(value); + self + } + + /// Set [`Self::coauthor_enabled`]. + pub fn with_coauthor_enabled(mut self, value: bool) -> Self { + self.coauthor_enabled = Some(value); + self + } + + /// Set [`Self::manage_schedule_enabled`]. + pub fn with_manage_schedule_enabled(mut self, value: bool) -> Self { + self.manage_schedule_enabled = Some(value); + self + } } /// Controls how the system message is constructed. diff --git a/rust/src/wire.rs b/rust/src/wire.rs index b97aea261..29f89d84d 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -67,6 +67,9 @@ pub(crate) struct SessionCreateWire { pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, + /// SDK always sends `"excluded"` so include + exclude lists compose + /// naturally (everything matching X except Y). + pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, pub env_value_mode: &'static str, @@ -143,6 +146,8 @@ pub(crate) struct SessionResumeWire { pub available_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub excluded_tools: Option>, + /// SDK always sends `"excluded"`. See create-wire docs. + pub tool_filter_precedence: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, pub env_value_mode: &'static str, diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 8589bca47..b24a647cd 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -33,6 +33,8 @@ mod hooks; mod hooks_extended; #[path = "e2e/mcp_and_agents.rs"] mod mcp_and_agents; +#[path = "e2e/mode_empty.rs"] +mod mode_empty; #[path = "e2e/mode_handlers.rs"] mod mode_handlers; #[path = "e2e/multi_client.rs"] diff --git a/rust/tests/e2e/mode_empty.rs b/rust/tests/e2e/mode_empty.rs new file mode 100644 index 000000000..af1e9267e --- /dev/null +++ b/rust/tests/e2e/mode_empty.rs @@ -0,0 +1,376 @@ +//! E2E coverage for `ClientMode::Empty` + `ToolSet` patterns. +//! +//! The runtime is mode-agnostic — these tests verify the SDK's +//! translation reaches the runtime correctly by inspecting the +//! resulting CapiProxy chat-completion request (the LLM only sees +//! tools the runtime exposed for the session) and end-to-end behavior. +//! +//! Mirrors `nodejs/test/e2e/mode_empty.e2e.test.ts` and shares the +//! same recorded cassettes under `test/snapshots/mode_empty/`. + +use std::sync::Arc; + +use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::types::SystemMessageConfig; +use github_copilot_sdk::{BUILTIN_TOOLS_ISOLATED, Client, ClientMode, SessionConfig, ToolSet}; +use serde_json::Value; + +use super::support::{assistant_message_content, with_e2e_context}; + +const SHELL_TOOL_NAME: &str = if cfg!(windows) { "powershell" } else { "bash" }; + +fn isolated_tool_set() -> Vec { + ToolSet::new() + .add_builtin_many(BUILTIN_TOOLS_ISOLATED.iter().copied()) + .expect("isolated tool set should be valid") + .into() +} + +fn star_builtin_tool_set() -> Vec { + ToolSet::new() + .add_builtin("*") + .expect("builtin wildcard should be valid") + .into() +} + +fn tool_names_from_request(exchange: &Value) -> Vec { + let Some(tools) = exchange + .get("request") + .and_then(|r| r.get("tools")) + .and_then(|t| t.as_array()) + else { + return Vec::new(); + }; + tools + .iter() + .filter_map(|t| { + let type_ok = t.get("type").and_then(Value::as_str) == Some("function"); + if !type_ok { + return None; + } + t.get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect() +} + +fn system_message_from_request(exchange: &Value) -> String { + let Some(messages) = exchange + .get("request") + .and_then(|r| r.get("messages")) + .and_then(|m| m.as_array()) + else { + return String::new(); + }; + for m in messages { + if m.get("role").and_then(Value::as_str) != Some("system") { + continue; + } + let content = m.get("content"); + if let Some(text) = content.and_then(Value::as_str) { + return text.to_owned(); + } + if let Some(parts) = content.and_then(Value::as_array) { + return parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + } + } + String::new() +} + +#[tokio::test] +async fn empty_mode_isolated_set_shell_tool_is_not_exposed() { + with_e2e_context( + "mode_empty", + "empty_mode_isolated_set_shell_tool_is_not_exposed", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()), + ) + .await + .expect("create session"); + + let _ = session.send_and_wait("Say hi.").await; + + let exchanges = ctx.exchanges(); + assert!(!exchanges.is_empty(), "expected at least one exchange"); + let tool_names = tool_names_from_request(exchanges.last().unwrap()); + for banned in ["bash", "powershell", "edit", "grep", "web_fetch"] { + assert!( + !tool_names.iter().any(|n| n == banned), + "isolated set must not expose {banned:?}, got {tool_names:?}" + ); + } + let any_isolated = BUILTIN_TOOLS_ISOLATED + .iter() + .any(|n| tool_names.iter().any(|t| t == n)); + assert!( + any_isolated, + "expected at least one isolated tool to be registered, got {tool_names:?}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_builtin_star_exposes_all_built_in_tools() { + with_e2e_context( + "mode_empty", + "empty_mode_builtin_star_exposes_all_built_in_tools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(star_builtin_tool_set()), + ) + .await + .expect("create session"); + + let _ = session.send_and_wait("Say hi.").await; + + let exchanges = ctx.exchanges(); + let tool_names = tool_names_from_request(exchanges.last().unwrap()); + assert!( + tool_names.iter().any(|n| n == SHELL_TOOL_NAME), + "builtin:* should expose {SHELL_TOOL_NAME}, got {tool_names:?}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_excluded_tools_subtracts_from_available_tools() { + with_e2e_context( + "mode_empty", + "empty_mode_excluded_tools_subtracts_from_available_tools", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(star_builtin_tool_set()) + .with_excluded_tools(vec![format!("builtin:{SHELL_TOOL_NAME}")]), + ) + .await + .expect("create session"); + + let _ = session.send_and_wait("Say hi.").await; + + let exchanges = ctx.exchanges(); + let tool_names = tool_names_from_request(exchanges.last().unwrap()); + assert!( + !tool_names.iter().any(|n| n == SHELL_TOOL_NAME), + "excluded {SHELL_TOOL_NAME} must not be exposed, got {tool_names:?}" + ); + assert!(!tool_names.is_empty()); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_strips_environment_context_from_the_system_message_by_default() { + with_e2e_context( + "mode_empty", + "empty_mode_strips_environment_context_from_the_system_message_by_default", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()) + .with_system_message( + SystemMessageConfig::new() + .with_mode("customize") + .with_content( + "If the user asks you to name an element, reply with exactly the single word ARGON in all caps and nothing else.", + ), + ), + ) + .await + .expect("create session"); + + let event = session + .send_and_wait("Name an element.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&event); + assert!(content.contains("ARGON"), "expected ARGON in reply, got {content:?}"); + + let exchanges = ctx.exchanges(); + let system_message = system_message_from_request(exchanges.last().unwrap()); + assert!( + !system_message.to_lowercase().contains("current working directory:"), + "env context should be stripped, got: {system_message}" + ); + assert!( + !system_message.to_lowercase().contains("operating system:"), + "env context should be stripped, got: {system_message}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_system_message_replace_llm_follows_caller_content_verbatim() { + with_e2e_context( + "mode_empty", + "empty_mode_system_message_replace_llm_follows_caller_content_verbatim", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()) + .with_system_message( + SystemMessageConfig::new() + .with_mode("replace") + .with_content( + "You are a test fixture. Whenever the user asks anything, reply with exactly the single word KRYPTON in all caps and nothing else.", + ), + ), + ) + .await + .expect("create session"); + + let event = session + .send_and_wait("Hello.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&event); + assert!(content.contains("KRYPTON"), "expected KRYPTON in reply, got {content:?}"); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped() { + with_e2e_context( + "mode_empty", + "empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let options = ctx + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(ctx.work_dir().to_path_buf()); + let client = Client::start(options).await.expect("start client"); + let session = client + .create_session( + SessionConfig::default() + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_available_tools(isolated_tool_set()) + .with_system_message( + SystemMessageConfig::new() + .with_mode("append") + .with_content( + "If the user asks you to name a noble gas, reply with exactly the single word XENON in all caps and nothing else.", + ), + ), + ) + .await + .expect("create session"); + + let event = session + .send_and_wait("Name a noble gas.") + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&event); + assert!(content.contains("XENON"), "expected XENON in reply, got {content:?}"); + + let exchanges = ctx.exchanges(); + let system_message = system_message_from_request(exchanges.last().unwrap()); + assert!( + !system_message.to_lowercase().contains("current working directory:"), + "env context should be stripped, got: {system_message}" + ); + assert!( + !system_message.to_lowercase().contains("operating system:"), + "env context should be stripped, got: {system_message}" + ); + + session.disconnect().await.expect("disconnect"); + client.stop().await.expect("stop"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_mcp_and_skills.rs b/rust/tests/e2e/rpc_mcp_and_skills.rs index 35e38072f..45233f97c 100644 --- a/rust/tests/e2e/rpc_mcp_and_skills.rs +++ b/rust/tests/e2e/rpc_mcp_and_skills.rs @@ -391,10 +391,10 @@ async fn should_round_trip_mcp_app_host_context() { .apps() .set_host_context(McpAppsSetHostContextRequest { context: McpAppsSetHostContextDetails { - available_display_modes: vec![ + available_display_modes: Some(vec![ McpAppsSetHostContextDetailsAvailableDisplayMode::Inline, McpAppsSetHostContextDetailsAvailableDisplayMode::Fullscreen, - ], + ]), display_mode: Some(McpAppsSetHostContextDetailsDisplayMode::Inline), locale: Some("en-US".to_string()), platform: Some(McpAppsSetHostContextDetailsPlatform::Desktop), @@ -416,7 +416,10 @@ async fn should_round_trip_mcp_app_host_context() { assert_eq!(context.locale.as_deref(), Some("en-US")); assert_eq!(context.time_zone.as_deref(), Some("Etc/UTC")); assert_eq!(context.user_agent.as_deref(), Some("rust-e2e")); - assert_eq!(context.available_display_modes.len(), 2); + assert_eq!( + context.available_display_modes.as_ref().map_or(0, Vec::len), + 2 + ); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -473,7 +476,7 @@ async fn should_diagnose_and_report_mcp_app_capability_errors() { .mcp() .apps() .call_tool(McpAppsCallToolRequest { - arguments: HashMap::new(), + arguments: Some(HashMap::new()), server_name: server_name.to_string(), origin_server_name: server_name.to_string(), tool_name: "missing-tool".to_string(), diff --git a/rust/tests/e2e/rpc_server.rs b/rust/tests/e2e/rpc_server.rs index 1e5b817bf..5ce55f847 100644 --- a/rust/tests/e2e/rpc_server.rs +++ b/rust/tests/e2e/rpc_server.rs @@ -166,8 +166,10 @@ async fn should_discover_server_mcp_and_skills() { .rpc() .skills() .discover(SkillsDiscoverRequest { - project_paths: Vec::new(), - skill_directories: vec![skill_directory.to_string_lossy().to_string()], + project_paths: None, + skill_directories: Some(vec![ + skill_directory.to_string_lossy().to_string(), + ]), }) .await .expect("skills discover"); @@ -190,8 +192,10 @@ async fn should_discover_server_mcp_and_skills() { .rpc() .skills() .discover(SkillsDiscoverRequest { - project_paths: Vec::new(), - skill_directories: vec![skill_directory.to_string_lossy().to_string()], + project_paths: None, + skill_directories: Some(vec![ + skill_directory.to_string_lossy().to_string(), + ]), }) .await .expect("skills discover disabled"); @@ -537,7 +541,7 @@ async fn should_prune_dryrun_and_bulkdelete_persisted_session() { older_than_days: 0, dry_run: Some(true), include_named: Some(true), - exclude_session_ids: vec![session_id.to_string()], + exclude_session_ids: Some(vec![session_id.to_string()]), }) .await .expect("dry-run prune"); diff --git a/rust/tests/e2e/rpc_session_state.rs b/rust/tests/e2e/rpc_session_state.rs index 11ff768fc..80b6d1bfb 100644 --- a/rust/tests/e2e/rpc_session_state.rs +++ b/rust/tests/e2e/rpc_session_state.rs @@ -731,7 +731,7 @@ async fn should_update_options_and_initialize_session_services() { .options() .update(SessionUpdateOptionsParams { ask_user_disabled: Some(true), - available_tools: vec!["view".to_string()], + available_tools: Some(vec!["view".to_string()]), client_name: Some("rust-rpc-e2e".to_string()), enable_streaming: Some(true), model: Some(MODEL_ID.to_string()), diff --git a/rust/tests/e2e/session.rs b/rust/tests/e2e/session.rs index 0bb08587e..8e2e65a46 100644 --- a/rust/tests/e2e/session.rs +++ b/rust/tests/e2e/session.rs @@ -1104,7 +1104,8 @@ async fn should_send_with_file_attachment() { let attachments = user .typed_data::() .expect("user message data") - .attachments; + .attachments + .expect("attachments"); assert_eq!(attachments.len(), 1); assert_eq!( attachments[0] @@ -1167,7 +1168,8 @@ async fn should_send_with_directory_attachment() { let attachments = user .typed_data::() .expect("user message data") - .attachments; + .attachments + .expect("attachments"); assert_eq!(attachments.len(), 1); assert_eq!( attachments[0] @@ -1235,6 +1237,7 @@ async fn should_send_with_selection_attachment() { .typed_data::() .expect("user message data") .attachments + .expect("attachments") .into_iter() .next() .expect("attachment"); @@ -1294,6 +1297,7 @@ async fn should_send_with_github_reference_attachment() { .typed_data::() .expect("user message data") .attachments + .expect("attachments") .into_iter() .next() .expect("attachment"); diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index e23ff4385..f35a358ec 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -808,14 +808,16 @@ function resolveRustType( function wrapOption(rustType: string, isRequired: boolean): string { if (isRequired) return rustType; - // Don't double-wrap Option, Vec, or HashMap (they're already nullable-ish) - if ( - rustType.startsWith("Option<") || - rustType.startsWith("Vec<") || - rustType.startsWith("HashMap<") - ) { + // Already wrapped in Option — don't double-wrap. + if (rustType.startsWith("Option<")) { return rustType; } + // Non-required Vec/HashMap must be Option> / Option> + // so the SDK can distinguish "field omitted" (None) from "explicitly + // empty" (Some(vec![])). Bare Vec/HashMap with #[serde(default)] would + // serialize as `[]`/`{}` for unset fields, which patch-style request + // types (e.g. SessionUpdateOptionsParams) interpret as "clear the + // list" — silently wiping server-side state set elsewhere. return `Option<${rustType}>`; } diff --git a/test/snapshots/mode_empty/empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped.yaml b/test/snapshots/mode_empty/empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped.yaml new file mode 100644 index 000000000..fac88270d --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Name a noble gas. + - role: assistant + content: XENON diff --git a/test/snapshots/mode_empty/empty_mode_builtin_star_exposes_all_built_in_tools.yaml b/test/snapshots/mode_empty/empty_mode_builtin_star_exposes_all_built_in_tools.yaml new file mode 100644 index 000000000..decf64bc3 --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode_builtin_star_exposes_all_built_in_tools.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi. + - role: assistant + content: Hi! diff --git a/test/snapshots/mode_empty/empty_mode_excluded_tools_subtracts_from_available_tools.yaml b/test/snapshots/mode_empty/empty_mode_excluded_tools_subtracts_from_available_tools.yaml new file mode 100644 index 000000000..decf64bc3 --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode_excluded_tools_subtracts_from_available_tools.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi. + - role: assistant + content: Hi! diff --git a/test/snapshots/mode_empty/empty_mode_isolated_set_shell_tool_is_not_exposed.yaml b/test/snapshots/mode_empty/empty_mode_isolated_set_shell_tool_is_not_exposed.yaml new file mode 100644 index 000000000..decf64bc3 --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode_isolated_set_shell_tool_is_not_exposed.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi. + - role: assistant + content: Hi! diff --git a/test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml b/test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml new file mode 100644 index 000000000..6f23714d9 --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode_strips_environment_context_from_the_system_message_by_default.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Name an element. + - role: assistant + content: ARGON diff --git a/test/snapshots/mode_empty/empty_mode_system_message_replace_llm_follows_caller_content_verbatim.yaml b/test/snapshots/mode_empty/empty_mode_system_message_replace_llm_follows_caller_content_verbatim.yaml new file mode 100644 index 000000000..5d63a9401 --- /dev/null +++ b/test/snapshots/mode_empty/empty_mode_system_message_replace_llm_follows_caller_content_verbatim.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Hello. + - role: assistant + content: KRYPTON