Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 230 additions & 8 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}

/// <summary>
Expand Down Expand Up @@ -491,6 +513,181 @@ private static (SystemMessageConfig? wireConfig, Dictionary<string, Func<string,
return (wireConfig, callbacks);
}

/// <summary>
/// Catches misuse of <see cref="SessionConfigBase.AvailableTools"/> /
/// <see cref="SessionConfigBase.ExcludedTools"/> at the SDK boundary so
/// callers get an actionable error rather than a silently-empty filter.
/// The runtime treats a bare <c>"*"</c> as a literal name match for a tool
/// whose name is the single character <c>*</c>, which the runtime's
/// charset guard would reject at registration — so the filter effectively
/// matches nothing.
/// </summary>
private static void ValidateToolFilterList(string field, IList<string>? 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));
}
}
Comment on lines +528 to +538
}

/// <summary>
/// Resolves <see cref="SessionConfigBase.AvailableTools"/> /
/// <see cref="SessionConfigBase.ExcludedTools"/> for the wire payload,
/// validating empty-mode requirements. <c>toolFilterPrecedence</c> is
/// always <c>excluded</c> so SDK consumers get composable allowlist /
/// denylist semantics.
/// </summary>
private (IList<string>? AvailableTools, IList<string>? 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);
}

/// <summary>
/// Applies mode-specific defaults to a session config in place. Caller
/// values win — only fields left unset by the caller are filled in.
/// </summary>
private void ApplyConfigDefaultsForMode(SessionConfigBase config)
{
if (_options.Mode == CopilotClientMode.Empty)
{
config.EnableSessionTelemetry ??= false;
}
}

/// <summary>
/// Returns the <see cref="SystemMessageConfig"/> to send to the runtime,
/// adjusted for the current mode. In empty mode the
/// <c>environment_context</c> 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 <see cref="SystemMessageConfig.Content"/>
/// in both modes).
/// </summary>
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, SectionOverride>
{
[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<SystemMessageSection, SectionOverride>()
: new Dictionary<SystemMessageSection, SectionOverride>(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, SectionOverride>
{
[SystemMessageSection.EnvironmentContext] = new() { Action = SectionOverrideAction.Remove },
},
};
default:
return supplied;
}
}

/// <summary>
/// Applies the post-create / post-resume <c>session.options.update</c>
/// patch for the current mode. In empty mode this defaults the four
/// overridable feature flags to safe values (caller values from
/// <paramref name="config"/> win); <c>installedPlugins=[]</c> is
/// unconditional under empty mode so apps that need plugins must switch
/// modes. In copilot-cli mode only explicitly-set fields are forwarded.
/// </summary>
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<SessionInstalledPlugin>? 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<SessionInstalledPlugin>();
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;

#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
}

/// <summary>
/// Creates a new Copilot session with the specified configuration.
/// </summary>
Expand Down Expand Up @@ -523,6 +720,10 @@ public async Task<CopilotSession> 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 ||
Expand Down Expand Up @@ -590,8 +791,8 @@ public async Task<CopilotSession> 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,
Expand Down Expand Up @@ -624,7 +825,8 @@ public async Task<CopilotSession> 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<CreateSessionResponse>(
Expand All @@ -637,6 +839,8 @@ public async Task<CopilotSession> 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)
{
Expand Down Expand Up @@ -691,6 +895,10 @@ public async Task<CopilotSession> 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 ||
Expand Down Expand Up @@ -756,8 +964,8 @@ public async Task<CopilotSession> 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,
Expand Down Expand Up @@ -792,7 +1000,8 @@ public async Task<CopilotSession> 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<ResumeSessionResponse>(
Expand All @@ -805,6 +1014,8 @@ public async Task<CopilotSession> 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)
{
Expand Down Expand Up @@ -1439,6 +1650,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)
{
Expand Down Expand Up @@ -1887,7 +2107,8 @@ internal record CreateSessionRequest(
IList<CanvasDeclaration>? Canvases = null,
bool? RequestCanvasRenderer = null,
bool? RequestExtensions = null,
ExtensionInfo? ExtensionInfo = null);
ExtensionInfo? ExtensionInfo = null,
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null);
#pragma warning restore GHCP001

internal record ToolDefinition(
Expand Down Expand Up @@ -1959,7 +2180,8 @@ internal record ResumeSessionRequest(
bool? RequestCanvasRenderer = null,
bool? RequestExtensions = null,
ExtensionInfo? ExtensionInfo = null,
IList<OpenCanvasInstance>? OpenCanvases = null);
IList<OpenCanvasInstance>? OpenCanvases = null,
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null);
#pragma warning restore GHCP001

internal record ResumeSessionResponse(
Expand Down
Loading
Loading