diff --git a/.github/workflows/sdk-rust.yml b/.github/workflows/sdk-rust.yml index b939b84dbc..c5cf2d10c7 100644 --- a/.github/workflows/sdk-rust.yml +++ b/.github/workflows/sdk-rust.yml @@ -71,6 +71,9 @@ jobs: run: cargo +nightly-2026-04-14 fmt --all -- --config-path .rustfmt.nightly.toml --check - if: github.event_name != 'merge_group' && runner.os == 'Linux' run: cargo clippy --all-targets --no-default-features --features test-support,local-runtime,derive -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type + # Path-dependency consumers must not rerun build.rs on unchanged rebuilds. + - if: github.event_name != 'merge_group' && runner.os == 'Linux' + run: bash scripts/check-fresh-rebuild.sh --no-default-features --features test-support,derive - if: github.event_name != 'merge_group' && runner.os == 'Linux' env: RUSTDOCFLAGS: "-D warnings" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e7b09d0ce..e6d22d4d22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -294,6 +294,16 @@ follow [the Rust SDK workflow](.github/workflows/sdk-rust.yml) for rustdoc. ### Recording and replaying SDK tests +Owned-stdio shutdown regressions share +`test/harness/stdio-shutdown-runtime.cjs` across all six SDKs. Launch it with +Node and arguments ` `. The fixture acknowledges +`runtime.shutdown`, but writes its cleanup marker only after stdin EOF, matching +the native wrapper's host-finalization boundary. Language-native tests exercise +graceful stop/disposal, force-stop where exposed, a child that ignores EOF, and +failed-startup cleanup. Keep those lifecycle expectations aligned when changing +an SDK transport; test watchdogs must allow all cleanup phases their separate +budgets, rather than treating the graceful-exit timeout as a total shutdown cap. + The shared harness records real inference responses under `test/snapshots`. Record new captures with `GITHUB_TOKEN` set and `GITHUB_ACTIONS` unset; never author model responses by hand. Rerun with `GITHUB_ACTIONS=true` and real diff --git a/dotnet/README.md b/dotnet/README.md index 276b2151d6..17fa8c8cb1 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -166,6 +166,10 @@ Start the CLI server and establish connection. ##### `StopAsync(): Task` Stop the server and close all sessions. Throws if errors are encountered during cleanup. +For an owned stdio runtime, graceful shutdown closes stdin and waits up to 10 seconds +for host cleanup, including telemetry export. This cleanup is best-effort: if the wait +times out, the process is terminated and that timeout alone is not reported as a cleanup +error. A successful return does not guarantee that all telemetry was exported. ##### `ForceStopAsync(): Task` @@ -195,6 +199,11 @@ Create a new conversation session. - `OnUserInputRequest` - Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. - `AskUserVariant` - Selects the model-facing `ask_user` tool shape. Defaults to `AskUserVariant.Legacy`; use `AskUserVariant.Elicitation` with `OnElicitationRequest`. - `Hooks` - Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. +- `CanvasHandler` - Handles canvas open, close, and action callbacks. The SDK awaits + asynchronous callbacks before replying, including callbacks without a result, unless + the runtime cancels the request first. A cancellation response can be sent while the + callback is still running. Their cancellation token is canceled by a per-request + `$/cancelRequest`, when the runtime connection closes, or when the client is disposed. ##### `ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null): Task` diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5e07c1ef60..c3cc66c048 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -692,7 +692,7 @@ or IOException if (ctx.CliProcess is { } childProcess) { - await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger); + await CleanupCliProcessAsync(childProcess, ctx.StderrPump, errors, _logger, gracefulRuntimeShutdown); } if (ctx.FfiHost is { } ffiHost) @@ -703,20 +703,35 @@ or IOException } } - private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List? errors, ILogger? logger) + private static async Task CleanupCliProcessAsync(Process childProcess, ProcessStderrPump? stderrPump, List? errors, ILogger? logger, bool gracefulRuntimeShutdown = false) { var processExited = false; try { + if (gracefulRuntimeShutdown && childProcess.StartInfo.RedirectStandardInput && !childProcess.HasExited) + { + try + { + // The native wrapper finalizes host telemetry after stdin EOF, + // not when it acknowledges runtime.shutdown. + childProcess.StandardInput.Close(); + await childProcess.WaitForExitAsync().WaitAsync(s_runtimeShutdownTimeout); + } + catch (Exception ex) when (ex is TimeoutException or IOException or ObjectDisposedException) + { + logger?.LogDebug(ex, "Graceful stdio runtime exit did not complete; terminating the process"); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException) + { + AddCleanupError(errors, ex, logger); + } + } + if (!childProcess.HasExited) { - // The runtime completes all cleanup before responding to - // runtime.shutdown and then leaves termination to us; it - // deliberately keeps its JSON-RPC server alive to send the - // response and never self-exits. Waiting for a self-exit that - // will never come just wastes time, so terminate the child - // immediately and only wait to reap it. + // Force-stop, failed startup, and runtimes that ignore EOF still + // require explicit termination. childProcess.Kill(entireProcessTree: true); // Kill is asynchronous; wait for the root CLI process to exit so cleanup callers // do not observe StopAsync/DisposeAsync completion while it is still tearing down. diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index d73e661b36..2108941096 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -8529,6 +8529,32 @@ internal sealed class SandboxDisableForSessionRequest public string SessionId { get; set; } = string.Empty; } +/// Result of accepting a sandbox path grant. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed class SandboxGrantPathForRequestResult +{ + /// Whether this call resolved the pending request and added the path to the session's sandbox policy. + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +/// Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +internal sealed class SandboxGrantPathForRequestRequest +{ + /// Optional attribution for the permission decision. + [JsonPropertyName("decisionContext")] + public PermissionDecisionContext? DecisionContext { get; set; } + + /// Identifier of the exact pending sandbox escalation permission request whose sandboxPathGrant to accept. + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + /// Target session identifier. + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + /// Authentication status and account metadata for the session. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] public sealed class SessionAuthStatus @@ -17487,6 +17513,7 @@ internal sealed class SessionUpdateOptionsParams public SandboxConfig? SandboxConfig { get; set; } /// Origin of the sandbox choice. Settings-derived origins (never_configured, user_enabled, user_disabled, repository_policy) let managed policy floor a host preference; explicit below-floor changes remain policy conflicts unless a session opt-out is authorized. Also used for telemetry provenance. + [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] [JsonPropertyName("sandboxConfigSource")] public SandboxConfigSource? SandboxConfigSource { get; set; } @@ -35902,7 +35929,7 @@ public override void Write(Utf8JsonWriter writer, OptionsUpdateReasoningSummary } -/// Origin of the sandbox choice supplied by the host. Settings-derived origins let managed policy floor the host preference; do not tag explicit session overrides as settings-derived. +/// Origin of the sandbox choice supplied by the host. This value describes preference or session intent; it does not authorize bypassing managed policy. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] [DebuggerDisplay("{Value,nq}")] @@ -35931,7 +35958,7 @@ public SandboxConfigSource(string value) /// The user's persisted settings disabled the sandbox. public static SandboxConfigSource UserDisabled { get; } = new("user_disabled"); - /// A command-line flag selected the sandbox state for this session. + /// An explicit session-scoped choice selected the sandbox state, such as a command-line flag. public static SandboxConfigSource SessionFlag { get; } = new("session_flag"); /// The user disabled the sandbox for the current session. @@ -41932,6 +41959,20 @@ public async Task DisableForSessionAsync(string var request = new SandboxDisableForSessionRequest { SessionId = _session.SessionId, RequestId = requestId, DecisionContext = decisionContext }; return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.sandbox.disableForSession", [request], cancellationToken); } + + /// Adds the path offered by a pending sandbox escalation permission request's sandboxPathGrant to the session's sandbox policy and approves the request, so the blocked operation re-runs inside the sandbox rather than outside it. The request is rejected unless the exact request is still pending, carries a sandboxPathGrant, and the grant still takes effect under the current managed policy. Does not persist the path; hosts that store sandbox settings save it themselves. + /// Identifier of the exact pending sandbox escalation permission request whose sandboxPathGrant to accept. + /// Optional attribution for the permission decision. + /// The to monitor for cancellation requests. The default is . + /// Result of accepting a sandbox path grant. + public async Task GrantPathForRequestAsync(string requestId, PermissionDecisionContext? decisionContext = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(requestId); + _session.ThrowIfDisposed(); + + var request = new SandboxGrantPathForRequestRequest { SessionId = _session.SessionId, RequestId = requestId, DecisionContext = decisionContext }; + return await CopilotClient.InvokeRpcAsync(_session.Rpc, "session.sandbox.grantPathForRequest", [request], cancellationToken); + } } /// Provides session-scoped GitHubAuth APIs. @@ -47398,6 +47439,8 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(GitHub.Copilot.PermissionRequestedEvent), TypeInfoPropertyName = "SessionEventsPermissionRequestedEvent")] [JsonSerializable(typeof(GitHub.Copilot.PermissionResult), TypeInfoPropertyName = "SessionEventsPermissionResult")] [JsonSerializable(typeof(GitHub.Copilot.PermissionRule), TypeInfoPropertyName = "SessionEventsPermissionRule")] +[JsonSerializable(typeof(GitHub.Copilot.PermissionSandboxPathGrant), TypeInfoPropertyName = "SessionEventsPermissionSandboxPathGrant")] +[JsonSerializable(typeof(GitHub.Copilot.PermissionSandboxPathGrantAccess), TypeInfoPropertyName = "SessionEventsPermissionSandboxPathGrantAccess")] [JsonSerializable(typeof(GitHub.Copilot.PersistedBinaryImage), TypeInfoPropertyName = "SessionEventsPersistedBinaryImage")] [JsonSerializable(typeof(GitHub.Copilot.PersistedBinaryImageType), TypeInfoPropertyName = "SessionEventsPersistedBinaryImageType")] [JsonSerializable(typeof(GitHub.Copilot.PersistedBinaryResult), TypeInfoPropertyName = "SessionEventsPersistedBinaryResult")] @@ -48179,6 +48222,8 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(SandboxDisableForSessionRequest))] [JsonSerializable(typeof(SandboxDisableForSessionResult))] [JsonSerializable(typeof(SandboxEnforcementStatus))] +[JsonSerializable(typeof(SandboxGrantPathForRequestRequest))] +[JsonSerializable(typeof(SandboxGrantPathForRequestResult))] [JsonSerializable(typeof(SandboxHostCapability))] [JsonSerializable(typeof(SandboxHostSupport))] [JsonSerializable(typeof(ScheduleAddAtRequest))] diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 4a16e65a13..b2d447cdb3 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -6663,11 +6663,21 @@ public sealed partial class SessionMcpServersLoadedData /// Payload of `session.mcp_server_status_changed` for one MCP server's status and optional failure error. public sealed partial class SessionMcpServerStatusChangedData { + /// Runtime configuration provenance for a failed connection, or unknown when unavailable. Additional string values may be introduced. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("configSource")] + public string? ConfigSource { get; set; } + /// Error message if the server entered a failed state. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("error")] public string? Error { get; set; } + /// Runtime-produced classification for the final failed connection; unclassified means no classification was supplied. Additional string values may be introduced. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("errorClassification")] + public string? ErrorClassification { get; set; } + /// Name of the MCP server whose status changed. [JsonPropertyName("serverName")] public required string ServerName { get; set; } @@ -10315,6 +10325,30 @@ public sealed partial class PermissionRequestShellPossibleUrl public required string Url { get; set; } } +/// A sandbox filesystem policy edit that would let a blocked operation run inside the sandbox instead of outside it. Offered only on a sandbox escalation request whose denial adding this path lifts, and only when managed policy permits the grant. A host accepts it with session.sandbox.grantPathForRequest, which adds the path to the session's sandbox policy and re-runs the operation sandboxed; a host that persists sandbox settings may also save the path there. +/// Nested data type for PermissionSandboxPathGrant. +[Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] +public sealed partial class PermissionSandboxPathGrant +{ + /// Which access the grant confers, and so which policy list the path is added to. + [JsonPropertyName("access")] + public required PermissionSandboxPathGrantAccess Access { get; set; } + + /// The path the sandbox refused, present only when it differs from path. That happens when a write under a read-only folder moves the folder to the read-write paths, when a path that does not exist yet is granted through its nearest existing folder, because the OS sandbox cannot grant a path before it exists, and when either is spelled through a symlink, because a grant covers its path as written, so path is then the resolved location. Hosts should then name path in the offer, since the denial names this one. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("deniedPath")] + public string? DeniedPath { get; set; } + + /// Absolute path to add to the sandbox filesystem policy. + [JsonPropertyName("path")] + public required string Path { get; set; } + + /// readonlyPaths entries the grant removes, exactly as written in the policy, because a read-only entry for the same location would otherwise keep the path read-only. A host that persists the path must remove these entries from its stored readonlyPaths too. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("removedReadonlyPaths")] + public string[]? RemovedReadonlyPaths { get; set; } +} + /// Shell command permission request. /// The shell variant of . public sealed partial class PermissionRequestShell : PermissionRequest @@ -10392,6 +10426,12 @@ public override bool? ManagedApprovalRequired [JsonPropertyName("resolvedWorkingDirectory")] public string? ResolvedWorkingDirectory { get; set; } + /// Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("sandboxPathGrant")] + public PermissionSandboxPathGrant? SandboxPathGrant { get; set; } + /// Tool call ID that triggered this permission request. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("toolCallId")] @@ -10457,6 +10497,12 @@ public override bool? ManagedApprovalRequired [JsonPropertyName("resolvedPath")] public string? ResolvedPath { get; set; } + /// Sandbox policy edit that would let the write run inside the sandbox. Only present when requestSandboxBypass is true. + [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("sandboxPathGrant")] + public PermissionSandboxPathGrant? SandboxPathGrant { get; set; } + /// Tool call ID that triggered this permission request. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("toolCallId")] @@ -10504,6 +10550,12 @@ public override bool? ManagedApprovalRequired [JsonPropertyName("resolvedPath")] public string? ResolvedPath { get; set; } + /// Sandbox policy edit that would let the read run inside the sandbox. Only present when requestSandboxBypass is true. + [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("sandboxPathGrant")] + public PermissionSandboxPathGrant? SandboxPathGrant { get; set; } + /// Tool call ID that triggered this permission request. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("toolCallId")] @@ -11016,6 +11068,12 @@ public sealed partial class PermissionPromptRequestCommands : PermissionPromptRe [JsonPropertyName("requestSandboxPermissive")] public bool? RequestSandboxPermissive { get; set; } + /// Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("sandboxPathGrant")] + public PermissionSandboxPathGrant? SandboxPathGrant { get; set; } + /// Tool call ID that triggered this permission request. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("toolCallId")] @@ -18043,6 +18101,67 @@ public override void Write(Utf8JsonWriter writer, SystemNotificationWorkflowComp } } +/// Access a sandbox path grant confers. +[JsonConverter(typeof(Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PermissionSandboxPathGrantAccess : IEquatable +{ + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The value to associate with this . + [JsonConstructor] + public PermissionSandboxPathGrantAccess(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + _value = value; + } + + /// Gets the value associated with this . + public string Value => _value ?? string.Empty; + + /// Read access: the path is added to readonlyPaths. + public static PermissionSandboxPathGrantAccess Read { get; } = new("read"); + + /// Read and write access: the path is added to readwritePaths. + public static PermissionSandboxPathGrantAccess ReadWrite { get; } = new("readWrite"); + + /// Returns a value indicating whether two instances are equivalent. + public static bool operator ==(PermissionSandboxPathGrantAccess left, PermissionSandboxPathGrantAccess right) => left.Equals(right); + + /// Returns a value indicating whether two instances are not equivalent. + public static bool operator !=(PermissionSandboxPathGrantAccess left, PermissionSandboxPathGrantAccess right) => !(left == right); + + /// + public override bool Equals(object? obj) => obj is PermissionSandboxPathGrantAccess other && Equals(other); + + /// + public bool Equals(PermissionSandboxPathGrantAccess other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PermissionSandboxPathGrantAccess Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert)); + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionSandboxPathGrantAccess value, JsonSerializerOptions options) + { + GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(PermissionSandboxPathGrantAccess)); + } + } +} + /// Advisory recommendation the runtime attaches to a permission request whose origin it can vouch for by construction. Unlike the auto-approval judge this does not depend on auto mode and does not evaluate what the tool call does; its absence simply means the runtime has no opinion and the request follows the host's normal approval flow. [Experimental(global::GitHub.Copilot.Diagnostics.Experimental)] [JsonConverter(typeof(Converter))] @@ -20535,6 +20654,7 @@ public override void Write(Utf8JsonWriter writer, ExtensionsLoadedExtensionStatu [JsonSerializable(typeof(PermissionResultDeniedInteractivelyByUser))] [JsonSerializable(typeof(PermissionResultDeniedNoApprovalRuleAndCouldNotRequestFromUser))] [JsonSerializable(typeof(PermissionRule))] +[JsonSerializable(typeof(PermissionSandboxPathGrant))] [JsonSerializable(typeof(PersistedBinaryImage))] [JsonSerializable(typeof(PersistedBinaryResult))] [JsonSerializable(typeof(PromptCacheBreakData))] diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index 7419ef0e9e..26d4fe297a 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -852,15 +852,22 @@ await SendErrorResponseAsync( { var result = registration.Handler.DynamicInvoke(invokeArgs); - // Handlers return one of: a synchronous value, Task (void async), or ValueTask. + // Handlers return a synchronous value, Task, ValueTask, or ValueTask. if (result is Task task) { // Task handlers are not supported — use ValueTask for results. - Debug.Assert(!task.GetType().IsGenericType, "Task handlers are not supported; use ValueTask."); + // An async Task method can return a generic runtime state-machine box. + Debug.Assert(registration.Handler.Method.ReturnType == typeof(Task), "Task handlers are not supported; use ValueTask."); await task.ConfigureAwait(false); return null; } + if (result is ValueTask valueTask) + { + await valueTask.ConfigureAwait(false); + return null; + } + if (result is not null && registration.ValueTaskAsTaskMethod is { } valueTaskAsTaskMethod) { var asTask = (Task)valueTaskAsTaskMethod.Invoke(result, null)!; diff --git a/dotnet/test/Harness/E2ETestBase.cs b/dotnet/test/Harness/E2ETestBase.cs index 852e6a694a..a9b18bf6cf 100644 --- a/dotnet/test/Harness/E2ETestBase.cs +++ b/dotnet/test/Harness/E2ETestBase.cs @@ -202,7 +202,7 @@ protected static Dictionary CreateTestMcpServers(params }); } - protected static string FindTestHarnessDir() + protected internal static string FindTestHarnessDir() { var relativePath = Path.Join("test", "harness", "test-mcp-server.mjs"); var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/dotnet/test/Unit/CanvasHandlerLifetimeTests.cs b/dotnet/test/Unit/CanvasHandlerLifetimeTests.cs new file mode 100644 index 0000000000..a7b014cb08 --- /dev/null +++ b/dotnet/test/Unit/CanvasHandlerLifetimeTests.cs @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using GitHub.Copilot.Rpc; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public sealed partial class ClientSessionLifetimeTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CanvasClose_Awaits_Handler_Completion_And_Propagates_Errors(bool fail) + { + await using var server = await FakeCopilotServer.StartAsync(); + server.ResponseFactory = _ => new Dictionary(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url) + }); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + CanvasHandler = new CloseCallbackCanvasHandler(async token => + { + started.SetResult(); + await release.Task.WaitAsync(token); + if (fail) + { + throw new InvalidOperationException("close handler failed"); + } + }) + }); + var close = server.SendRequestAsync("canvas.close", CanvasCloseParams(session)); + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + // The ping response fences dispatch of the earlier canvas callback. + await client.PingAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(close.IsCompleted); + release.SetResult(); + + if (fail) + { + var error = await Assert.ThrowsAsync(() => + close.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Contains("close handler failed", error.Message); + } + else + { + await close.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + finally + { + release.TrySetResult(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CanvasClose_Cancels_Handler_When_Connection_Closes(bool disposeClient) + { + await using var server = await FakeCopilotServer.StartAsync(); + server.ResponseFactory = _ => new Dictionary(); + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForUri(server.Url) + }); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + CanvasHandler = new CloseCallbackCanvasHandler(async token => + { + using var registration = token.Register(() => cancelled.TrySetResult()); + started.SetResult(token); + await release.Task; + }) + }); + var close = server.SendRequestAsync("canvas.close", CanvasCloseParams(session)); + _ = close.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + try + { + var token = await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await client.PingAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(token.IsCancellationRequested); + + if (disposeClient) + { + await client.DisposeAsync(); + } + else + { + server.CloseConnection(); + } + + await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(token.IsCancellationRequested); + } + finally + { + release.TrySetResult(); + } + } + + private static Dictionary CanvasCloseParams(CopilotSession session) => new() + { + ["sessionId"] = session.SessionId, + ["canvasId"] = "test-canvas", + ["instanceId"] = "test-instance" + }; + + private sealed class CloseCallbackCanvasHandler(Func close) : CanvasHandlerBase + { + public override Task OnOpenAsync( + CanvasProviderOpenRequest context, CancellationToken cancellationToken) => + Task.FromResult(new CanvasProviderOpenResult { Status = "ready" }); + + public override Task OnCloseAsync( + CanvasProviderCloseRequest context, CancellationToken cancellationToken) => + close(cancellationToken); + } +} +#endif diff --git a/dotnet/test/Unit/StdioShutdownTests.cs b/dotnet/test/Unit/StdioShutdownTests.cs new file mode 100644 index 0000000000..595e5bbb95 --- /dev/null +++ b/dotnet/test/Unit/StdioShutdownTests.cs @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using System.Diagnostics; +using GitHub.Copilot.Test.Harness; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public sealed class StdioShutdownTests +{ + [Theory] + [InlineData("stop")] + [InlineData("dispose")] + [InlineData("force")] + [InlineData("fallback")] + [InlineData("start-failure")] + public async Task Owned_Stdio_Runtime_Finishes_Host_Cleanup_Before_Graceful_Stop_Returns(string operation) + { + var directory = Path.Combine(Path.GetTempPath(), $"copilot-shutdown-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var script = Path.Combine(E2ETestBase.FindTestHarnessDir(), "stdio-shutdown-runtime.cjs"); + var marker = Path.Combine(directory, "telemetry.jsonl"); + var pidPath = Path.Combine(directory, "runtime.pid"); + + try + { + await using var client = new CopilotClient(new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(path: "node", args: [script, marker, operation, pidPath]), + UseLoggedInUser = false, + }); + if (operation == "start-failure") + { + var error = await Assert.ThrowsAsync(() => + client.StartAsync().WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Contains("protocol version mismatch", error.Message); + var pid = int.Parse(await File.ReadAllTextAsync(pidPath), System.Globalization.CultureInfo.InvariantCulture); + Assert.Throws(() => Process.GetProcessById(pid)); + Assert.False(File.Exists(marker)); + return; + } + + await client.StartAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using var process = Process.GetProcessById( + int.Parse(await File.ReadAllTextAsync(pidPath), System.Globalization.CultureInfo.InvariantCulture)); + var elapsed = Stopwatch.StartNew(); + + switch (operation) + { + case "stop": + await client.StopAsync().WaitAsync(TimeSpan.FromSeconds(5)); + break; + case "dispose": + await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + break; + case "fallback": + // Allow shutdown RPC, graceful exit, kill/reap, and stderr drain their separate budgets. + await client.StopAsync().WaitAsync(TimeSpan.FromSeconds(40)); + Assert.True(elapsed.Elapsed >= TimeSpan.FromSeconds(10), + "Graceful stop must wait for its exit timeout before terminating the child."); + break; + default: + await client.ForceStopAsync().WaitAsync(TimeSpan.FromSeconds(5)); + break; + } + + Assert.True(process.HasExited); + await client.DisposeAsync(); + await client.DisposeAsync(); + if (operation == "force") + { + Assert.False(File.Exists(marker)); + } + else + { + Assert.Equal("{\"type\":\"span\"}\n", await File.ReadAllTextAsync(marker)); + } + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} +#endif diff --git a/go/README.md b/go/README.md index f301ed9d19..d7545a9573 100644 --- a/go/README.md +++ b/go/README.md @@ -244,7 +244,7 @@ Implemented with pure-Go FFI (via [purego](https://github.com/ebitengine/purego) - `NewClient(options *ClientOptions) *Client` - Create a new client - `Start(ctx context.Context) error` - Start the CLI server -- `Stop() error` - Stop the CLI server +- `Stop() error` - Gracefully stop the CLI server. For an owned stdio process, requests runtime shutdown, closes stdin, and waits up to 10 seconds for host cleanup (including telemetry) and natural exit before falling back to a forced termination. - `ForceStop()` - Forcefully stop without graceful cleanup - `CreateSession(ctx context.Context, config *SessionConfig) (*Session, error)` - Create a new session - `ResumeSession(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume an existing session diff --git a/go/client.go b/go/client.go index 52443f09aa..3fcc40c80b 100644 --- a/go/client.go +++ b/go/client.go @@ -34,6 +34,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net" "net/netip" @@ -148,6 +149,7 @@ func validateEnvironmentOptions(connection RuntimeConnection, opts *ClientOption type Client struct { options ClientOptions process *exec.Cmd + processStdin io.WriteCloser client *jsonrpc2.Client actualPort int actualHost string @@ -570,8 +572,8 @@ func (c *Client) Start(ctx context.Context) error { // This method performs graceful cleanup: // 1. Closes all active sessions (releases in-memory resources) // 2. Requests runtime shutdown for SDK-owned CLI processes -// 3. Closes the JSON-RPC connection -// 4. Terminates the CLI server process (if spawned by this client) +// 3. Closes owned stdio input and waits up to 10 seconds for host cleanup and exit +// 4. Terminates any remaining owned CLI process and closes the JSON-RPC connection // // Note: session data on disk is preserved, so sessions can be resumed later. // To permanently remove session data before stopping, call [Client.DeleteSession] @@ -634,11 +636,24 @@ func (c *Client) Stop() error { } } - // The runtime completes all cleanup before responding to runtime.shutdown - // and then leaves termination to us; it deliberately keeps its JSON-RPC - // server alive to send the response and never self-exits. Waiting for a - // self-exit that will never come just wastes time, so terminate the child - // immediately and only wait to reap it. + // The stdio host finalizes telemetry after EOF, not after runtime.shutdown. + // Keep stdout open while allowing the child to finish that cleanup naturally. + if c.process != nil && !c.isExternalServer && c.processStdin != nil { + processExitStart := time.Now() + if err := c.processStdin.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + errs = append(errs, fmt.Errorf("failed to close CLI stdin: %w", err)) + } + c.processStdin = nil + select { + case <-c.processDone: + c.logDebugTiming(processExitStart, "CopilotClient.Stop CLI process exited gracefully") + c.osProcess.Store(nil) + c.process = nil + case <-time.After(processExitTimeout): + c.logDebugTiming(processExitStart, "CopilotClient.Stop CLI process exit timed out; killing process") + } + } + if c.process != nil && !c.isExternalServer { if err := c.killProcessAndWait(); err != nil { errs = append(errs, err) @@ -2214,6 +2229,7 @@ func (c *Client) startCLIServer(ctx context.Context) error { return fmt.Errorf("failed to start CLI server: %w", err) } + c.processStdin = stdin c.monitorProcess() // Create JSON-RPC client immediately @@ -2396,6 +2412,10 @@ func (c *Client) killProcess() error { return fmt.Errorf("failed to kill CLI process: %w", err) } } + if c.processStdin != nil { + _ = c.processStdin.Close() + c.processStdin = nil + } c.process = nil return nil } diff --git a/go/client_shutdown_test.go b/go/client_shutdown_test.go new file mode 100644 index 0000000000..6bcef7c2ef --- /dev/null +++ b/go/client_shutdown_test.go @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +package copilot_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" +) + +func TestOwnedStdioShutdown(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("shutdown fixture requires Node.js:", err) + } + fixture, err := filepath.Abs("../test/harness/stdio-shutdown-runtime.cjs") + if err != nil { + t.Fatal(err) + } + + for _, mode := range []string{"stop", "force", "fallback", "start-failure"} { + t.Run(mode, func(t *testing.T) { + directory := t.TempDir() + marker := filepath.Join(directory, "telemetry.jsonl") + pidFile := filepath.Join(directory, "runtime.pid") + client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{ + Path: node, + Args: []string{fixture, marker, mode, pidFile}, + }, + UseLoggedInUser: copilot.Bool(false), + }) + t.Cleanup(client.ForceStop) + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + err := client.Start(ctx) + if mode == "start-failure" { + if err == nil || !strings.Contains(err.Error(), "protocol version") { + t.Fatalf("expected protocol version failure, got %v", err) + } + } else { + if err != nil { + t.Fatal("Start failed:", err) + } + started := time.Now() + if mode == "force" { + runShutdownWithWatchdog(t, func() error { + client.ForceStop() + return nil + }) + if elapsed := time.Since(started); elapsed >= 10*time.Second { + t.Fatalf("ForceStop waited for graceful timeout: %s", elapsed) + } + } else { + runShutdownWithWatchdog(t, client.Stop) + } + if mode == "fallback" && time.Since(started) < 10*time.Second { + t.Fatal("Stop did not allow the full graceful exit timeout") + } + } + + // Force-stop and failed startup kill without waiting for the child to be reaped. + exitWait := "0" + if mode == "force" || mode == "start-failure" { + exitWait = "5000" + } + assertShutdownChildExited(t, node, pidFile, exitWait) + contents, err := os.ReadFile(marker) + if mode == "force" || mode == "start-failure" { + if !os.IsNotExist(err) { + t.Fatalf("forced termination unexpectedly finalized telemetry: %q (error: %v)", contents, err) + } + } else if err != nil || string(contents) != "{\"type\":\"span\"}\n" { + t.Fatalf("Stop returned without EOF cleanup: %q (error: %v)", contents, err) + } + runShutdownWithWatchdog(t, client.Stop) + }) + } +} + +func runShutdownWithWatchdog(t *testing.T, stop func() error) { + t.Helper() + done := make(chan error, 1) + go func() { done <- stop() }() + select { + case err := <-done: + if err != nil { + t.Fatal("shutdown failed:", err) + } + case <-time.After(40 * time.Second): + // Cover shutdown RPC, graceful exit, and forced reap budgets, plus scheduling slack. + t.Fatal("shutdown exceeded all cleanup budgets") + } +} + +func assertShutdownChildExited(t *testing.T, node, pidFile, waitMillis string) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + // Node's process probe is portable, unlike os.Process.Signal(0) on Windows. + cmd := exec.CommandContext(ctx, node, "-e", ` +const fs = require("node:fs"); +const pid = Number(fs.readFileSync(process.argv[1], "utf8")); +const deadline = Date.now() + Number(process.argv[2]); +function check() { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === "ESRCH") return; + throw error; + } + if (Date.now() >= deadline) throw new Error("Child still running"); + setTimeout(check, 25); +} +check(); +`, pidFile, waitMillis) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("child process did not exit: %v\n%s", err, output) + } +} diff --git a/go/rpc/zrpc.go b/go/rpc/zrpc.go index 0926597716..7c8526d49e 100644 --- a/go/rpc/zrpc.go +++ b/go/rpc/zrpc.go @@ -12730,6 +12730,27 @@ type SandboxEnforcementStatus struct { Required bool `json:"required"` } +// Request to accept the sandbox path grant offered on an active sandbox escalation +// permission prompt. +// Experimental: SandboxGrantPathForRequestRequest is part of an experimental API and may +// change or be removed. +type SandboxGrantPathForRequestRequest struct { + // Optional attribution for the permission decision. + DecisionContext *PermissionDecisionContext `json:"decisionContext,omitempty"` + // Identifier of the exact pending sandbox escalation permission request whose + // sandboxPathGrant to accept. + RequestID string `json:"requestId"` +} + +// Result of accepting a sandbox path grant. +// Experimental: SandboxGrantPathForRequestResult is part of an experimental API and may +// change or be removed. +type SandboxGrantPathForRequestResult struct { + // Whether this call resolved the pending request and added the path to the session's + // sandbox policy. + Success bool `json:"success"` +} + // Whether this host can run one sandbox policy feature. A session whose effective policy // uses an unsupported feature fails each sandboxed command with `reason`. // Experimental: SandboxHostCapability is part of an experimental API and may change or be @@ -14588,6 +14609,8 @@ type SessionOpenOptions struct { // user_disabled, repository_policy) let managed policy floor a host preference; explicit // below-floor changes remain policy conflicts unless a session opt-out is authorized. Also // used for telemetry provenance. + // Experimental: SandboxConfigSource is part of an experimental API and may change or be + // removed. SandboxConfigSource *SandboxConfigSource `json:"sandboxConfigSource,omitempty"` // Capabilities enabled for this session. SessionCapabilities []SessionCapability `json:"sessionCapabilities,omitzero"` @@ -15840,6 +15863,8 @@ type SessionUpdateOptionsParams struct { // user_disabled, repository_policy) let managed policy floor a host preference; explicit // below-floor changes remain policy conflicts unless a session opt-out is authorized. Also // used for telemetry provenance. + // Experimental: SandboxConfigSource is part of an experimental API and may change or be + // removed. SandboxConfigSource *SandboxConfigSource `json:"sandboxConfigSource,omitempty"` // Replaces the session's capability set with the given list. Use to enable or disable // capabilities mid-session (e.g., remove `memory` for reproducible scripted runs). Omit the @@ -23664,9 +23689,8 @@ const ( ResponseFormatTypeJSONSchema ResponseFormatType = "json_schema" ) -// Origin of the sandbox choice supplied by the host. Settings-derived origins let managed -// policy floor the host preference; do not tag explicit session overrides as -// settings-derived. +// Origin of the sandbox choice supplied by the host. This value describes preference or +// session intent; it does not authorize bypassing managed policy. // Experimental: SandboxConfigSource is part of an experimental API and may change or be // removed. type SandboxConfigSource string @@ -23678,7 +23702,7 @@ const ( SandboxConfigSourceRepositoryPolicy SandboxConfigSource = "repository_policy" // The user disabled the sandbox for the current session. SandboxConfigSourceSessionDisabled SandboxConfigSource = "session_disabled" - // A command-line flag selected the sandbox state for this session. + // An explicit session-scoped choice selected the sandbox state, such as a command-line flag. SandboxConfigSourceSessionFlag SandboxConfigSource = "session_flag" // The client disabled the sandbox because the host cannot enforce it. SandboxConfigSourceUnsupportedHost SandboxConfigSource = "unsupported_host" @@ -32528,6 +32552,38 @@ func (a *SandboxAPI) GetEnforcementStatus(ctx context.Context) (*SandboxEnforcem return &result, nil } +// GrantPathForRequest adds the path offered by a pending sandbox escalation permission +// request's sandboxPathGrant to the session's sandbox policy and approves the request, so +// the blocked operation re-runs inside the sandbox rather than outside it. The request is +// rejected unless the exact request is still pending, carries a sandboxPathGrant, and the +// grant still takes effect under the current managed policy. Does not persist the path; +// hosts that store sandbox settings save it themselves. +// +// RPC method: session.sandbox.grantPathForRequest. +// +// Parameters: Request to accept the sandbox path grant offered on an active sandbox +// escalation permission prompt. +// +// Returns: Result of accepting a sandbox path grant. +func (a *SandboxAPI) GrantPathForRequest(ctx context.Context, params *SandboxGrantPathForRequestRequest) (*SandboxGrantPathForRequestResult, error) { + req := map[string]any{"sessionId": a.sessionID} + if params != nil { + if params.DecisionContext != nil { + req["decisionContext"] = *params.DecisionContext + } + req["requestId"] = params.RequestID + } + raw, err := a.client.Request(ctx, "session.sandbox.grantPathForRequest", req) + if err != nil { + return nil, err + } + var result SandboxGrantPathForRequestResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + // Experimental: ScheduleAPI contains experimental APIs that may change or be removed. type ScheduleAPI sessionAPI diff --git a/go/rpc/zsession_events.go b/go/rpc/zsession_events.go index dad3151d74..cabd4cee81 100644 --- a/go/rpc/zsession_events.go +++ b/go/rpc/zsession_events.go @@ -2178,8 +2178,12 @@ func (*SessionMCPServerRemovedData) Type() SessionEventType { // Payload of `session.mcp_server_status_changed` for one MCP server's status and optional failure error. type SessionMCPServerStatusChangedData struct { + // Runtime configuration provenance for a failed connection, or unknown when unavailable. Additional string values may be introduced. + ConfigSource *string `json:"configSource,omitempty"` // Error message if the server entered a failed state Error *string `json:"error,omitempty"` + // Runtime-produced classification for the final failed connection; unclassified means no classification was supplied. Additional string values may be introduced. + ErrorClassification *string `json:"errorClassification,omitempty"` // Name of the MCP server whose status changed ServerName string `json:"serverName"` // Connection status: connected, failed, needs-auth, pending, disabled, stopped, or not_configured @@ -4012,6 +4016,9 @@ type PermissionPromptRequestCommands struct { RequestSandboxBypassReason *string `json:"requestSandboxBypassReason,omitempty"` // True when the escalation is a permissive retry that keeps the sandbox and network policy attached while recording file and process accesses instead of blocking them. RequestSandboxPermissive *bool `json:"requestSandboxPermissive,omitempty"` + // Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + // Experimental: SandboxPathGrant is part of an experimental API and may change or be removed. + SandboxPathGrant *PermissionSandboxPathGrant `json:"sandboxPathGrant,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` // Optional warning message about risks of running this command @@ -4491,6 +4498,9 @@ type PermissionRequestRead struct { // Runtime-resolved canonical path used for authorization identity checks. Internal and experimental; clients should continue to display path. // Experimental: ResolvedPath is part of an experimental API and may change or be removed. ResolvedPath *string `json:"resolvedPath,omitempty"` + // Sandbox policy edit that would let the read run inside the sandbox. Only present when requestSandboxBypass is true. + // Experimental: SandboxPathGrant is part of an experimental API and may change or be removed. + SandboxPathGrant *PermissionSandboxPathGrant `json:"sandboxPathGrant,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` } @@ -4532,6 +4542,9 @@ type PermissionRequestShell struct { // Runtime-resolved canonical working directory the command runs in, used for authorization identity checks. Internal and experimental; clients should not display it. // Experimental: ResolvedWorkingDirectory is part of an experimental API and may change or be removed. ResolvedWorkingDirectory *string `json:"resolvedWorkingDirectory,omitempty"` + // Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + // Experimental: SandboxPathGrant is part of an experimental API and may change or be removed. + SandboxPathGrant *PermissionSandboxPathGrant `json:"sandboxPathGrant,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` // Optional warning message about risks of running this command @@ -4628,6 +4641,9 @@ type PermissionRequestWrite struct { // Runtime-resolved canonical path used for authorization identity checks. Internal and experimental; clients should continue to display fileName. // Experimental: ResolvedPath is part of an experimental API and may change or be removed. ResolvedPath *string `json:"resolvedPath,omitempty"` + // Sandbox policy edit that would let the write run inside the sandbox. Only present when requestSandboxBypass is true. + // Experimental: SandboxPathGrant is part of an experimental API and may change or be removed. + SandboxPathGrant *PermissionSandboxPathGrant `json:"sandboxPathGrant,omitempty"` // Tool call ID that triggered this permission request ToolCallID *string `json:"toolCallId,omitempty"` } @@ -4784,6 +4800,19 @@ func (PermissionDeniedNoApprovalRuleAndCouldNotRequestFromUser) Kind() Permissio return PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser } +// A sandbox filesystem policy edit that would let a blocked operation run inside the sandbox instead of outside it. Offered only on a sandbox escalation request whose denial adding this path lifts, and only when managed policy permits the grant. A host accepts it with session.sandbox.grantPathForRequest, which adds the path to the session's sandbox policy and re-runs the operation sandboxed; a host that persists sandbox settings may also save the path there. +// Experimental: PermissionSandboxPathGrant is part of an experimental API and may change or be removed. +type PermissionSandboxPathGrant struct { + // Which access the grant confers, and so which policy list the path is added to + Access PermissionSandboxPathGrantAccess `json:"access"` + // The path the sandbox refused, present only when it differs from path. That happens when a write under a read-only folder moves the folder to the read-write paths, when a path that does not exist yet is granted through its nearest existing folder, because the OS sandbox cannot grant a path before it exists, and when either is spelled through a symlink, because a grant covers its path as written, so path is then the resolved location. Hosts should then name path in the offer, since the denial names this one. + DeniedPath *string `json:"deniedPath,omitempty"` + // Absolute path to add to the sandbox filesystem policy + Path string `json:"path"` + // readonlyPaths entries the grant removes, exactly as written in the policy, because a read-only entry for the same location would otherwise keep the path read-only. A host that persists the path must remove these entries from its stored readonlyPaths too. + RemovedReadonlyPaths []string `json:"removedReadonlyPaths,omitzero"` +} + // A model-facing binary result as persisted: full inline data, a size-omitted marker, or a deduplicated asset reference // Experimental: PersistedBinaryResult is part of an experimental API and may change or be removed. type PersistedBinaryResult interface { @@ -6357,6 +6386,16 @@ const ( PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser PermissionResultKind = "denied-no-approval-rule-and-could-not-request-from-user" ) +// Access a sandbox path grant confers +type PermissionSandboxPathGrantAccess string + +const ( + // Read access: the path is added to readonlyPaths. + PermissionSandboxPathGrantAccessRead PermissionSandboxPathGrantAccess = "read" + // Read and write access: the path is added to readwritePaths. + PermissionSandboxPathGrantAccessReadWrite PermissionSandboxPathGrantAccess = "readWrite" +) + // Binary result type discriminator. Use "image" for images and "resource" for other binary data. type PersistedBinaryImageType string diff --git a/go/zsession_events.go b/go/zsession_events.go index 5fce4a01db..10cc781d06 100644 --- a/go/zsession_events.go +++ b/go/zsession_events.go @@ -260,6 +260,8 @@ type ( PermissionResult = rpc.PermissionResult PermissionResultKind = rpc.PermissionResultKind PermissionRule = rpc.PermissionRule + PermissionSandboxPathGrant = rpc.PermissionSandboxPathGrant + PermissionSandboxPathGrantAccess = rpc.PermissionSandboxPathGrantAccess PersistedBinaryImage = rpc.PersistedBinaryImage PersistedBinaryImageType = rpc.PersistedBinaryImageType PersistedBinaryResult = rpc.PersistedBinaryResult @@ -806,6 +808,8 @@ const ( PermissionResultKindDeniedByRules = rpc.PermissionResultKindDeniedByRules PermissionResultKindDeniedInteractivelyByUser = rpc.PermissionResultKindDeniedInteractivelyByUser PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser = rpc.PermissionResultKindDeniedNoApprovalRuleAndCouldNotRequestFromUser + PermissionSandboxPathGrantAccessRead = rpc.PermissionSandboxPathGrantAccessRead + PermissionSandboxPathGrantAccessReadWrite = rpc.PermissionSandboxPathGrantAccessReadWrite PersistedBinaryImageTypeImage = rpc.PersistedBinaryImageTypeImage PersistedBinaryImageTypeResource = rpc.PersistedBinaryImageTypeResource PersistedBinaryResultTypeImage = rpc.PersistedBinaryResultTypeImage diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/SessionMcpServerStatusChangedEvent.java b/java/sdk/src/generated/java/com/github/copilot/generated/SessionMcpServerStatusChangedEvent.java index b084652db1..a1c411e297 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/SessionMcpServerStatusChangedEvent.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/SessionMcpServerStatusChangedEvent.java @@ -39,7 +39,11 @@ public record SessionMcpServerStatusChangedEventData( /** Connection status: connected, failed, needs-auth, pending, disabled, stopped, or not_configured */ @JsonProperty("status") McpServerStatus status, /** Error message if the server entered a failed state */ - @JsonProperty("error") String error + @JsonProperty("error") String error, + /** Runtime-produced classification for the final failed connection; unclassified means no classification was supplied. Additional string values may be introduced. */ + @JsonProperty("errorClassification") String errorClassification, + /** Runtime configuration provenance for a failed connection, or unknown when unavailable. Additional string values may be introduced. */ + @JsonProperty("configSource") String configSource ) { } } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxConfigSource.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxConfigSource.java index 2e570b83d3..f7d55e6c71 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxConfigSource.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SandboxConfigSource.java @@ -7,13 +7,17 @@ package com.github.copilot.generated.rpc; +import com.github.copilot.CopilotExperimental; import javax.annotation.processing.Generated; /** - * Origin of the sandbox choice supplied by the host. Settings-derived origins let managed policy floor the host preference; do not tag explicit session overrides as settings-derived. + * Origin of the sandbox choice supplied by the host. This value describes preference or session intent; it does not authorize bypassing managed policy. + * + * @apiNote This type is experimental and may change in a future version. * * @since 1.0.0 */ +@CopilotExperimental @javax.annotation.processing.Generated("copilot-sdk-codegen") public enum SandboxConfigSource { /** The {@code never_configured} variant. */ diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java index 02dbbea37a..a13ef559ef 100644 --- a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxApi.java @@ -57,4 +57,20 @@ public CompletableFuture disableForSessio return caller.invoke("session.sandbox.disableForSession", _p, SessionSandboxDisableForSessionResult.class); } + /** + * Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt. + *

+ * Note: the {@code sessionId} field in the params record is overridden + * by the session-scoped wrapper; any value provided is ignored. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ + @CopilotExperimental + public CompletableFuture grantPathForRequest(SessionSandboxGrantPathForRequestParams params) { + com.fasterxml.jackson.databind.node.ObjectNode _p = MAPPER.valueToTree(params); + _p.put("sessionId", this.sessionId); + return caller.invoke("session.sandbox.grantPathForRequest", _p, SessionSandboxGrantPathForRequestResult.class); + } + } diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGrantPathForRequestParams.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGrantPathForRequestParams.java new file mode 100644 index 0000000000..a74dca91ec --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGrantPathForRequestParams.java @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionSandboxGrantPathForRequestParams( + /** Target session identifier */ + @JsonProperty("sessionId") String sessionId, + /** Identifier of the exact pending sandbox escalation permission request whose sandboxPathGrant to accept. */ + @JsonProperty("requestId") String requestId, + /** Optional attribution for the permission decision. */ + @JsonProperty("decisionContext") PermissionDecisionContext decisionContext +) { +} diff --git a/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGrantPathForRequestResult.java b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGrantPathForRequestResult.java new file mode 100644 index 0000000000..95d176ab66 --- /dev/null +++ b/java/sdk/src/generated/java/com/github/copilot/generated/rpc/SessionSandboxGrantPathForRequestResult.java @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package com.github.copilot.generated.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.CopilotExperimental; +import javax.annotation.processing.Generated; + +/** + * Result of accepting a sandbox path grant. + * + * @apiNote This method is experimental and may change in a future version. + * @since 1.0.0 + */ +@CopilotExperimental +@javax.annotation.processing.Generated("copilot-sdk-codegen") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record SessionSandboxGrantPathForRequestResult( + /** Whether this call resolved the pending request and added the path to the session's sandbox policy. */ + @JsonProperty("success") Boolean success +) { +} diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java index 21a1c0fc9e..0783e6871e 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -93,11 +93,12 @@ public final class CopilotClient implements AutoCloseable { private static final Logger LOG = Logger.getLogger(CopilotClient.class.getName()); /** - * Timeout, in seconds, used by {@link #close()} when waiting for graceful - * shutdown via {@link #stop()}. + * Timeout, in seconds, allowed by {@link #close()} for session and executor + * cleanup, in addition to the bounded runtime shutdown phases. */ public static final int AUTOCLOSEABLE_TIMEOUT_SECONDS = 10; private static final int RUNTIME_SHUTDOWN_TIMEOUT_SECONDS = 10; + private static final int PROCESS_EXIT_TIMEOUT_SECONDS = 10; private static final int FORCE_KILL_TIMEOUT_SECONDS = 10; /** @@ -735,8 +736,10 @@ private static boolean isUnsupportedConnectMethod(JsonRpcException ex) { *

    *
  1. Closes all active sessions (releases in-memory resources)
  2. *
  3. Requests runtime shutdown for SDK-owned CLI processes
  4. - *
  5. Closes the JSON-RPC connection
  6. - *
  7. Terminates the CLI server process (if spawned by this client)
  8. + *
  9. Closes stdin for an owned stdio process and waits for its host + * cleanup
  10. + *
  11. Closes the JSON-RPC connection, terminating an owned process if + * needed
  12. *
*

* Note: session data on disk is preserved, so sessions can be resumed later. To @@ -828,7 +831,10 @@ private CompletableFuture cleanupConnection(boolean gracefulRuntimeShutdow }); } - return shutdownFuture.handle((ignored, error) -> { + return shutdownFuture.handleAsync((ignored, error) -> { + if (gracefulRuntimeShutdown && connection.process != null && options.isUseStdio()) { + awaitStdioProcessExit(connection.process); + } try { connection.rpc.close(); } catch (Exception e) { @@ -842,10 +848,26 @@ private CompletableFuture cleanupConnection(boolean gracefulRuntimeShutdow closeRuntimeHost(connection.runtimeHost); } return (Void) null; - }); + }, SHUTDOWN_DISPATCHER); }).thenCompose(result -> result); } + private static void awaitStdioProcessExit(Process process) { + try { + // Host telemetry flushes after stdio EOF, not the shutdown RPC response. + // Keep the reader draining stdout until the child has finished. + process.getOutputStream().close(); + if (!process.waitFor(PROCESS_EXIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + LOG.fine("Process did not exit after stdin EOF within graceful shutdown timeout; terminating"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.log(Level.FINE, "Interrupted while waiting for process exit", e); + } catch (IOException e) { + LOG.log(Level.FINE, "Error closing process stdin", e); + } + } + /** * Returns true only when the child had already exited and no streams were * destroyed. @@ -853,12 +875,6 @@ private CompletableFuture cleanupConnection(boolean gracefulRuntimeShutdow private static boolean cleanupCliProcess(Process process, boolean forceImmediately) { try { if (process.isAlive()) { - // The runtime completes all cleanup before responding to - // runtime.shutdown and then leaves termination to us; it - // deliberately keeps its JSON-RPC server alive to send the - // response and never self-exits. Waiting for a self-exit that - // will never come just wastes time, so terminate the child - // immediately and only wait to reap it. if (forceImmediately) { process.destroyForcibly(); if (!process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { @@ -1793,9 +1809,11 @@ private CompletableFuture ensureConnected() { * Closes this client using graceful shutdown semantics. *

* This method is intended for {@code try-with-resources} usage and blocks while - * waiting for {@link #stop()} to complete, up to - * {@link #AUTOCLOSEABLE_TIMEOUT_SECONDS} seconds. If shutdown fails or times - * out, the error is logged at {@link Level#FINE} and the method returns. + * waiting for {@link #stop()} to complete. The timeout includes the bounded + * runtime shutdown, natural exit, termination and kill phases, plus + * {@link #AUTOCLOSEABLE_TIMEOUT_SECONDS} for session cleanup. If shutdown fails + * or times out, the error is logged at {@link Level#FINE} and the method + * returns. *

* This method is idempotent. * @@ -1809,7 +1827,8 @@ public void close() { return; disposed = true; try { - stop().get(AUTOCLOSEABLE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + stop().get(AUTOCLOSEABLE_TIMEOUT_SECONDS + RUNTIME_SHUTDOWN_TIMEOUT_SECONDS + PROCESS_EXIT_TIMEOUT_SECONDS + + 2 * FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (Exception e) { LOG.log(Level.FINE, "Error during close", e); } finally { diff --git a/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java b/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java index 7101f56d3b..574f057467 100644 --- a/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java +++ b/java/sdk/src/test/java/com/github/copilot/CopilotClientTest.java @@ -18,6 +18,7 @@ import com.github.copilot.rpc.SessionLifecycleEventTypes; import com.github.copilot.rpc.ToolDefinition; +import java.io.OutputStream; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; @@ -50,31 +51,6 @@ static void setup() { cliPath = TestUtil.findCliPath(); } - @Test - void testStopRequestsRuntimeShutdownForOwnedProcess() throws Exception { - var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false)); - var rpc = mock(JsonRpcClient.class); - when(rpc.invoke(eq("runtime.shutdown"), any(), eq(Void.class))) - .thenReturn(CompletableFuture.completedFuture(null)); - var process = mock(Process.class); - when(process.isAlive()).thenReturn(true); - when(process.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true); - - setConnectionFuture(client, rpc, process); - - client.stop().get(); - - verify(rpc).invoke(eq("runtime.shutdown"), eq(Map.of()), eq(Void.class)); - verify(rpc).close(); - // The runtime never self-exits after runtime.shutdown (it keeps its - // JSON-RPC server alive to send the response and leaves termination to - // the caller), so stop() terminates the owned process. The mocked - // process exits on the first SIGTERM (waitFor returns true), so we - // never escalate to destroyForcibly(). - verify(process).destroy(); - verify(process, never()).destroyForcibly(); - } - @Test void testStopDoesNotThrowWhenRuntimeShutdownFails() throws Exception { var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false)); @@ -83,6 +59,7 @@ void testStopDoesNotThrowWhenRuntimeShutdownFails() throws Exception { .thenReturn(CompletableFuture.failedFuture(new RuntimeException("shutdown failed"))); var process = mock(Process.class); when(process.isAlive()).thenReturn(true); + when(process.getOutputStream()).thenReturn(OutputStream.nullOutputStream()); when(process.destroyForcibly()).thenReturn(process); when(process.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(true); diff --git a/java/sdk/src/test/java/com/github/copilot/RpcSurfaceParityE2ETest.java b/java/sdk/src/test/java/com/github/copilot/RpcSurfaceParityE2ETest.java index 02f905a682..063a145fb0 100644 --- a/java/sdk/src/test/java/com/github/copilot/RpcSurfaceParityE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/RpcSurfaceParityE2ETest.java @@ -41,8 +41,8 @@ class RpcSurfaceParityE2ETest { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final long TIMEOUT_SECONDS = 30; - private static final int EXPECTED_RPC_METHOD_COUNT = 440; - private static final String EXPECTED_RPC_SIGNATURE_SHA256 = "ba2205f94be05f808734e5f7bd5ba0ce18d7816c68395bff90415fd97574936b"; + private static final int EXPECTED_RPC_METHOD_COUNT = 441; + private static final String EXPECTED_RPC_SIGNATURE_SHA256 = "39b0aced7a065761af54df5e2cc728b218981c68b8b4933b369e56a6eed9259a"; private static final Map EXPECTED_METHODS_BY_DECLARING_TYPE = Map.ofEntries( Map.entry("RpcCaller", 2), Map.entry("ServerAccountApi", 6), Map.entry("ServerAccountsApi", 1), Map.entry("ServerAgentRegistryApi", 1), Map.entry("ServerAgentsApi", 2), Map.entry("ServerCatalogApi", 3), @@ -75,7 +75,7 @@ class RpcSurfaceParityE2ETest { Map.entry("SessionPermissionsUrlsApi", 1), Map.entry("SessionPlanApi", 5), Map.entry("SessionPluginsApi", 8), Map.entry("SessionPluginsMarketplacesApi", 6), Map.entry("SessionProviderApi", 4), Map.entry("SessionQueueApi", 20), Map.entry("SessionRemoteApi", 3), - Map.entry("SessionRpc", 9), Map.entry("SessionSandboxApi", 2), Map.entry("SessionScheduleApi", 9), + Map.entry("SessionRpc", 9), Map.entry("SessionSandboxApi", 3), Map.entry("SessionScheduleApi", 9), Map.entry("SessionSettingsApi", 2), Map.entry("SessionShellApi", 4), Map.entry("SessionSkillsApi", 6), Map.entry("SessionTasksApi", 13), Map.entry("SessionTelemetryApi", 2), Map.entry("SessionToolsApi", 8), Map.entry("SessionUiApi", 10), Map.entry("SessionUsageApi", 1), Map.entry("SessionVisibilityApi", 2), @@ -207,6 +207,7 @@ void omittedNamespaceMethodsUseExactGeneratedEntryPoints() { rpc.remote.notifySteerableChanged(params(SessionRemoteNotifySteerableChangedParams.class, "{}")); rpc.sandbox.getEnforcementStatus(); rpc.sandbox.disableForSession(params(SessionSandboxDisableForSessionParams.class, "{}")); + rpc.sandbox.grantPathForRequest(params(SessionSandboxGrantPathForRequestParams.class, "{}")); rpc.settings.snapshot(); rpc.settings.evaluatePredicate(params(SessionSettingsEvaluatePredicateParams.class, "{}")); rpc.shell.exec(params(SessionShellExecParams.class, "{}")); @@ -282,7 +283,7 @@ void omittedNamespaceMethodsUseExactGeneratedEntryPoints() { rpc.workspaces.saveLargePaste(params(SessionWorkspacesSaveLargePasteParams.class, "{}")); rpc.workspaces.diff(params(SessionWorkspacesDiffParams.class, "{}")); - assertEquals(104, caller.calls().size()); + assertEquals(105, caller.calls().size()); assertTrue(caller.calls().stream().allMatch(call -> call.method().startsWith("session."))); assertTrue(caller.calls().stream().allMatch( call -> "direct-session".equals(MAPPER.valueToTree(call.params()).path("sessionId").asText()))); @@ -291,11 +292,11 @@ void omittedNamespaceMethodsUseExactGeneratedEntryPoints() { assertTrue(methods.containsAll(Set.of("session.permissions.paths.list", "session.permissions.urls.setUnrestrictedMode", "session.plan.readSqlTodosWithDependencies", "session.provider.add", "session.queue.process", "session.remote.notifySteerableChanged", - "session.sandbox.disableForSession", "session.settings.evaluatePredicate", - "session.shell.cancelUserRequested", "session.skills.ensureLoaded", "session.tasks.sendMessage", - "session.telemetry.setFeatureOverrides", "session.tools.updateSubagentSettings", - "session.ui.unregisterDirectAutoModeSwitchHandler", "session.visibility.set", - "session.workspaces.diff"))); + "session.sandbox.disableForSession", "session.sandbox.grantPathForRequest", + "session.settings.evaluatePredicate", "session.shell.cancelUserRequested", + "session.skills.ensureLoaded", "session.tasks.sendMessage", "session.telemetry.setFeatureOverrides", + "session.tools.updateSubagentSettings", "session.ui.unregisterDirectAutoModeSwitchHandler", + "session.visibility.set", "session.workspaces.diff"))); } @Test @@ -608,6 +609,12 @@ void sessionControlRpcsSerializeRequestsAndProjectUnionsAndState() throws Except assertTrue(disabled.success()); assertFalse(disabled.enabled()); + var granted = rpc.sandbox + .grantPathForRequest( + new SessionSandboxGrantPathForRequestParams(null, "sandbox-request-2", null)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertTrue(granted.success()); + assertTrue(rpc.abort(new SessionAbortParams(null, AbortReason.USER_INITIATED)) .get(TIMEOUT_SECONDS, TimeUnit.SECONDS).success()); assertTrue(rpc.interruptMainTurn(new SessionInterruptMainTurnParams(null, true)) @@ -624,8 +631,8 @@ void sessionControlRpcsSerializeRequestsAndProjectUnionsAndState() throws Except "session.debug.collectLogs", "session.history.clearContext", "session.limitPrediction.predict", "session.metadata.getClientMetadata", "session.model.setAllowedModels", "session.model.switchAutoTier", "session.sandbox.getEnforcementStatus", - "session.sandbox.disableForSession", "session.abort", "session.interruptMainTurn", - "session.cancelAllBackgroundAgents", "session.log"); + "session.sandbox.disableForSession", "session.sandbox.grantPathForRequest", "session.abort", + "session.interruptMainTurn", "session.cancelAllBackgroundAgents", "session.log"); assertEquals(session.getSessionId(), parameters(runtime, "session.log").path("sessionId").asText()); assertTrue(parameters(runtime, "session.interruptMainTurn").path("flushQueued").asBoolean()); } @@ -902,6 +909,9 @@ private static JsonNode handle(JsonNode request) { case "session.sandbox.disableForSession" -> json(""" {"success":true,"enabled":false} """); + case "session.sandbox.grantPathForRequest" -> json(""" + {"success":true} + """); case "session.abort" -> json(""" {"success":true,"error":null} """); diff --git a/java/sdk/src/test/java/com/github/copilot/StdioShutdownIT.java b/java/sdk/src/test/java/com/github/copilot/StdioShutdownIT.java new file mode 100644 index 0000000000..ed3200e671 --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/StdioShutdownIT.java @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import com.github.copilot.rpc.CopilotClientOptions; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises owned-process shutdown through the public client API. + */ +@Timeout(value = 90, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) +class StdioShutdownIT { + + @Test + void stopWaitsForCleanupAfterStdinEof() throws Exception { + try (var fixture = new Fixture("stop")) { + fixture.client.start().get(30, TimeUnit.SECONDS); + fixture.client.stop().get(60, TimeUnit.SECONDS); + fixture.assertCleanExit(); + } + } + + @Test + void closeWaitsForCleanupAfterStdinEof() throws Exception { + try (var fixture = new Fixture("dispose")) { + fixture.client.start().get(30, TimeUnit.SECONDS); + fixture.client.close(); + fixture.assertCleanExit(); + } + } + + @Test + void forceStopDoesNotWaitForGracefulCleanup() throws Exception { + try (var fixture = new Fixture("force")) { + fixture.client.start().get(30, TimeUnit.SECONDS); + fixture.client.forceStop().get(5, TimeUnit.SECONDS); + fixture.assertExited(); + assertFalse(Files.exists(fixture.marker)); + } + } + + @Test + void stopTerminatesChildThatDoesNotExitAfterEof() throws Exception { + try (var fixture = new Fixture("fallback")) { + fixture.client.start().get(30, TimeUnit.SECONDS); + long started = System.nanoTime(); + fixture.client.stop().get(60, TimeUnit.SECONDS); + assertTrue(System.nanoTime() - started >= TimeUnit.SECONDS.toNanos(10), + "Stop must allow the full graceful exit timeout before terminating"); + fixture.assertCleanExit(); + } + } + + @Test + void closeTerminatesChildThatDoesNotExitAfterEof() throws Exception { + try (var fixture = new Fixture("fallback")) { + fixture.client.start().get(30, TimeUnit.SECONDS); + long started = System.nanoTime(); + fixture.client.close(); + assertTrue(System.nanoTime() - started >= TimeUnit.SECONDS.toNanos(10), + "Close must allow the full graceful exit timeout before terminating"); + fixture.assertCleanExit(); + } + } + + @Test + void failedStartupTerminatesChild() throws Exception { + try (var fixture = new Fixture("start-failure")) { + assertThrows(ExecutionException.class, () -> fixture.client.start().get(30, TimeUnit.SECONDS)); + fixture.assertExited(); + assertFalse(Files.exists(fixture.marker)); + } + } + + private static final class Fixture implements AutoCloseable { + private final Path directory; + private final Path marker; + private final Path pid; + private final CopilotClient client; + + private Fixture(String mode) throws Exception { + directory = Files.createDirectories(Path.of("target", "shutdown-" + UUID.randomUUID()).toAbsolutePath()); + marker = directory.resolve("cleanup.jsonl"); + pid = directory.resolve("pid"); + Path script = Path.of("..", "..", "test", "harness", "stdio-shutdown-runtime.cjs").toAbsolutePath(); + assertTrue(Files.isRegularFile(script), "Shared shutdown fixture must exist"); + client = new CopilotClient(new CopilotClientOptions().setAutoStart(false).setCliPath("node") + .setCliArgs(new String[]{script.toString(), marker.toString(), mode, pid.toString()})); + } + + private void assertCleanExit() throws Exception { + assertEquals("{\"type\":\"span\"}\n", Files.readString(marker)); + assertExited(); + } + + private void assertExited() throws Exception { + assertTrue(Files.isRegularFile(pid), "Child must have started"); + assertFalse( + ProcessHandle.of(Long.parseLong(Files.readString(pid))).map(ProcessHandle::isAlive).orElse(false), + "Child must be reaped before shutdown returns"); + } + + @Override + public void close() throws Exception { + if (Files.isRegularFile(pid)) { + var process = ProcessHandle.of(Long.parseLong(Files.readString(pid))); + if (process.isPresent() && process.get().isAlive()) { + process.get().destroyForcibly(); + process.get().onExit().get(10, TimeUnit.SECONDS); + } + } + client.close(); + try (var paths = Files.walk(directory)) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } + } +} diff --git a/nodejs/README.md b/nodejs/README.md index f995fa59e9..ef452e3cf5 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -188,6 +188,9 @@ Start the CLI server and establish connection. ##### `stop(): Promise` Stop the server and close all sessions. Returns a list of any errors encountered during cleanup. +For an owned stdio runtime, closes stdin and waits up to 10 seconds for host cleanup +(including telemetry export) and process exit before falling back to termination. +This graceful-exit timeout is separate from the shutdown RPC and post-termination wait. ##### `forceStop(): Promise` diff --git a/nodejs/package.json b/nodejs/package.json index 1232724c8c..9a9141efe7 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/github/copilot-sdk.git" }, "version": "0.0.0-dev", - "copilotCliVersion": "1.0.89-7", + "copilotCliVersion": "1.0.90-2", "description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC", "main": "./dist/cjs/index.js", "types": "./dist/index.d.ts", diff --git a/nodejs/src/cliVersion.ts b/nodejs/src/cliVersion.ts index 95551eddda..477f6f3976 100644 --- a/nodejs/src/cliVersion.ts +++ b/nodejs/src/cliVersion.ts @@ -1,3 +1,3 @@ -export const COPILOT_CLI_VERSION = "1.0.89-7"; +export const COPILOT_CLI_VERSION = "1.0.90-2"; export const COPILOT_CLI_USE_NPM_PACKAGE = false; diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9f92d73cb3..cf2ba98576 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1013,7 +1013,8 @@ export class CopilotClient { * 1. Closes all active sessions (releases in-memory resources) * 2. Requests runtime shutdown for SDK-owned CLI processes * 3. Closes the JSON-RPC connection - * 4. Terminates the CLI server process (if spawned by this client) + * 4. Signals EOF to an owned stdio process and waits for host cleanup, then + * terminates the process if it does not exit within the shutdown timeout * * Note: session data on disk is preserved, so sessions can be resumed later. * To permanently remove session data before stopping, call @@ -1166,15 +1167,33 @@ export class CopilotClient { } } - // The runtime completes all cleanup before responding to - // runtime.shutdown and then leaves termination to us; it deliberately - // keeps its JSON-RPC server alive to send the response and never - // self-exits. Waiting a grace window for a self-exit that will never - // come just wastes time, so terminate the child immediately and only - // wait to reap it. if (this.cliProcess && !this.isExternalServer) { const child = this.cliProcess; - this.cliProcess = null; + if ( + this.connectionConfig.kind === "stdio" && + child.stdin && + child.exitCode == null && + child.signalCode == null + ) { + const gracefulExitStart = Date.now(); + try { + // Host telemetry is finalized after transport EOF, not the shutdown RPC. + child.stdin.end(); + const exited = await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS); + this.logDebugTiming( + exited + ? "CopilotClient.stop graceful stdio exit complete" + : "CopilotClient.stop graceful stdio exit timed out; terminating child", + gracefulExitStart + ); + } catch (error) { + errors.push( + new Error( + `Failed to close CLI stdin: ${error instanceof Error ? error.message : String(error)}` + ) + ); + } + } try { if (child.exitCode == null && child.signalCode == null) { child.kill(); @@ -1192,6 +1211,10 @@ export class CopilotClient { `Failed to kill CLI process: ${error instanceof Error ? error.message : String(error)}` ) ); + } finally { + if (this.cliProcess === child) { + this.cliProcess = null; + } } } // Tear down the in-process FFI host (closes the native connection and diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 8c3d96f120..8d51bec1df 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -4311,7 +4311,7 @@ export type ResponseFormat = { type: "json_schema"; }; /** - * Origin of the sandbox choice supplied by the host. Settings-derived origins let managed policy floor the host preference; do not tag explicit session overrides as settings-derived. + * Origin of the sandbox choice supplied by the host. This value describes preference or session intent; it does not authorize bypassing managed policy. * * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema * via the `definition` "SandboxConfigSource". @@ -4324,7 +4324,7 @@ export type SandboxConfigSource = | "user_enabled" /** The user's persisted settings disabled the sandbox. */ | "user_disabled" - /** A command-line flag selected the sandbox state for this session. */ + /** An explicit session-scoped choice selected the sandbox state, such as a command-line flag. */ | "session_flag" /** The user disabled the sandbox for the current session. */ | "session_disabled" @@ -20304,6 +20304,33 @@ export interface SandboxEnforcementStatus { */ reason?: string; } +/** + * Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SandboxGrantPathForRequestRequest". + */ +/** @experimental */ +export interface SandboxGrantPathForRequestRequest { + /** + * Identifier of the exact pending sandbox escalation permission request whose sandboxPathGrant to accept. + */ + requestId: string; + decisionContext?: PermissionDecisionContext; +} +/** + * Result of accepting a sandbox path grant. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "SandboxGrantPathForRequestResult". + */ +/** @experimental */ +export interface SandboxGrantPathForRequestResult { + /** + * Whether this call resolved the pending request and added the path to the session's sandbox policy. + */ + success: boolean; +} /** * Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`. * @@ -22018,6 +22045,11 @@ export interface SessionOpenOptions { */ shellProcessFlags?: string[]; sandboxConfig?: SandboxConfig; + /** + * Origin of the sandbox choice. Settings-derived origins (never_configured, user_enabled, user_disabled, repository_policy) let managed policy floor a host preference; explicit below-floor changes remain policy conflicts unless a session opt-out is authorized. Also used for telemetry provenance. + * + * @experimental + */ sandboxConfigSource?: SandboxConfigSource; /** * Whether interactive shell sessions are logged. @@ -23544,6 +23576,11 @@ export interface SessionUpdateOptionsParams { */ shellProcessFlags?: string[]; sandboxConfig?: SandboxConfig; + /** + * Origin of the sandbox choice. Settings-derived origins (never_configured, user_enabled, user_disabled, repository_policy) let managed policy floor a host preference; explicit below-floor changes remain policy conflicts unless a session opt-out is authorized. Also used for telemetry provenance. + * + * @experimental + */ sandboxConfigSource?: SandboxConfigSource; /** * Whether interactive shell sessions are logged. @@ -29506,6 +29543,15 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin */ disableForSession: async (params: SandboxDisableForSessionRequest): Promise => connection.sendRequest("session.sandbox.disableForSession", { sessionId, ...params }), + /** + * Adds the path offered by a pending sandbox escalation permission request's sandboxPathGrant to the session's sandbox policy and approves the request, so the blocked operation re-runs inside the sandbox rather than outside it. The request is rejected unless the exact request is still pending, carries a sandboxPathGrant, and the grant still takes effect under the current managed policy. Does not persist the path; hosts that store sandbox settings save it themselves. + * + * @param params Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt. + * + * @returns Result of accepting a sandbox path grant. + */ + grantPathForRequest: async (params: SandboxGrantPathForRequestRequest): Promise => + connection.sendRequest("session.sandbox.grantPathForRequest", { sessionId, ...params }), }, /** * Aborts the current agent turn. diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index a2a87f0a43..dad12ab7c2 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -1131,6 +1131,14 @@ export type PermissionRequest = | PermissionRequestWorkflow | PermissionRequestExtensionPermissionAccess | PermissionRequestExtensionEnvAccess; +/** + * Access a sandbox path grant confers + */ +export type PermissionSandboxPathGrantAccess = + /** Read access: the path is added to readonlyPaths. */ + | "read" + /** Read and write access: the path is added to readwritePaths. */ + | "readWrite"; /** * Advisory recommendation the runtime attaches to a permission request whose origin it can vouch for by construction. Unlike the auto-approval judge this does not depend on auto mode and does not evaluate what the tool call does; its absence simply means the runtime has no opinion and the request follows the host's normal approval flow. */ @@ -8765,6 +8773,12 @@ export interface PermissionRequestShell { * @experimental */ resolvedWorkingDirectory?: string; + /** + * Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + * + * @experimental + */ + sandboxPathGrant?: PermissionSandboxPathGrant; /** * Tool call ID that triggered this permission request */ @@ -8809,6 +8823,25 @@ export interface PermissionRequestShellPossibleUrl { */ url: string; } +/** + * A sandbox filesystem policy edit that would let a blocked operation run inside the sandbox instead of outside it. Offered only on a sandbox escalation request whose denial adding this path lifts, and only when managed policy permits the grant. A host accepts it with session.sandbox.grantPathForRequest, which adds the path to the session's sandbox policy and re-runs the operation sandboxed; a host that persists sandbox settings may also save the path there. + */ +/** @experimental */ +export interface PermissionSandboxPathGrant { + access: PermissionSandboxPathGrantAccess; + /** + * The path the sandbox refused, present only when it differs from path. That happens when a write under a read-only folder moves the folder to the read-write paths, when a path that does not exist yet is granted through its nearest existing folder, because the OS sandbox cannot grant a path before it exists, and when either is spelled through a symlink, because a grant covers its path as written, so path is then the resolved location. Hosts should then name path in the offer, since the denial names this one. + */ + deniedPath?: string; + /** + * Absolute path to add to the sandbox filesystem policy + */ + path: string; + /** + * readonlyPaths entries the grant removes, exactly as written in the policy, because a read-only entry for the same location would otherwise keep the path read-only. A host that persists the path must remove these entries from its stored readonlyPaths too. + */ + removedReadonlyPaths?: string[]; +} /** * File write permission request */ @@ -8855,6 +8888,12 @@ export interface PermissionRequestWrite { * @experimental */ resolvedPath?: string; + /** + * Sandbox policy edit that would let the write run inside the sandbox. Only present when requestSandboxBypass is true. + * + * @experimental + */ + sandboxPathGrant?: PermissionSandboxPathGrant; /** * Tool call ID that triggered this permission request */ @@ -8894,6 +8933,12 @@ export interface PermissionRequestRead { * @experimental */ resolvedPath?: string; + /** + * Sandbox policy edit that would let the read run inside the sandbox. Only present when requestSandboxBypass is true. + * + * @experimental + */ + sandboxPathGrant?: PermissionSandboxPathGrant; /** * Tool call ID that triggered this permission request */ @@ -9374,6 +9419,12 @@ export interface PermissionPromptRequestCommands { * True when the escalation is a permissive retry that keeps the sandbox and network policy attached while recording file and process accesses instead of blocking them. */ requestSandboxPermissive?: boolean; + /** + * Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + * + * @experimental + */ + sandboxPathGrant?: PermissionSandboxPathGrant; /** * Tool call ID that triggered this permission request */ @@ -12577,10 +12628,18 @@ export interface McpServerStatusChangedEvent { * Payload of `session.mcp_server_status_changed` for one MCP server's status and optional failure error. */ export interface McpServerStatusChangedData { + /** + * Runtime configuration provenance for a failed connection, or unknown when unavailable. Additional string values may be introduced. + */ + configSource?: string; /** * Error message if the server entered a failed state */ error?: string; + /** + * Runtime-produced classification for the final failed connection; unclassified means no classification was supplied. Additional string values may be introduced. + */ + errorClassification?: string; /** * Name of the MCP server whose status changed */ diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 28202b7af0..c29160f947 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -171,6 +171,7 @@ export type { ProviderModelConfig, ProviderTokenArgs, RemoteSessionMode, + SandboxConfigSource, ResumeSessionConfig, SectionOverride, SectionOverrideAction, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 1c9a92e26a..c8c9c0a53b 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -38,6 +38,7 @@ import type { import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; +export type { SandboxConfigSource } from "./generated/rpc.js"; export type { ConnectorAccountRequest, ConnectorAvailability, diff --git a/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts b/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts index c8d6fa39e0..67cd9258fa 100644 --- a/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts +++ b/nodejs/test/e2e/rpc_surface_coverage.e2e.test.ts @@ -313,7 +313,7 @@ describe("Generated RPC surface coverage", () => { ...collectRuntimeFunctions(session.rpc, "session"), ]); - expect(inventory).toHaveLength(368); + expect(inventory).toHaveLength(369); expect([...runtimeFunctions.keys()].sort()).toEqual( inventory.map((method) => `${method.scope}.${method.path}`) ); diff --git a/nodejs/test/sandbox-config.test.ts b/nodejs/test/sandbox-config.test.ts index 0870f9c2e3..7494807251 100644 --- a/nodejs/test/sandbox-config.test.ts +++ b/nodejs/test/sandbox-config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { SandboxConfig } from "../src/generated/rpc.js"; +import type { SandboxConfigSource } from "../src/index.js"; describe("SandboxConfig", () => { it("round-trips allowBypass and omits it when absent", () => { @@ -14,3 +15,10 @@ describe("SandboxConfig", () => { expect(JSON.parse(JSON.stringify(omitted))).toEqual({ enabled: true }); }); }); + +describe("SandboxConfigSource", () => { + it("is importable from the package root", () => { + const source: SandboxConfigSource = "user_disabled"; + expect(source).toBe("user_disabled"); + }); +}); diff --git a/nodejs/test/stdio-shutdown.test.ts b/nodejs/test/stdio-shutdown.test.ts new file mode 100644 index 0000000000..d17575f700 --- /dev/null +++ b/nodejs/test/stdio-shutdown.test.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, onTestFinished } from "vitest"; +import { CopilotClient, RuntimeConnection } from "../src/index.js"; + +const fixture = fileURLToPath( + new URL("../../test/harness/stdio-shutdown-runtime.cjs", import.meta.url) +); + +describe("owned stdio shutdown", () => { + it.each([ + "stop", + "dispose", + "force", + "fallback", + "start-failure", + "force-during-stop", + ] as const)( + "%s preserves the owned-process cleanup contract", + async (mode) => { + const directory = mkdtempSync(join(tmpdir(), "copilot-node-shutdown-")); + const marker = join(directory, "telemetry.jsonl"); + const pidFile = join(directory, "runtime.pid"); + const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ + path: process.execPath, + args: [ + fixture, + marker, + mode === "force-during-stop" ? "fallback" : mode, + pidFile, + ], + }), + useLoggedInUser: false, + }); + onTestFinished(async () => { + await client.forceStop(); + rmSync(directory, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); + }); + + if (mode === "start-failure") { + await expect(client.start()).rejects.toThrow(/protocol version/i); + } else { + await client.start(); + const started = performance.now(); + if (mode === "force") { + await client.forceStop(); + expect(performance.now() - started).toBeLessThan(10_000); + } else if (mode === "force-during-stop") { + const stopping = client.stop(); + await expect.poll(() => existsSync(marker), { timeout: 5000 }).toBe(true); + await client.forceStop(); + expect(await stopping).toEqual([]); + expect(performance.now() - started).toBeLessThan(10_000); + } else if (mode === "dispose") { + await client[Symbol.asyncDispose](); + } else { + expect(await client.stop()).toEqual([]); + } + if (mode === "fallback") { + expect(performance.now() - started).toBeGreaterThanOrEqual(10_000); + } + } + + const pid = Number(readFileSync(pidFile, "utf8")); + const hasExited = () => { + try { + process.kill(pid, 0); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + return true; + } + }; + if (mode === "force" || mode === "start-failure") { + // Force-stop sends the kill signal without waiting to reap the child. + await expect.poll(hasExited, { timeout: 5000 }).toBe(true); + expect(existsSync(marker)).toBe(false); + } else { + expect(hasExited()).toBe(true); + expect(readFileSync(marker, "utf8")).toBe('{"type":"span"}\n'); + } + expect(await client.stop()).toEqual([]); + await client[Symbol.asyncDispose](); + }, + 40_000 + ); +}); diff --git a/nodejs/tsconfig.test.json b/nodejs/tsconfig.test.json index 5618bfd010..f3916c8264 100644 --- a/nodejs/tsconfig.test.json +++ b/nodejs/tsconfig.test.json @@ -12,6 +12,7 @@ "test/ffiRuntimeHost.test.ts", "test/session-event-types.test.ts", "test/session-config-types.test.ts", + "test/sandbox-config.test.ts", "test/message-source.test.ts", "test/legacy-request-compatibility.test.ts" ], diff --git a/python/README.md b/python/README.md index 4938228e4e..db99cd0b44 100644 --- a/python/README.md +++ b/python/README.md @@ -141,6 +141,12 @@ or supply its reply. If you need more control over the lifecycle, you can call `start()`, `stop()`, and `disconnect()` manually: +For an SDK-owned stdio runtime, `stop()` and async context-manager exit request +shutdown, close stdin, and wait up to 10 seconds for the process to finish host +cleanup, including telemetry flushing. A process that does not exit is terminated, +then killed if necessary, with bounded waits. `force_stop()` skips graceful cleanup; +externally managed runtimes are not shut down. + ```python import asyncio diff --git a/python/copilot/client.py b/python/copilot/client.py index a659fde15c..add3283e62 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1425,6 +1425,7 @@ def _session_lifecycle_event_from_dict(data: dict) -> SessionLifecycleEvent: # Servers reporting a version below this are rejected. _MIN_PROTOCOL_VERSION = 3 _RUNTIME_SHUTDOWN_TIMEOUT_SECONDS = 10 +_CLI_PROCESS_GRACEFUL_EXIT_TIMEOUT_SECONDS = 10 _CLI_PROCESS_EXIT_TIMEOUT_SECONDS = 5 @@ -2075,8 +2076,8 @@ async def stop(self) -> None: This method performs graceful cleanup: 1. Closes all active sessions (releases in-memory resources) 2. Requests runtime shutdown for SDK-owned CLI processes - 3. Closes the JSON-RPC connection - 4. Terminates the CLI server process (if spawned by this client) + 3. Closes owned stdio input and waits for host cleanup and natural exit + 4. Closes the JSON-RPC connection and terminates any remaining owned process Note: session data on disk is preserved, so sessions can be resumed later. To permanently remove session data before stopping, call @@ -2143,6 +2144,26 @@ async def stop(self) -> None: ) errors.append(StopError(message=f"Failed to gracefully shut down runtime: {e}")) + # Host telemetry is finalized after stdio EOF, not the shutdown response. + # Keep the readers alive while the child drains its final output. + if ( + self._cli_process is not None + and not self._is_external_server + and isinstance(self._connection, StdioRuntimeConnection) + and self._cli_process.poll() is None + ): + try: + if self._cli_process.stdin is not None: + self._cli_process.stdin.close() + await asyncio.to_thread( + self._cli_process.wait, + timeout=_CLI_PROCESS_GRACEFUL_EXIT_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + logger.debug("Timed out waiting for graceful CLI exit; terminating the process") + except OSError: + logger.debug("Error while closing Copilot CLI stdin", exc_info=True) + # Close client if self._client: await self._client.stop() @@ -2171,15 +2192,7 @@ async def stop(self) -> None: logger.debug("Error while closing Copilot runtime transport", exc_info=True) self._process = None - # Terminate CLI process (only if we spawned it). - # - # Per the runtime.shutdown contract, the runtime completes all cleanup - # *before* responding and then leaves termination to the caller ("callers - # may then terminate the owned runtime process"). It deliberately keeps - # its JSON-RPC server alive to send the response and does not self-exit, - # so there is no point waiting a grace window for a self-exit that will - # never come. Once shutdown has completed (or failed) we terminate the - # child immediately and only wait to reap it. + # Terminate and reap an owned process that did not exit gracefully. if self._cli_process and not self._is_external_server: poll = getattr(self._cli_process, "poll", None) is_running = poll is None or poll() is None diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 0cf3b44f5b..f944b79304 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -10654,9 +10654,8 @@ def to_dict(self) -> dict: # Experimental: this type is part of an experimental API and may change or be removed. class SandboxConfigSource(Enum): - """Origin of the sandbox choice supplied by the host. Settings-derived origins let managed - policy floor the host preference; do not tag explicit session overrides as - settings-derived. + """Origin of the sandbox choice supplied by the host. This value describes preference or + session intent; it does not authorize bypassing managed policy. Origin of the sandbox choice. Settings-derived origins (never_configured, user_enabled, user_disabled, repository_policy) let managed policy floor a host preference; explicit @@ -10725,6 +10724,27 @@ def to_dict(self) -> dict: result["reason"] = from_union([from_str, from_none], self.reason) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SandboxGrantPathForRequestResult: + """Result of accepting a sandbox path grant.""" + + success: bool + """Whether this call resolved the pending request and added the path to the session's + sandbox policy. + """ + + @staticmethod + def from_dict(obj: Any) -> 'SandboxGrantPathForRequestResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SandboxGrantPathForRequestResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SandboxHostCapability: @@ -32522,6 +32542,33 @@ def to_dict(self) -> dict: result["decisionContext"] = from_union([lambda x: to_class(PermissionDecisionContext, x), from_none], self.decision_context) return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class SandboxGrantPathForRequestRequest: + """Request to accept the sandbox path grant offered on an active sandbox escalation + permission prompt. + """ + request_id: str + """Identifier of the exact pending sandbox escalation permission request whose + sandboxPathGrant to accept. + """ + decision_context: PermissionDecisionContext | None = None + """Optional attribution for the permission decision.""" + + @staticmethod + def from_dict(obj: Any) -> 'SandboxGrantPathForRequestRequest': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + decision_context = from_union([PermissionDecisionContext.from_dict, from_none], obj.get("decisionContext")) + return SandboxGrantPathForRequestRequest(request_id, decision_context) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + if self.decision_context is not None: + result["decisionContext"] = from_union([lambda x: to_class(PermissionDecisionContext, x), from_none], self.decision_context) + return result + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class PermissionsConfigureAdditionalContentExclusionPolicy: @@ -45359,6 +45406,8 @@ class RPC: sandbox_disable_for_session_request: SandboxDisableForSessionRequest sandbox_disable_for_session_result: SandboxDisableForSessionResult sandbox_enforcement_status: SandboxEnforcementStatus + sandbox_grant_path_for_request_request: SandboxGrantPathForRequestRequest + sandbox_grant_path_for_request_result: SandboxGrantPathForRequestResult sandbox_host_capability: SandboxHostCapability sandbox_host_capability_name: str sandbox_host_support: SandboxHostSupport @@ -46792,6 +46841,8 @@ def from_dict(obj: Any) -> 'RPC': sandbox_disable_for_session_request = SandboxDisableForSessionRequest.from_dict(obj.get("SandboxDisableForSessionRequest")) sandbox_disable_for_session_result = SandboxDisableForSessionResult.from_dict(obj.get("SandboxDisableForSessionResult")) sandbox_enforcement_status = SandboxEnforcementStatus.from_dict(obj.get("SandboxEnforcementStatus")) + sandbox_grant_path_for_request_request = SandboxGrantPathForRequestRequest.from_dict(obj.get("SandboxGrantPathForRequestRequest")) + sandbox_grant_path_for_request_result = SandboxGrantPathForRequestResult.from_dict(obj.get("SandboxGrantPathForRequestResult")) sandbox_host_capability = SandboxHostCapability.from_dict(obj.get("SandboxHostCapability")) sandbox_host_capability_name = from_str(obj.get("SandboxHostCapabilityName")) sandbox_host_support = SandboxHostSupport.from_dict(obj.get("SandboxHostSupport")) @@ -47283,7 +47334,7 @@ def from_dict(obj: Any) -> 'RPC': subagent_settings = from_union([SubagentSettings.from_dict, from_none], obj.get("SubagentSettings")) task_progress = from_union([TaskProgress.from_dict, from_none], obj.get("TaskProgress")) workspace_summary = from_union([WorkspaceSummary.from_dict, from_none], obj.get("WorkspaceSummary")) - return RPC(abort_request, abort_result, accepted_enqueue_command_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_kind, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, accounts_enumerate_request, accounts_get_request, accounts_set_request, account_status, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_list_request, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agent_set_prompt_request, agents_get_discovery_paths_request, api_key_auth_info, auth_enumerate_query, auth_enumerate_value, auth_identity, auth_info, auth_info_type, auth_login_advance_request, auth_login_begin_request, auth_login_begun, auth_login_cancel_request, auth_login_result_dto, auth_login_result_status, auth_login_step, auth_read_query, auth_read_value, auth_status_dto, auth_validation_error, auth_validation_errors, auth_write, auth_write_result, autopilot_objective_credit_limit, autopilot_objective_get_state_result, autopilot_objective_state, autopilot_objective_status, built_in_model_catalog, built_in_model_catalog_entry, builtin_tool_descriptor, builtin_tool_format, builtin_tool_format_type, builtin_tool_input_schema, builtin_tool_input_schema_type, builtin_tool_safe_for_telemetry, builtin_tool_safe_telemetry_fields, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_provider_register_request, canvas_provider_unregister_request, canvas_session_context, capi_session_options, card_digest, card_digest_algorithm, card_digest_value, catalog_agent_plugin_candidate, catalog_agent_plugin_candidate_kind, catalog_agent_plugin_candidate_provenance, catalog_agent_plugin_compatibility_tag, catalog_agent_plugin_media_type, catalog_ai_skill_candidate, catalog_ai_skill_candidate_kind, catalog_ai_skill_candidate_provenance, catalog_ai_skill_installability, catalog_ai_skill_media_type, catalog_authentication_required_error, catalog_authentication_required_reason, catalog_candidate, catalog_candidate_kind, catalog_candidate_source, catalog_candidate_source_embedded, catalog_candidate_source_url, catalog_capability, catalog_capability_id, catalog_client_contract, catalog_contract_violation_error, catalog_contract_violation_reason, catalog_handle_rejected_error, catalog_handle_rejection_reason, catalog_handle_type, catalog_invalid_request_error, catalog_invalid_request_field, catalog_malformed_card_error, catalog_malformed_card_reason, catalog_mcp_server_candidate, catalog_mcp_server_candidate_kind, catalog_mcp_server_candidate_provenance, catalog_mcp_server_installability, catalog_media_type, catalog_negotiated_contract, catalog_negotiation_refused_error, catalog_negotiation_refused_reason, catalog_network_failure_error, catalog_network_failure_reason, catalog_not_installable_error, catalog_not_installable_reason, catalog_plugin_repository_source, catalog_policy_rejected_error, catalog_resource_identity, catalog_resource_version, catalog_search_page, catalog_search_pagination, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_search_total_count_relation, catalog_selection_cancelled, catalog_selection_decision, catalog_selection_declined, catalog_selection_foreign, catalog_selection_invalid, catalog_selection_replayed, catalog_selection_request, catalog_selection_result, catalog_selection_selected, catalog_selection_stale, catalog_selection_timed_out, catalog_selection_wrong_kind, catalog_trust_eligibility, catalog_trust_provenance, catalog_trust_snapshot, catalog_trust_snapshot_absent, catalog_trust_snapshot_absent_status, catalog_trust_snapshot_current, catalog_trust_snapshot_current_status, catalog_trust_snapshot_downgraded, catalog_trust_snapshot_downgraded_status, catalog_trust_snapshot_malformed, catalog_trust_snapshot_malformed_status, catalog_trust_snapshot_revoked, catalog_trust_snapshot_revoked_status, catalog_trust_snapshot_schema_version, catalog_trust_snapshot_stale, catalog_trust_snapshot_stale_status, catalog_trust_snapshot_unsupported, catalog_trust_snapshot_unsupported_status, catalog_trust_source, catalog_trust_tier, catalog_unavailable_error, catalog_unavailable_reason, catalog_unavailable_transport_error, catalog_unavailable_transport_reason, catalog_unsafe_retrieval_error, catalog_unsafe_retrieval_reason, catalog_unsupported_kind_error, client_metadata, client_task_cancel_reason, client_task_cancel_request, client_task_cancel_result, command_list, commands_finalize_invocation_effect_request, commands_finalize_invocation_effect_result, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invocation_effect_outcome, commands_invocation_origin, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connect_client_info, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connector_account_request, connector_authorization_requirement, connector_authorization_scope, connector_availability, connector_capabilities, connector_catalog_entry, connector_catalog_result, connector_catalog_status, connector_connect_request, connector_connect_result, connector_continue_request, connector_disconnect_result, connector_mcp_status, connector_reconcile_request, connector_runtime_status, connector_status, connect_remote_session_params, connect_request, connect_result, content_exclusion_check_paths_request, content_exclusion_check_paths_result, content_exclusion_path_check, content_filter_mode, context_heaviest_message, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, diagnostic_cursor_status, diagnostic_entry, diagnostic_log_level, diagnostics_configuration, diagnostics_configure_request, diagnostic_severity, diagnostic_source, diagnostic_sources_configuration, diagnostics_read_request, diagnostics_read_result, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_hook, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, entra_token_acquire_request, entra_token_acquire_result, entra_token_interaction, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_direction, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_launch_profile, extension_launch_provider_resolve_request, extension_launch_provider_resolve_result, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, git_hub_token_acquire_reason, git_hub_token_acquire_request, git_hub_token_acquire_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_clear_context_request, history_clear_context_result, history_compact_context_window, history_compact_request, history_compact_result, history_file_restore_skip_reason, history_list_rewind_points_result, history_preview_rewind_request, history_preview_rewind_result, history_rewind_change_type, history_rewind_file_preview, history_rewind_mode, history_rewind_outcome, history_rewind_point, history_rewind_request, history_rewind_result, history_rewind_unavailable_reason, history_skipped_file_restore, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, hook_invoke_request, hook_invoke_response, hook_origin, hooks_discover_request, hooks_discover_result, hook_type, installation_catalogue_identity, installation_confirmation_request, installation_confirmation_response, installation_decision, installation_review, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, interrupt_main_turn_request, interrupt_main_turn_result, json_schema_response_format, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, login_provider_kind, log_request, log_result, lsp_initialize_request, managed_mcp_server_config, managed_settings_read_result, managed_settings_resolved_data, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apply_install_request, mcp_apply_uninstall_request, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_diagnostic_details, mcp_diagnostic_direction, mcp_diagnostic_kind, mcp_diagnostic_source_configuration, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_elicitation_form_mode, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_failed_server, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_installation_failure_reason, mcp_installation_input, mcp_installation_management_outcome, mcp_installation_management_result, mcp_installation_operation_request, mcp_installation_operation_status, mcp_installation_outcome, mcp_installation_remote_configuration, mcp_installation_result, mcp_installation_review, mcp_installation_secret, mcp_installation_secret_storage, mcp_installations_request, mcp_installation_state, mcp_installation_summary, mcp_install_plan, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_authentication_state_changed_request, mcp_oauth_cancel_login_request, mcp_oauth_cancel_login_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_prepare_login_request, mcp_oauth_prepare_login_result, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_owned_oauth_login_status, mcp_plan_configuration_change, mcp_plan_configuration_operation, mcp_plan_enum_value_type, mcp_plan_install_planned, mcp_plan_install_request, mcp_plan_install_result, mcp_plan_install_source, mcp_plan_install_source_candidate, mcp_plan_install_source_candidate_kind, mcp_plan_install_source_card, mcp_plan_install_source_card_kind, mcp_plan_package_install_method, mcp_plan_package_transport, mcp_plan_policy_decision, mcp_plan_policy_result, mcp_plan_policy_source, mcp_plan_provenance, mcp_plan_remote_install_method, mcp_plan_remote_transport, mcp_plan_required_value, mcp_plan_required_value_enum, mcp_plan_required_value_enum_kind, mcp_plan_required_value_scalar, mcp_plan_required_value_scalar_kind, mcp_plan_resource_identity, mcp_plan_scalar_value_type, mcp_plan_scope, mcp_plan_secret_placeholder, mcp_plan_secret_reference, mcp_plan_target, mcp_plan_transport_choice, mcp_plan_transport_choice_package, mcp_plan_transport_choice_remote, mcp_plan_uninstall_request, mcp_plan_value_category, mcp_prepared_install, mcp_prepare_install_request, mcp_register_external_client_request, mcp_reload_config, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_resource, mcp_resource_annotations, mcp_resource_content, mcp_resource_icon, mcp_resources_list_request, mcp_resources_list_result, mcp_resources_list_templates_request, mcp_resources_list_templates_result, mcp_resources_read_request, mcp_resources_read_result, mcp_resource_template, mcp_restart_server_request, mcp_safe_for_telemetry, mcp_safe_for_telemetry_fields, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_serializable_server_config, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_card_embedded, mcp_server_card_embedded_kind, mcp_server_card_media_type, mcp_server_card_reference, mcp_server_card_url, mcp_server_card_url_kind, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_memory, mcp_server_config_memory_type, mcp_server_config_stdio, mcp_server_config_stdio_type, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_server_ownership, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_source_file, mcp_source_plugin, mcp_source_ref, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_task_metadata, mcp_tools, mcp_tool_ui, mcp_tool_ui_visibility, mcp_uninstall_plan, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, metadata_update_client_metadata_request, model, model_apply_startup_overlay_request, model_billing, model_billing_promo, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_message, model_picker_category, model_picker_persistence_request, model_picker_price_category, model_picker_settings_context, model_policy, model_policy_state, model_provider_descriptor, model_provider_kind, model_provider_ref, model_set_allowed_models_request, model_set_allowed_models_result, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_auto_tier_request, model_switch_auto_tier_result, model_switch_auto_tier_status, model_switch_confirmation, model_switch_to_request, model_switch_to_result, model_warning_text, mode_set_request, mode_set_result, move_mcp_loading_to_background_result, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_env_access, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_workflow, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_env_access, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_workflow, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_context, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_outcome, permission_decision_reject, permission_decision_request, permission_decision_surface, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_mode_source, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_response_capability, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_mode_request, permissions_get_mode_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_env_access, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_workflow, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_mode_request, permissions_set_mode_result, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_install_staging_mode, plugin_list, plugin_list_result, plugins_builtin_set_request, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, protocol_append_mode, protocol_customize_mode, protocol_external_tool_defer, protocol_external_tool_definition, protocol_marker_section_override, protocol_replace_mode, protocol_section_override, protocol_static_section_action, protocol_static_section_override, protocol_system_message_append_config, protocol_system_message_config, protocol_system_message_customize_config, protocol_system_message_replace_config, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_descriptor, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_sync_request, provider_sync_result, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queue_append_steering_request, queue_begin_deferred_idle_drain_request, queue_begin_deferred_idle_drain_result, queue_consume_system_notifications_request, queued_command_handled, queued_command_not_handled, queued_command_result, queue_defer_session_idle_request, queue_duplicate_at_request, queue_duplicate_at_result, queue_enqueue_resume_pending_result, queue_finish_deferred_idle_drain_request, queue_finish_deferred_idle_drain_result, queue_has_pending_result, queue_insert_at_request, queue_insert_at_result, queue_insert_message, queue_move_item_request, queue_move_item_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_at_request, queue_remove_at_result, queue_remove_most_recent_result, queue_send_now_request, queue_send_now_result, queue_set_drain_paused_request, queue_snapshot_result, queue_update_text_request, queue_update_text_result, queue_withdraw_message_request, queue_withdraw_message_result, register_event_interest_params, register_event_interest_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_host_status, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, response_format, sandbox_config, sandbox_config_auth, sandbox_config_source, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_network_proxy, sandbox_config_user_policy_seatbelt, sandbox_disable_for_session_request, sandbox_disable_for_session_result, sandbox_enforcement_status, sandbox_host_capability, sandbox_host_capability_name, sandbox_host_support, sandbox_session_change, schedule_add_at_request, schedule_add_cron_request, schedule_add_request, schedule_add_result, schedule_add_self_paced_request, schedule_entry, schedule_has_self_paced_result, schedule_list, schedule_rearm_self_paced_request, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_message_item, send_messages_request, send_messages_result, send_mode, send_request, send_result, send_system_notification_request, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_agent_list_request, session_auth_login_request, session_auth_logout_user_request, session_auth_status, session_auth_switch_request, session_bulk_delete_result, session_cancel_all_background_agents_result, session_capability, session_commands_list_request, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_sqlite_transaction_error, session_fs_sqlite_transaction_error_class, session_fs_sqlite_transaction_request, session_fs_sqlite_transaction_result, session_fs_sqlite_transaction_statement, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_git_hub_auth_get_all_auth_available_result, session_git_hub_auth_logout_result, session_git_hub_auth_logout_user_result, session_history_compact_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_limit_prediction_baseline_data, session_limit_prediction_client_type, session_limit_prediction_details, session_limit_prediction_predict_request, session_limit_prediction_request, session_limit_prediction_result, session_limit_prediction_source, session_limit_prediction_tier, session_limit_prediction_tier_option, session_limit_prediction_unavailable_reason, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_managed_permissions, session_managed_settings, session_mcp_apps_call_tool_result, session_mcp_oauth_cancel_login_request, session_mcp_oauth_cancel_login_result, session_mcp_oauth_prepare_login_request, session_mcp_oauth_prepare_login_result, session_metadata_snapshot, session_mode, session_model_list, session_model_list_request, session_model_price_category, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_plugins_disable_request, session_plugins_enable_request, session_plugins_install_request, session_plugins_marketplaces_refresh_request, session_plugins_reload_request, session_provider_get_endpoint_request, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_client_metadata_entry, sessions_close_request, sessions_close_result, sessions_delete_request, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_client_metadata_request, sessions_get_client_metadata_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_metadata_request, sessions_get_metadata_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_non_empty_session_ids_request, sessions_list_non_empty_session_ids_result, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_prune_old_request, sessions_read_persisted_events_request, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_workflow_pause_at_checkpoint_result, session_working_directory_context, session_working_directory_context_host_type, settable_auth_info, settable_token_auth_info, shell_cancel_user_requested_request, shell_credentials, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_init_profile, shell_init_script, shell_init_script_shell, shell_kill_request, shell_kill_result, shell_kill_signal, shell_options, shutdown_request, skill, skill_apply_install_request, skill_apply_uninstall_request, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_installation_failure_reason, skill_installation_file_review, skill_installation_location, skill_installation_management_outcome, skill_installation_management_result, skill_installation_operation_request, skill_installation_operation_status, skill_installation_outcome, skill_installation_ownership_state, skill_installation_result, skill_installation_review, skill_installation_scope, skill_installation_session_state, skill_installation_source, skill_installations_request, skill_installation_summary, skill_install_plan, skill_list, skill_plan_install_request, skill_plan_uninstall_request, skill_provider_descriptor, skill_provider_list_request, skill_provider_list_result, skill_provider_read_request, skill_provider_read_result, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skill_set_enabled_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, skill_uninstall_plan, slash_command_add_timeline_entry_result, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_model_picker_dialog, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_set_model_result, slash_command_set_plan_model_result, slash_command_show_dialog_result, slash_command_text_result, slash_command_timeline_entry, subagent_settings_entry, subagent_settings_entry_context_tier, system_message_block, task_agent_info, task_agent_progress, task_client_active_status, task_client_execution_mode, task_client_info, task_client_owner, task_client_owner_kind, task_client_owner_presence, task_client_progress, task_client_status, task_client_type, task_client_update, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_kind, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_register_request, tasks_register_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_update_request, tasks_update_result, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, token_provider_auth_info, tool, tool_list, tool_result, tool_result_expanded, tool_result_new_message, tool_result_type, tools_execute_request, tools_get_builtin_descriptors_request, tools_get_builtin_descriptors_result, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_set_request, tools_set_result, tools_shell_descriptor_config, tools_task_complete_event_data_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, unsupported_enqueue_command_result, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_agent_metric, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workflow_abort_request, workflow_ack_result, workflow_agent_options, workflow_agent_request, workflow_agent_result, workflow_agent_summary, workflow_cancel_request, workflow_current_phase, workflow_declared_limits, workflow_durable_operation, workflow_execute_request, workflow_execute_result, workflow_get_run_progress_request, workflow_get_run_request, workflow_journal_get_request, workflow_journal_get_result, workflow_journal_put_request, workflow_list_runs_request, workflow_list_runs_result, workflow_log_line, workflow_log_line_kind, workflow_log_request, workflow_pause_checkpoint_action, workflow_pause_checkpoint_request, workflow_pause_checkpoint_result, workflow_pause_info, workflow_pause_request, workflow_phase_observation, workflow_phase_status, workflow_progress_line, workflow_progress_page, workflow_resume_request, workflow_resume_result, workflow_run_consumed, workflow_run_detail, workflow_run_failure, workflow_run_failure_kind, workflow_run_limits, workflow_run_options, workflow_run_request, workflow_run_result, workflow_run_status, workflow_run_summary, workflow_run_terminal, workflow_tool_resume_request, workflow_tool_run_options, workflow_tool_run_request, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_add_summary_request, workspaces_add_summary_result, workspaces_autopilot_objective_exists_result, workspaces_checkpoints, workspaces_create_directory_request, workspaces_create_file_request, workspaces_delete_autopilot_objective_result, workspaces_diff_request, workspaces_ensure_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_autopilot_objective_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_remove_path_request, workspaces_rename_path_request, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_stat_file_request, workspaces_stat_file_result, workspaces_truncate_summaries_request, workspace_summary_host_type, workspaces_update_metadata_request, workspaces_workspace_details_host_type, workspaces_write_autopilot_objective_request, workspaces_write_autopilot_objective_result, session_auth_info_result, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) + return RPC(abort_request, abort_result, accepted_enqueue_command_result, account_all_users, account_get_all_users_result, account_get_current_auth_result, account_get_quota_request, account_get_quota_result, account_kind, account_login_request, account_login_result, account_logout_request, account_logout_result, account_quota_snapshot, accounts_enumerate_request, accounts_get_request, accounts_set_request, account_status, adaptive_thinking_support, agent_discovery_path, agent_discovery_path_list, agent_discovery_path_scope, agent_get_current_result, agent_info, agent_info_source, agent_list, agent_list_request, agent_registry_live_target_entry, agent_registry_live_target_entry_attention_kind, agent_registry_live_target_entry_kind, agent_registry_live_target_entry_last_terminal_event, agent_registry_live_target_entry_status, agent_registry_log_capture, agent_registry_log_capture_open_error_reason, agent_registry_spawn_error, agent_registry_spawn_permission_mode, agent_registry_spawn_registry_timeout, agent_registry_spawn_request, agent_registry_spawn_result, agent_registry_spawn_spawned, agent_registry_spawn_validation_error, agent_registry_spawn_validation_error_field, agent_registry_spawn_validation_error_reason, agent_reload_result, agents_discover_request, agent_select_request, agent_select_result, agent_set_prompt_request, agents_get_discovery_paths_request, api_key_auth_info, auth_enumerate_query, auth_enumerate_value, auth_identity, auth_info, auth_info_type, auth_login_advance_request, auth_login_begin_request, auth_login_begun, auth_login_cancel_request, auth_login_result_dto, auth_login_result_status, auth_login_step, auth_read_query, auth_read_value, auth_status_dto, auth_validation_error, auth_validation_errors, auth_write, auth_write_result, autopilot_objective_credit_limit, autopilot_objective_get_state_result, autopilot_objective_state, autopilot_objective_status, built_in_model_catalog, built_in_model_catalog_entry, builtin_tool_descriptor, builtin_tool_format, builtin_tool_format_type, builtin_tool_input_schema, builtin_tool_input_schema_type, builtin_tool_safe_for_telemetry, builtin_tool_safe_telemetry_fields, cancel_user_requested_shell_command_result, canvas_action, canvas_action_invoke_request, canvas_action_invoke_result, canvas_close_request, canvas_host_context, canvas_host_context_capabilities, canvas_json_schema, canvas_list, canvas_list_open_result, canvas_open_request, canvas_provider_close_request, canvas_provider_invoke_action_request, canvas_provider_open_request, canvas_provider_open_result, canvas_provider_register_request, canvas_provider_unregister_request, canvas_session_context, capi_session_options, card_digest, card_digest_algorithm, card_digest_value, catalog_agent_plugin_candidate, catalog_agent_plugin_candidate_kind, catalog_agent_plugin_candidate_provenance, catalog_agent_plugin_compatibility_tag, catalog_agent_plugin_media_type, catalog_ai_skill_candidate, catalog_ai_skill_candidate_kind, catalog_ai_skill_candidate_provenance, catalog_ai_skill_installability, catalog_ai_skill_media_type, catalog_authentication_required_error, catalog_authentication_required_reason, catalog_candidate, catalog_candidate_kind, catalog_candidate_source, catalog_candidate_source_embedded, catalog_candidate_source_url, catalog_capability, catalog_capability_id, catalog_client_contract, catalog_contract_violation_error, catalog_contract_violation_reason, catalog_handle_rejected_error, catalog_handle_rejection_reason, catalog_handle_type, catalog_invalid_request_error, catalog_invalid_request_field, catalog_malformed_card_error, catalog_malformed_card_reason, catalog_mcp_server_candidate, catalog_mcp_server_candidate_kind, catalog_mcp_server_candidate_provenance, catalog_mcp_server_installability, catalog_media_type, catalog_negotiated_contract, catalog_negotiation_refused_error, catalog_negotiation_refused_reason, catalog_network_failure_error, catalog_network_failure_reason, catalog_not_installable_error, catalog_not_installable_reason, catalog_plugin_repository_source, catalog_policy_rejected_error, catalog_resource_identity, catalog_resource_version, catalog_search_page, catalog_search_pagination, catalog_search_request, catalog_search_result, catalog_search_succeeded, catalog_search_total_count_relation, catalog_selection_cancelled, catalog_selection_decision, catalog_selection_declined, catalog_selection_foreign, catalog_selection_invalid, catalog_selection_replayed, catalog_selection_request, catalog_selection_result, catalog_selection_selected, catalog_selection_stale, catalog_selection_timed_out, catalog_selection_wrong_kind, catalog_trust_eligibility, catalog_trust_provenance, catalog_trust_snapshot, catalog_trust_snapshot_absent, catalog_trust_snapshot_absent_status, catalog_trust_snapshot_current, catalog_trust_snapshot_current_status, catalog_trust_snapshot_downgraded, catalog_trust_snapshot_downgraded_status, catalog_trust_snapshot_malformed, catalog_trust_snapshot_malformed_status, catalog_trust_snapshot_revoked, catalog_trust_snapshot_revoked_status, catalog_trust_snapshot_schema_version, catalog_trust_snapshot_stale, catalog_trust_snapshot_stale_status, catalog_trust_snapshot_unsupported, catalog_trust_snapshot_unsupported_status, catalog_trust_source, catalog_trust_tier, catalog_unavailable_error, catalog_unavailable_reason, catalog_unavailable_transport_error, catalog_unavailable_transport_reason, catalog_unsafe_retrieval_error, catalog_unsafe_retrieval_reason, catalog_unsupported_kind_error, client_metadata, client_task_cancel_reason, client_task_cancel_request, client_task_cancel_result, command_list, commands_finalize_invocation_effect_request, commands_finalize_invocation_effect_result, commands_handle_pending_command_request, commands_handle_pending_command_result, commands_invocation_effect_outcome, commands_invocation_origin, commands_invoke_request, commands_list_request, commands_respond_to_queued_command_request, commands_respond_to_queued_command_result, completions_get_trigger_characters_result, completions_request_request, completions_request_result, configure_session_extensions_params, connect_client_info, connected_remote_session_metadata, connected_remote_session_metadata_kind, connected_remote_session_metadata_repository, connector_account_request, connector_authorization_requirement, connector_authorization_scope, connector_availability, connector_capabilities, connector_catalog_entry, connector_catalog_result, connector_catalog_status, connector_connect_request, connector_connect_result, connector_continue_request, connector_disconnect_result, connector_mcp_status, connector_reconcile_request, connector_runtime_status, connector_status, connect_remote_session_params, connect_request, connect_result, content_exclusion_check_paths_request, content_exclusion_check_paths_result, content_exclusion_path_check, content_filter_mode, context_heaviest_message, copilot_api_token_auth_info, copilot_user_response, copilot_user_response_endpoints, copilot_user_response_quota_snapshots, copilot_user_response_quota_snapshots_chat, copilot_user_response_quota_snapshots_completions, copilot_user_response_quota_snapshots_premium_interactions, current_model, current_tool_metadata, debug_collect_logs_collected_entry, debug_collect_logs_destination, debug_collect_logs_entry, debug_collect_logs_entry_kind, debug_collect_logs_include, debug_collect_logs_redaction, debug_collect_logs_request, debug_collect_logs_result, debug_collect_logs_result_kind, debug_collect_logs_skipped_entry, debug_collect_logs_source, diagnostic_cursor_status, diagnostic_entry, diagnostic_log_level, diagnostics_configuration, diagnostics_configure_request, diagnostic_severity, diagnostic_source, diagnostic_sources_configuration, diagnostics_read_request, diagnostics_read_result, discovered_canvas, discovered_extension, discovered_extension_mode, discovered_extension_plugin, discovered_extensions, discovered_extensions_disable_request, discovered_extensions_enable_request, discovered_extension_source, discovered_hook, discovered_mcp_server, discovered_mcp_server_type, enqueue_command_params, enqueue_command_result, entra_token_acquire_request, entra_token_acquire_result, entra_token_interaction, env_auth_info, event_log_read_request, event_log_release_interest_result, event_log_tail_result, event_log_types, events_agent_scope, events_cursor_status, events_read_direction, events_read_result, execute_command_params, execute_command_result, extension, extension_context_push_input, extension_launch_profile, extension_launch_provider_resolve_request, extension_launch_provider_resolve_result, extension_list, extensions_disable_request, extensions_enable_request, extension_source, extension_status, external_tool_result, external_tool_text_result_for_llm, external_tool_text_result_for_llm_binary_results_for_llm, external_tool_text_result_for_llm_binary_results_for_llm_type, external_tool_text_result_for_llm_content, external_tool_text_result_for_llm_content_audio, external_tool_text_result_for_llm_content_image, external_tool_text_result_for_llm_content_resource, external_tool_text_result_for_llm_content_resource_details, external_tool_text_result_for_llm_content_resource_link, external_tool_text_result_for_llm_content_resource_link_icon, external_tool_text_result_for_llm_content_resource_link_icon_theme, external_tool_text_result_for_llm_content_shell_exit, external_tool_text_result_for_llm_content_terminal, external_tool_text_result_for_llm_content_text, filter_mapping, fleet_start_request, fleet_start_result, folder_trust_add_params, folder_trust_check_params, folder_trust_check_result, gh_cli_auth_info, git_hub_telemetry_client_info, git_hub_telemetry_event, git_hub_telemetry_notification, git_hub_token_acquire_reason, git_hub_token_acquire_request, git_hub_token_acquire_result, handle_pending_tool_call_request, handle_pending_tool_call_result, history_abort_manual_compaction_result, history_cancel_background_compaction_result, history_clear_context_request, history_clear_context_result, history_compact_context_window, history_compact_request, history_compact_result, history_file_restore_skip_reason, history_list_rewind_points_result, history_preview_rewind_request, history_preview_rewind_result, history_rewind_change_type, history_rewind_file_preview, history_rewind_mode, history_rewind_outcome, history_rewind_point, history_rewind_request, history_rewind_result, history_rewind_unavailable_reason, history_skipped_file_restore, history_summarize_for_handoff_result, history_truncate_request, history_truncate_result, hmac_auth_info, hook_invoke_request, hook_invoke_response, hook_origin, hooks_discover_request, hooks_discover_result, hook_type, installation_catalogue_identity, installation_confirmation_request, installation_confirmation_response, installation_decision, installation_review, installed_plugin, installed_plugin_info, installed_plugin_source, installed_plugin_source_git_hub, installed_plugin_source_local, installed_plugin_source_url, instruction_discovery_path, instruction_discovery_path_kind, instruction_discovery_path_list, instruction_discovery_path_location, instructions_discover_request, instructions_get_discovery_paths_request, instructions_get_sources_result, instruction_source, instruction_source_location, instruction_source_type, interrupt_main_turn_request, interrupt_main_turn_result, json_schema_response_format, llm_inference_headers, llm_inference_http_request_chunk_request, llm_inference_http_request_chunk_result, llm_inference_http_request_start_request, llm_inference_http_request_start_result, llm_inference_http_request_start_transport, llm_inference_http_response_chunk_error, llm_inference_http_response_chunk_request, llm_inference_http_response_chunk_result, llm_inference_http_response_start_request, llm_inference_http_response_start_result, llm_inference_set_provider_result, local_session_metadata_value, login_provider_kind, log_request, log_result, lsp_initialize_request, managed_mcp_server_config, managed_settings_read_result, managed_settings_resolved_data, marketplace_add_result, marketplace_browse_result, marketplace_info, marketplace_list_result, marketplace_plugin_info, marketplace_refresh_entry, marketplace_refresh_result, marketplace_remove_result, mcp_allowed_server, mcp_apply_install_request, mcp_apply_uninstall_request, mcp_apps_call_tool_request, mcp_apps_diagnose_capability, mcp_apps_diagnose_request, mcp_apps_diagnose_result, mcp_apps_diagnose_server, mcp_apps_host_context, mcp_apps_host_context_details, mcp_apps_host_context_details_available_display_mode, mcp_apps_host_context_details_display_mode, mcp_apps_host_context_details_platform, mcp_apps_host_context_details_theme, mcp_apps_list_tools_request, mcp_apps_list_tools_result, mcp_apps_read_resource_request, mcp_apps_read_resource_result, mcp_apps_resource_content, mcp_apps_set_host_context_details, mcp_apps_set_host_context_details_available_display_mode, mcp_apps_set_host_context_details_display_mode, mcp_apps_set_host_context_details_platform, mcp_apps_set_host_context_details_theme, mcp_apps_set_host_context_request, mcp_cancel_sampling_execution_params, mcp_cancel_sampling_execution_result, mcp_config_add_request, mcp_config_disable_request, mcp_config_enable_request, mcp_config_list, mcp_config_remove_request, mcp_config_update_request, mcp_configure_git_hub_request, mcp_configure_git_hub_result, mcp_diagnostic_details, mcp_diagnostic_direction, mcp_diagnostic_kind, mcp_diagnostic_source_configuration, mcp_disable_request, mcp_discover_request, mcp_discover_result, mcp_elicitation_form_mode, mcp_enable_request, mcp_execute_sampling_params, mcp_execute_sampling_request, mcp_execute_sampling_result, mcp_failed_server, mcp_filtered_server, mcp_headers_handle_pending_headers_refresh_request, mcp_headers_handle_pending_headers_refresh_request_request, mcp_headers_handle_pending_headers_refresh_request_result, mcp_host_state, mcp_installation_failure_reason, mcp_installation_input, mcp_installation_management_outcome, mcp_installation_management_result, mcp_installation_operation_request, mcp_installation_operation_status, mcp_installation_outcome, mcp_installation_remote_configuration, mcp_installation_result, mcp_installation_review, mcp_installation_secret, mcp_installation_secret_storage, mcp_installations_request, mcp_installation_state, mcp_installation_summary, mcp_install_plan, mcp_is_server_running_request, mcp_is_server_running_result, mcp_list_tools_request, mcp_list_tools_result, mcp_oauth_authentication_state_changed_request, mcp_oauth_cancel_login_request, mcp_oauth_cancel_login_result, mcp_oauth_handle_pending_request, mcp_oauth_handle_pending_result, mcp_oauth_login_grant_type, mcp_oauth_login_request, mcp_oauth_login_result, mcp_oauth_pending_request_response, mcp_oauth_prepare_login_request, mcp_oauth_prepare_login_result, mcp_oauth_probe_needs_auth_reason, mcp_oauth_probe_request, mcp_oauth_probe_result, mcp_oauth_respond_request, mcp_oauth_respond_result, mcp_owned_oauth_login_status, mcp_plan_configuration_change, mcp_plan_configuration_operation, mcp_plan_enum_value_type, mcp_plan_install_planned, mcp_plan_install_request, mcp_plan_install_result, mcp_plan_install_source, mcp_plan_install_source_candidate, mcp_plan_install_source_candidate_kind, mcp_plan_install_source_card, mcp_plan_install_source_card_kind, mcp_plan_package_install_method, mcp_plan_package_transport, mcp_plan_policy_decision, mcp_plan_policy_result, mcp_plan_policy_source, mcp_plan_provenance, mcp_plan_remote_install_method, mcp_plan_remote_transport, mcp_plan_required_value, mcp_plan_required_value_enum, mcp_plan_required_value_enum_kind, mcp_plan_required_value_scalar, mcp_plan_required_value_scalar_kind, mcp_plan_resource_identity, mcp_plan_scalar_value_type, mcp_plan_scope, mcp_plan_secret_placeholder, mcp_plan_secret_reference, mcp_plan_target, mcp_plan_transport_choice, mcp_plan_transport_choice_package, mcp_plan_transport_choice_remote, mcp_plan_uninstall_request, mcp_plan_value_category, mcp_prepared_install, mcp_prepare_install_request, mcp_register_external_client_request, mcp_reload_config, mcp_reload_with_config_request, mcp_remove_git_hub_result, mcp_resource, mcp_resource_annotations, mcp_resource_content, mcp_resource_icon, mcp_resources_list_request, mcp_resources_list_result, mcp_resources_list_templates_request, mcp_resources_list_templates_result, mcp_resources_read_request, mcp_resources_read_result, mcp_resource_template, mcp_restart_server_request, mcp_safe_for_telemetry, mcp_safe_for_telemetry_fields, mcp_sampling_execution_action, mcp_sampling_execution_result, mcp_serializable_server_config, mcp_server, mcp_server_auth_config, mcp_server_auth_config_redirect_port, mcp_server_card_embedded, mcp_server_card_embedded_kind, mcp_server_card_media_type, mcp_server_card_reference, mcp_server_card_url, mcp_server_card_url_kind, mcp_server_config, mcp_server_config_defer_tools, mcp_server_config_http, mcp_server_config_http_oauth_grant_type, mcp_server_config_http_type, mcp_server_config_memory, mcp_server_config_memory_type, mcp_server_config_stdio, mcp_server_config_stdio_type, mcp_server_failure_info, mcp_server_list, mcp_server_needs_auth_info, mcp_server_ownership, mcp_set_env_value_mode_details, mcp_set_env_value_mode_params, mcp_set_env_value_mode_result, mcp_source_file, mcp_source_plugin, mcp_source_ref, mcp_start_server_request, mcp_start_servers_result, mcp_stop_server_request, mcp_task_metadata, mcp_tools, mcp_tool_ui, mcp_tool_ui_visibility, mcp_uninstall_plan, mcp_unregister_external_client_request, memory_configuration, metadata_context_attribution_result, metadata_context_heaviest_messages_request, metadata_context_heaviest_messages_result, metadata_context_info_request, metadata_context_info_result, metadata_is_processing_result, metadata_recompute_context_tokens_request, metadata_recompute_context_tokens_result, metadata_record_context_change_request, metadata_record_context_change_result, metadata_set_working_directory_request, metadata_set_working_directory_result, metadata_snapshot_current_mode, metadata_snapshot_remote_metadata, metadata_snapshot_remote_metadata_repository, metadata_snapshot_remote_metadata_task_type, metadata_update_client_metadata_request, model, model_apply_startup_overlay_request, model_billing, model_billing_promo, model_billing_token_prices, model_billing_token_prices_long_context, model_capabilities, model_capabilities_limits, model_capabilities_limits_vision, model_capabilities_override, model_capabilities_override_limits, model_capabilities_override_limits_vision, model_capabilities_override_supports, model_capabilities_supports, model_list, model_list_request, model_message, model_picker_category, model_picker_persistence_request, model_picker_price_category, model_picker_settings_context, model_policy, model_policy_state, model_provider_descriptor, model_provider_kind, model_provider_ref, model_set_allowed_models_request, model_set_allowed_models_result, model_set_reasoning_effort_request, model_set_reasoning_effort_result, models_list_request, model_switch_auto_tier_request, model_switch_auto_tier_result, model_switch_auto_tier_status, model_switch_confirmation, model_switch_to_request, model_switch_to_result, model_warning_text, mode_set_request, mode_set_result, move_mcp_loading_to_background_result, named_provider_config, name_get_result, name_set_auto_request, name_set_auto_result, name_set_request, open_canvas_instance, options_update_additional_content_exclusion_policy, options_update_additional_content_exclusion_policy_rule, options_update_additional_content_exclusion_policy_rule_source, options_update_additional_content_exclusion_policy_scope, options_update_context_tier, options_update_env_value_mode, options_update_reasoning_summary, options_update_tool_filter_precedence, pending_permission_request, pending_permission_request_list, permission_decision, permission_decision_approved, permission_decision_approved_for_location, permission_decision_approved_for_session, permission_decision_approve_for_location, permission_decision_approve_for_location_approval, permission_decision_approve_for_location_approval_commands, permission_decision_approve_for_location_approval_custom_tool, permission_decision_approve_for_location_approval_extension_env_access, permission_decision_approve_for_location_approval_extension_management, permission_decision_approve_for_location_approval_extension_permission_access, permission_decision_approve_for_location_approval_mcp, permission_decision_approve_for_location_approval_mcp_sampling, permission_decision_approve_for_location_approval_memory, permission_decision_approve_for_location_approval_read, permission_decision_approve_for_location_approval_workflow, permission_decision_approve_for_location_approval_write, permission_decision_approve_for_session, permission_decision_approve_for_session_approval, permission_decision_approve_for_session_approval_commands, permission_decision_approve_for_session_approval_custom_tool, permission_decision_approve_for_session_approval_extension_env_access, permission_decision_approve_for_session_approval_extension_management, permission_decision_approve_for_session_approval_extension_permission_access, permission_decision_approve_for_session_approval_mcp, permission_decision_approve_for_session_approval_mcp_sampling, permission_decision_approve_for_session_approval_memory, permission_decision_approve_for_session_approval_read, permission_decision_approve_for_session_approval_workflow, permission_decision_approve_for_session_approval_write, permission_decision_approve_once, permission_decision_approve_permanently, permission_decision_cancelled, permission_decision_context, permission_decision_denied_by_content_exclusion_policy, permission_decision_denied_by_permission_request_hook, permission_decision_denied_by_rules, permission_decision_denied_interactively_by_user, permission_decision_denied_no_approval_rule_and_could_not_request_from_user, permission_decision_outcome, permission_decision_reject, permission_decision_request, permission_decision_surface, permission_decision_user_not_available, permission_location_add_tool_approval_params, permission_location_apply_params, permission_location_apply_result, permission_location_resolve_params, permission_location_resolve_result, permission_location_type, permission_mode_source, permission_paths_add_params, permission_paths_allowed_check_params, permission_paths_allowed_check_result, permission_paths_config, permission_paths_list, permission_paths_update_primary_params, permission_paths_workspace_check_params, permission_paths_workspace_check_result, permission_prompt_shown_notification, permission_request_result, permission_response_capability, permission_rules_set, permissions_configure_additional_content_exclusion_policy, permissions_configure_additional_content_exclusion_policy_rule, permissions_configure_additional_content_exclusion_policy_rule_source, permissions_configure_additional_content_exclusion_policy_scope, permissions_configure_params, permissions_configure_result, permissions_folder_trust_add_trusted_result, permissions_get_mode_request, permissions_get_mode_result, permissions_locations_add_tool_approval_details, permissions_locations_add_tool_approval_details_commands, permissions_locations_add_tool_approval_details_custom_tool, permissions_locations_add_tool_approval_details_extension_env_access, permissions_locations_add_tool_approval_details_extension_management, permissions_locations_add_tool_approval_details_extension_permission_access, permissions_locations_add_tool_approval_details_mcp, permissions_locations_add_tool_approval_details_mcp_sampling, permissions_locations_add_tool_approval_details_memory, permissions_locations_add_tool_approval_details_read, permissions_locations_add_tool_approval_details_workflow, permissions_locations_add_tool_approval_details_write, permissions_locations_add_tool_approval_result, permissions_modify_rules_params, permissions_modify_rules_result, permissions_modify_rules_scope, permissions_notify_prompt_shown_result, permissions_paths_add_result, permissions_paths_list_request, permissions_paths_update_primary_result, permissions_pending_requests_request, permissions_reset_session_approvals_request, permissions_reset_session_approvals_result, permissions_set_approve_all_request, permissions_set_approve_all_result, permissions_set_approve_all_source, permissions_set_mode_request, permissions_set_mode_result, permissions_set_required_request, permissions_set_required_result, permissions_urls_set_unrestricted_mode_result, permission_urls_config, permission_urls_set_unrestricted_mode_params, ping_request, ping_result, plan_read_result, plan_read_sql_todos_result, plan_read_sql_todos_with_dependencies_result, plan_sql_todo_dependency, plan_sql_todos_row, plan_update_request, plugin, plugin_install_result, plugin_install_staging_mode, plugin_list, plugin_list_result, plugins_builtin_set_request, plugins_disable_request, plugins_enable_request, plugins_install_request, plugins_marketplaces_add_request, plugins_marketplaces_browse_request, plugins_marketplaces_refresh_request, plugins_marketplaces_remove_request, plugins_reload_request, plugins_uninstall_request, plugins_update_request, plugin_update_all_entry, plugin_update_all_result, plugin_update_result, protocol_append_mode, protocol_customize_mode, protocol_external_tool_defer, protocol_external_tool_definition, protocol_marker_section_override, protocol_replace_mode, protocol_section_override, protocol_static_section_action, protocol_static_section_override, protocol_system_message_append_config, protocol_system_message_config, protocol_system_message_customize_config, protocol_system_message_replace_config, provider_add_request, provider_add_result, provider_config, provider_config_azure, provider_config_transport, provider_config_type, provider_config_wire_api, provider_descriptor, provider_endpoint, provider_endpoint_transport, provider_endpoint_type, provider_endpoint_wire_api, provider_get_endpoint_request, provider_model_config, provider_session_token, provider_sync_request, provider_sync_result, provider_token_acquire_request, provider_token_acquire_result, push_attachment, push_attachment_blob, push_attachment_directory, push_attachment_file, push_attachment_file_line_range, push_attachment_git_hub_actions_job, push_attachment_git_hub_commit, push_attachment_git_hub_file, push_attachment_git_hub_file_diff, push_attachment_git_hub_file_diff_side, push_attachment_git_hub_reference, push_attachment_git_hub_reference_type, push_attachment_git_hub_release, push_attachment_git_hub_repository, push_attachment_git_hub_snippet, push_attachment_git_hub_tree_comparison, push_attachment_git_hub_tree_comparison_side, push_attachment_git_hub_url, push_attachment_selection, push_attachment_selection_details, push_attachment_selection_details_end, push_attachment_selection_details_start, push_git_hub_repo_ref, queue_append_steering_request, queue_begin_deferred_idle_drain_request, queue_begin_deferred_idle_drain_result, queue_consume_system_notifications_request, queued_command_handled, queued_command_not_handled, queued_command_result, queue_defer_session_idle_request, queue_duplicate_at_request, queue_duplicate_at_result, queue_enqueue_resume_pending_result, queue_finish_deferred_idle_drain_request, queue_finish_deferred_idle_drain_result, queue_has_pending_result, queue_insert_at_request, queue_insert_at_result, queue_insert_message, queue_move_item_request, queue_move_item_result, queue_pending_items, queue_pending_items_kind, queue_pending_items_result, queue_remove_at_request, queue_remove_at_result, queue_remove_most_recent_result, queue_send_now_request, queue_send_now_result, queue_set_drain_paused_request, queue_snapshot_result, queue_update_text_request, queue_update_text_result, queue_withdraw_message_request, queue_withdraw_message_result, register_event_interest_params, register_event_interest_result, release_event_interest_params, remote_control_config, remote_control_config_existing_mc_session, remote_control_status, remote_control_status_active, remote_control_status_connecting, remote_control_status_error, remote_control_status_off, remote_control_status_result, remote_control_stop_result, remote_control_transfer_result, remote_enable_request, remote_enable_result, remote_notify_steerable_changed_request, remote_notify_steerable_changed_result, remote_session_connection_result, remote_session_host_status, remote_session_metadata_repository, remote_session_metadata_task_type, remote_session_metadata_value, remote_session_mode, remote_session_repository, response_format, sandbox_config, sandbox_config_auth, sandbox_config_source, sandbox_config_user_policy, sandbox_config_user_policy_experimental, sandbox_config_user_policy_experimental_seatbelt, sandbox_config_user_policy_filesystem, sandbox_config_user_policy_network, sandbox_config_user_policy_network_proxy, sandbox_config_user_policy_seatbelt, sandbox_disable_for_session_request, sandbox_disable_for_session_result, sandbox_enforcement_status, sandbox_grant_path_for_request_request, sandbox_grant_path_for_request_result, sandbox_host_capability, sandbox_host_capability_name, sandbox_host_support, sandbox_session_change, schedule_add_at_request, schedule_add_cron_request, schedule_add_request, schedule_add_result, schedule_add_self_paced_request, schedule_entry, schedule_has_self_paced_result, schedule_list, schedule_rearm_self_paced_request, schedule_stop_request, schedule_stop_result, secrets_add_filter_values_request, secrets_add_filter_values_result, send_agent_mode, send_attachments_to_message_params, send_message_item, send_messages_request, send_messages_result, send_mode, send_request, send_result, send_system_notification_request, server_agent_list, server_instruction_source_list, server_skill, server_skill_list, session_activity, session_agent_list_request, session_auth_login_request, session_auth_logout_user_request, session_auth_status, session_auth_switch_request, session_bulk_delete_result, session_cancel_all_background_agents_result, session_capability, session_commands_list_request, session_completion_item, session_context, session_context_host_type, session_enrich_metadata_result, session_fs_append_file_request, session_fs_error, session_fs_error_code, session_fs_exists_request, session_fs_exists_result, session_fs_mkdir_request, session_fs_readdir_request, session_fs_readdir_result, session_fs_readdir_with_types_entry, session_fs_readdir_with_types_entry_type, session_fs_readdir_with_types_request, session_fs_readdir_with_types_result, session_fs_read_file_request, session_fs_read_file_result, session_fs_rename_request, session_fs_rm_request, session_fs_set_provider_capabilities, session_fs_set_provider_conventions, session_fs_set_provider_request, session_fs_set_provider_result, session_fs_sqlite_exists_request, session_fs_sqlite_exists_result, session_fs_sqlite_query_request, session_fs_sqlite_query_result, session_fs_sqlite_query_type, session_fs_sqlite_transaction_error, session_fs_sqlite_transaction_error_class, session_fs_sqlite_transaction_request, session_fs_sqlite_transaction_result, session_fs_sqlite_transaction_statement, session_fs_stat_request, session_fs_stat_result, session_fs_write_file_request, session_git_hub_auth_get_all_auth_available_result, session_git_hub_auth_logout_result, session_git_hub_auth_logout_user_result, session_history_compact_request, session_installed_plugin, session_installed_plugin_source, session_installed_plugin_source_git_hub, session_installed_plugin_source_local, session_installed_plugin_source_url, session_limit_prediction_baseline_data, session_limit_prediction_client_type, session_limit_prediction_details, session_limit_prediction_predict_request, session_limit_prediction_request, session_limit_prediction_result, session_limit_prediction_source, session_limit_prediction_tier, session_limit_prediction_tier_option, session_limit_prediction_unavailable_reason, session_list, session_list_entry, session_list_filter, session_load_deferred_repo_hooks_result, session_log_level, session_managed_permissions, session_managed_settings, session_mcp_apps_call_tool_result, session_mcp_oauth_cancel_login_request, session_mcp_oauth_cancel_login_result, session_mcp_oauth_prepare_login_request, session_mcp_oauth_prepare_login_result, session_metadata_snapshot, session_mode, session_model_list, session_model_list_request, session_model_price_category, session_open_options, session_open_options_additional_content_exclusion_policy, session_open_options_additional_content_exclusion_policy_rule, session_open_options_additional_content_exclusion_policy_rule_source, session_open_options_additional_content_exclusion_policy_scope, session_open_options_env_value_mode, session_open_options_reasoning_summary, session_open_params, session_open_result, session_plugins_disable_request, session_plugins_enable_request, session_plugins_install_request, session_plugins_marketplaces_refresh_request, session_plugins_reload_request, session_provider_get_endpoint_request, session_prune_result, sessions_bulk_delete_request, sessions_check_in_use_request, sessions_check_in_use_result, sessions_client_metadata_entry, sessions_close_request, sessions_close_result, sessions_delete_request, sessions_enrich_metadata_request, session_set_credentials_params, session_set_credentials_result, session_settings_built_in_tool_availability_snapshot, session_settings_evaluate_predicate_request, session_settings_evaluate_predicate_result, session_settings_job_snapshot, session_settings_model_snapshot, session_settings_online_evaluation_snapshot, session_settings_predicate_name, session_settings_repo_snapshot, session_settings_snapshot, session_settings_validation_snapshot, sessions_find_by_prefix_request, sessions_find_by_prefix_result, sessions_find_by_task_id_request, sessions_find_by_task_id_result, sessions_fork_request, sessions_fork_result, sessions_get_board_entry_count_request, sessions_get_board_entry_count_result, sessions_get_client_metadata_request, sessions_get_client_metadata_result, sessions_get_event_file_path_request, sessions_get_event_file_path_result, sessions_get_last_for_context_request, sessions_get_last_for_context_result, sessions_get_metadata_request, sessions_get_metadata_result, sessions_get_persisted_remote_steerable_request, sessions_get_persisted_remote_steerable_result, session_sizes, sessions_list_non_empty_session_ids_request, sessions_list_non_empty_session_ids_result, sessions_list_request, sessions_load_deferred_repo_hooks_request, sessions_open_attach, sessions_open_cloud, sessions_open_create, sessions_open_handoff, sessions_open_handoff_task_type, sessions_open_progress, sessions_open_progress_status, sessions_open_progress_step, sessions_open_remote, sessions_open_resume, sessions_open_resume_last, sessions_open_status, session_source, sessions_prune_old_request, sessions_read_persisted_events_request, sessions_release_lock_request, sessions_release_lock_result, sessions_reload_plugin_hooks_request, sessions_reload_plugin_hooks_result, sessions_save_request, sessions_save_result, sessions_set_additional_plugins_request, sessions_set_additional_plugins_result, sessions_set_remote_control_steering_request, sessions_start_remote_control_request, sessions_stop_remote_control_request, sessions_transfer_remote_control_request, session_telemetry_engagement, session_update_options_params, session_update_options_result, session_visibility_status, session_workflow_pause_at_checkpoint_result, session_working_directory_context, session_working_directory_context_host_type, settable_auth_info, settable_token_auth_info, shell_cancel_user_requested_request, shell_credentials, shell_exec_request, shell_exec_result, shell_execute_user_requested_request, shell_init_profile, shell_init_script, shell_init_script_shell, shell_kill_request, shell_kill_result, shell_kill_signal, shell_options, shutdown_request, skill, skill_apply_install_request, skill_apply_uninstall_request, skill_discovery_path, skill_discovery_path_list, skill_discovery_scope, skill_installation_failure_reason, skill_installation_file_review, skill_installation_location, skill_installation_management_outcome, skill_installation_management_result, skill_installation_operation_request, skill_installation_operation_status, skill_installation_outcome, skill_installation_ownership_state, skill_installation_result, skill_installation_review, skill_installation_scope, skill_installation_session_state, skill_installation_source, skill_installations_request, skill_installation_summary, skill_install_plan, skill_list, skill_plan_install_request, skill_plan_uninstall_request, skill_provider_descriptor, skill_provider_list_request, skill_provider_list_result, skill_provider_read_request, skill_provider_read_result, skills_config_set_disabled_skills_request, skills_config_set_skill_disabled_request, skills_disable_request, skills_discover_request, skills_enable_request, skill_set_enabled_request, skills_get_discovery_paths_request, skills_get_invoked_result, skills_invoked_skill, skills_load_diagnostics, skill_uninstall_plan, slash_command_add_timeline_entry_result, slash_command_agent_prompt_result, slash_command_completed_result, slash_command_info, slash_command_input, slash_command_input_choice, slash_command_input_completion, slash_command_invocation_result, slash_command_kind, slash_command_model_picker_dialog, slash_command_select_subcommand_option, slash_command_select_subcommand_result, slash_command_set_model_result, slash_command_set_plan_model_result, slash_command_show_dialog_result, slash_command_text_result, slash_command_timeline_entry, subagent_settings_entry, subagent_settings_entry_context_tier, system_message_block, task_agent_info, task_agent_progress, task_client_active_status, task_client_execution_mode, task_client_info, task_client_owner, task_client_owner_kind, task_client_owner_presence, task_client_progress, task_client_status, task_client_type, task_client_update, task_complete_data, task_completion_decision, task_execution_mode, task_info, task_kind, task_list, task_progress_line, tasks_cancel_request, tasks_cancel_result, tasks_get_current_promotable_result, tasks_get_progress_request, tasks_get_progress_result, task_shell_info, task_shell_info_attachment_mode, task_shell_progress, tasks_promote_current_to_background_result, tasks_promote_to_background_request, tasks_promote_to_background_result, tasks_refresh_result, tasks_register_request, tasks_register_result, tasks_remove_request, tasks_remove_result, tasks_send_message_request, tasks_send_message_result, tasks_start_agent_request, tasks_start_agent_result, task_status, tasks_update_request, tasks_update_result, tasks_wait_for_pending_result, telemetry_set_feature_overrides_request, token_auth_info, token_provider_auth_info, tool, tool_list, tool_result, tool_result_expanded, tool_result_new_message, tool_result_type, tools_execute_request, tools_get_builtin_descriptors_request, tools_get_builtin_descriptors_result, tools_get_current_metadata_result, tools_initialize_and_validate_result, tools_list_request, tools_set_request, tools_set_result, tools_shell_descriptor_config, tools_task_complete_event_data_request, tools_update_subagent_settings_result, ui_auto_mode_switch_response, ui_elicitation_array_any_of_field, ui_elicitation_array_any_of_field_items, ui_elicitation_array_any_of_field_items_any_of, ui_elicitation_array_enum_field, ui_elicitation_array_enum_field_items, ui_elicitation_field_value, ui_elicitation_request, ui_elicitation_response, ui_elicitation_response_action, ui_elicitation_response_content, ui_elicitation_result, ui_elicitation_schema, ui_elicitation_schema_property, ui_elicitation_schema_property_boolean, ui_elicitation_schema_property_number, ui_elicitation_schema_property_number_type, ui_elicitation_schema_property_string, ui_elicitation_schema_property_string_format, ui_elicitation_string_enum_field, ui_elicitation_string_one_of_field, ui_elicitation_string_one_of_field_one_of, ui_ephemeral_query_request, ui_ephemeral_query_result, ui_exit_plan_mode_action, ui_exit_plan_mode_response, ui_handle_pending_auto_mode_switch_request, ui_handle_pending_elicitation_request, ui_handle_pending_exit_plan_mode_request, ui_handle_pending_result, ui_handle_pending_sampling_request, ui_handle_pending_sampling_response, ui_handle_pending_session_limits_exhausted_request, ui_handle_pending_user_input_request, ui_register_direct_auto_mode_switch_handler_result, ui_session_limits_exhausted_response, ui_session_limits_exhausted_response_action, ui_unregister_direct_auto_mode_switch_handler_request, ui_unregister_direct_auto_mode_switch_handler_result, ui_user_input_response, unsupported_enqueue_command_result, update_subagent_settings_request, usage_get_metrics_result, usage_metrics_agent_metric, usage_metrics_code_changes, usage_metrics_model_metric, usage_metrics_model_metric_requests, usage_metrics_model_metric_token_detail, usage_metrics_model_metric_usage, usage_metrics_token_detail, user_auth_info, user_requested_shell_command_result, user_setting_metadata, user_settings_get_result, user_settings_set_request, user_settings_set_result, visibility_get_result, visibility_set_request, visibility_set_result, workflow_abort_request, workflow_ack_result, workflow_agent_options, workflow_agent_request, workflow_agent_result, workflow_agent_summary, workflow_cancel_request, workflow_current_phase, workflow_declared_limits, workflow_durable_operation, workflow_execute_request, workflow_execute_result, workflow_get_run_progress_request, workflow_get_run_request, workflow_journal_get_request, workflow_journal_get_result, workflow_journal_put_request, workflow_list_runs_request, workflow_list_runs_result, workflow_log_line, workflow_log_line_kind, workflow_log_request, workflow_pause_checkpoint_action, workflow_pause_checkpoint_request, workflow_pause_checkpoint_result, workflow_pause_info, workflow_pause_request, workflow_phase_observation, workflow_phase_status, workflow_progress_line, workflow_progress_page, workflow_resume_request, workflow_resume_result, workflow_run_consumed, workflow_run_detail, workflow_run_failure, workflow_run_failure_kind, workflow_run_limits, workflow_run_options, workflow_run_request, workflow_run_result, workflow_run_status, workflow_run_summary, workflow_run_terminal, workflow_tool_resume_request, workflow_tool_run_options, workflow_tool_run_request, workspace_diff_file_change, workspace_diff_file_change_type, workspace_diff_mode, workspace_diff_result, workspaces_add_summary_request, workspaces_add_summary_result, workspaces_autopilot_objective_exists_result, workspaces_checkpoints, workspaces_create_directory_request, workspaces_create_file_request, workspaces_delete_autopilot_objective_result, workspaces_diff_request, workspaces_ensure_request, workspaces_get_workspace_result, workspaces_list_checkpoints_result, workspaces_list_files_result, workspaces_read_autopilot_objective_result, workspaces_read_checkpoint_request, workspaces_read_checkpoint_result, workspaces_read_file_request, workspaces_read_file_result, workspaces_remove_path_request, workspaces_rename_path_request, workspaces_save_large_paste_request, workspaces_save_large_paste_result, workspaces_stat_file_request, workspaces_stat_file_result, workspaces_truncate_summaries_request, workspace_summary_host_type, workspaces_update_metadata_request, workspaces_workspace_details_host_type, workspaces_write_autopilot_objective_request, workspaces_write_autopilot_objective_result, session_auth_info_result, session_context_attribution, session_context_info, subagent_settings, task_progress, workspace_summary) def to_dict(self) -> dict: result: dict = {} @@ -48225,6 +48276,8 @@ def to_dict(self) -> dict: result["SandboxDisableForSessionRequest"] = to_class(SandboxDisableForSessionRequest, self.sandbox_disable_for_session_request) result["SandboxDisableForSessionResult"] = to_class(SandboxDisableForSessionResult, self.sandbox_disable_for_session_result) result["SandboxEnforcementStatus"] = to_class(SandboxEnforcementStatus, self.sandbox_enforcement_status) + result["SandboxGrantPathForRequestRequest"] = to_class(SandboxGrantPathForRequestRequest, self.sandbox_grant_path_for_request_request) + result["SandboxGrantPathForRequestResult"] = to_class(SandboxGrantPathForRequestResult, self.sandbox_grant_path_for_request_result) result["SandboxHostCapability"] = to_class(SandboxHostCapability, self.sandbox_host_capability) result["SandboxHostCapabilityName"] = from_str(self.sandbox_host_capability_name) result["SandboxHostSupport"] = to_class(SandboxHostSupport, self.sandbox_host_support) @@ -50003,6 +50056,12 @@ async def disable_for_session(self, params: SandboxDisableForSessionRequest, *, params_dict["sessionId"] = self._session_id return SandboxDisableForSessionResult.from_dict(await self._client.request("session.sandbox.disableForSession", params_dict, **_timeout_kwargs(timeout))) + async def grant_path_for_request(self, params: SandboxGrantPathForRequestRequest, *, timeout: float | None = None) -> SandboxGrantPathForRequestResult: + "Adds the path offered by a pending sandbox escalation permission request's sandboxPathGrant to the session's sandbox policy and approves the request, so the blocked operation re-runs inside the sandbox rather than outside it. The request is rejected unless the exact request is still pending, carries a sandboxPathGrant, and the grant still takes effect under the current managed policy. Does not persist the path; hosts that store sandbox settings save it themselves.\n\nArgs:\n params: Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt.\n\nReturns:\n Result of accepting a sandbox path grant." + params_dict: dict[str, Any] = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SandboxGrantPathForRequestResult.from_dict(await self._client.request("session.sandbox.grantPathForRequest", params_dict, **_timeout_kwargs(timeout))) + # Experimental: this API group is experimental and may change or be removed. class GitHubAuthApi: @@ -53686,6 +53745,8 @@ async def handle_installations_confirm(params: dict) -> dict | None: "SandboxDisableForSessionRequest", "SandboxDisableForSessionResult", "SandboxEnforcementStatus", + "SandboxGrantPathForRequestRequest", + "SandboxGrantPathForRequestResult", "SandboxHostCapability", "SandboxHostCapabilityName", "SandboxHostSupport", diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 6f605929f1..308c94367e 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -1690,6 +1690,40 @@ def to_dict(self) -> dict: return result +# Experimental: this type is part of an experimental API and may change or be removed. +@dataclass +class PermissionSandboxPathGrant: + "A sandbox filesystem policy edit that would let a blocked operation run inside the sandbox instead of outside it. Offered only on a sandbox escalation request whose denial adding this path lifts, and only when managed policy permits the grant. A host accepts it with session.sandbox.grantPathForRequest, which adds the path to the session's sandbox policy and re-runs the operation sandboxed; a host that persists sandbox settings may also save the path there." + access: PermissionSandboxPathGrantAccess + path: str + denied_path: str | None = None + removed_readonly_paths: list[str] | None = None + + @staticmethod + def from_dict(obj: Any) -> "PermissionSandboxPathGrant": + assert isinstance(obj, dict) + access = parse_enum(PermissionSandboxPathGrantAccess, obj.get("access")) + path = from_str(obj.get("path")) + denied_path = from_union([from_none, from_str], obj.get("deniedPath")) + removed_readonly_paths = from_union([from_none, lambda x: from_list(from_str, x)], obj.get("removedReadonlyPaths")) + return PermissionSandboxPathGrant( + access=access, + path=path, + denied_path=denied_path, + removed_readonly_paths=removed_readonly_paths, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["access"] = to_enum(PermissionSandboxPathGrantAccess, self.access) + result["path"] = from_str(self.path) + if self.denied_path is not None: + result["deniedPath"] = from_union([from_none, from_str], self.denied_path) + if self.removed_readonly_paths is not None: + result["removedReadonlyPaths"] = from_union([from_none, lambda x: from_list(from_str, x)], self.removed_readonly_paths) + return result + + # Experimental: this type is part of an experimental API and may change or be removed. @dataclass class SessionAutoModeResolvedData: @@ -6479,6 +6513,8 @@ class PermissionPromptRequestCommands: request_sandbox_permissive: bool | None = None tool_call_id: str | None = None warning: str | None = None + # Experimental: this field is part of an experimental API and may change or be removed. + sandbox_path_grant: PermissionSandboxPathGrant | None = None @staticmethod def from_dict(obj: Any) -> "PermissionPromptRequestCommands": @@ -6494,6 +6530,7 @@ def from_dict(obj: Any) -> "PermissionPromptRequestCommands": request_sandbox_permissive = from_union([from_none, from_bool], obj.get("requestSandboxPermissive")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) warning = from_union([from_none, from_str], obj.get("warning")) + sandbox_path_grant = from_union([from_none, PermissionSandboxPathGrant.from_dict], obj.get("sandboxPathGrant")) return PermissionPromptRequestCommands( can_offer_session_approval=can_offer_session_approval, command_identifiers=command_identifiers, @@ -6506,6 +6543,7 @@ def from_dict(obj: Any) -> "PermissionPromptRequestCommands": request_sandbox_permissive=request_sandbox_permissive, tool_call_id=tool_call_id, warning=warning, + sandbox_path_grant=sandbox_path_grant, ) def to_dict(self) -> dict: @@ -6529,6 +6567,8 @@ def to_dict(self) -> dict: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) if self.warning is not None: result["warning"] = from_union([from_none, from_str], self.warning) + if self.sandbox_path_grant is not None: + result["sandboxPathGrant"] = from_union([from_none, lambda x: to_class(PermissionSandboxPathGrant, x)], self.sandbox_path_grant) return result @@ -7549,6 +7589,8 @@ class PermissionRequestRead: tool_call_id: str | None = None # Experimental: this field is part of an experimental API and may change or be removed. resolved_path: str | None = None + # Experimental: this field is part of an experimental API and may change or be removed. + sandbox_path_grant: PermissionSandboxPathGrant | None = None @staticmethod def from_dict(obj: Any) -> "PermissionRequestRead": @@ -7560,6 +7602,7 @@ def from_dict(obj: Any) -> "PermissionRequestRead": request_sandbox_bypass_reason = from_union([from_none, from_str], obj.get("requestSandboxBypassReason")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) resolved_path = from_union([from_none, from_str], obj.get("resolvedPath")) + sandbox_path_grant = from_union([from_none, PermissionSandboxPathGrant.from_dict], obj.get("sandboxPathGrant")) return PermissionRequestRead( intention=intention, path=path, @@ -7568,6 +7611,7 @@ def from_dict(obj: Any) -> "PermissionRequestRead": request_sandbox_bypass_reason=request_sandbox_bypass_reason, tool_call_id=tool_call_id, resolved_path=resolved_path, + sandbox_path_grant=sandbox_path_grant, ) def to_dict(self) -> dict: @@ -7585,6 +7629,8 @@ def to_dict(self) -> dict: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) if self.resolved_path is not None: result["resolvedPath"] = from_union([from_none, from_str], self.resolved_path) + if self.sandbox_path_grant is not None: + result["sandboxPathGrant"] = from_union([from_none, lambda x: to_class(PermissionSandboxPathGrant, x)], self.sandbox_path_grant) return result @@ -7610,6 +7656,8 @@ class PermissionRequestShell: resolved_working_directory: str | None = None tool_call_id: str | None = None warning: str | None = None + # Experimental: this field is part of an experimental API and may change or be removed. + sandbox_path_grant: PermissionSandboxPathGrant | None = None @staticmethod def from_dict(obj: Any) -> "PermissionRequestShell": @@ -7630,6 +7678,7 @@ def from_dict(obj: Any) -> "PermissionRequestShell": resolved_working_directory = from_union([from_none, from_str], obj.get("resolvedWorkingDirectory")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) warning = from_union([from_none, from_str], obj.get("warning")) + sandbox_path_grant = from_union([from_none, PermissionSandboxPathGrant.from_dict], obj.get("sandboxPathGrant")) return PermissionRequestShell( can_offer_session_approval=can_offer_session_approval, commands=commands, @@ -7647,6 +7696,7 @@ def from_dict(obj: Any) -> "PermissionRequestShell": resolved_working_directory=resolved_working_directory, tool_call_id=tool_call_id, warning=warning, + sandbox_path_grant=sandbox_path_grant, ) def to_dict(self) -> dict: @@ -7677,6 +7727,8 @@ def to_dict(self) -> dict: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) if self.warning is not None: result["warning"] = from_union([from_none, from_str], self.warning) + if self.sandbox_path_grant is not None: + result["sandboxPathGrant"] = from_union([from_none, lambda x: to_class(PermissionSandboxPathGrant, x)], self.sandbox_path_grant) return result @@ -7901,6 +7953,8 @@ class PermissionRequestWrite: # Experimental: this field is part of an experimental API and may change or be removed. resolved_path: str | None = None tool_call_id: str | None = None + # Experimental: this field is part of an experimental API and may change or be removed. + sandbox_path_grant: PermissionSandboxPathGrant | None = None @staticmethod def from_dict(obj: Any) -> "PermissionRequestWrite": @@ -7915,6 +7969,7 @@ def from_dict(obj: Any) -> "PermissionRequestWrite": request_sandbox_bypass_reason = from_union([from_none, from_str], obj.get("requestSandboxBypassReason")) resolved_path = from_union([from_none, from_str], obj.get("resolvedPath")) tool_call_id = from_union([from_none, from_str], obj.get("toolCallId")) + sandbox_path_grant = from_union([from_none, PermissionSandboxPathGrant.from_dict], obj.get("sandboxPathGrant")) return PermissionRequestWrite( can_offer_session_approval=can_offer_session_approval, diff=diff, @@ -7926,6 +7981,7 @@ def from_dict(obj: Any) -> "PermissionRequestWrite": request_sandbox_bypass_reason=request_sandbox_bypass_reason, resolved_path=resolved_path, tool_call_id=tool_call_id, + sandbox_path_grant=sandbox_path_grant, ) def to_dict(self) -> dict: @@ -7947,6 +8003,8 @@ def to_dict(self) -> dict: result["resolvedPath"] = from_union([from_none, from_str], self.resolved_path) if self.tool_call_id is not None: result["toolCallId"] = from_union([from_none, from_str], self.tool_call_id) + if self.sandbox_path_grant is not None: + result["sandboxPathGrant"] = from_union([from_none, lambda x: to_class(PermissionSandboxPathGrant, x)], self.sandbox_path_grant) return result @@ -9369,26 +9427,36 @@ class SessionMcpServerStatusChangedData: "Payload of `session.mcp_server_status_changed` for one MCP server's status and optional failure error." server_name: str status: McpServerStatus + config_source: str | None = None error: str | None = None + error_classification: str | None = None @staticmethod def from_dict(obj: Any) -> "SessionMcpServerStatusChangedData": assert isinstance(obj, dict) server_name = from_str(obj.get("serverName")) status = parse_enum(McpServerStatus, obj.get("status")) + config_source = from_union([from_none, from_str], obj.get("configSource")) error = from_union([from_none, from_str], obj.get("error")) + error_classification = from_union([from_none, from_str], obj.get("errorClassification")) return SessionMcpServerStatusChangedData( server_name=server_name, status=status, + config_source=config_source, error=error, + error_classification=error_classification, ) def to_dict(self) -> dict: result: dict = {} result["serverName"] = from_str(self.server_name) result["status"] = to_enum(McpServerStatus, self.status) + if self.config_source is not None: + result["configSource"] = from_union([from_none, from_str], self.config_source) if self.error is not None: result["error"] = from_union([from_none, from_str], self.error) + if self.error_classification is not None: + result["errorClassification"] = from_union([from_none, from_str], self.error_classification) return result @@ -14172,6 +14240,14 @@ class PermissionRequestMemoryScope(Enum): USER = "user" +class PermissionSandboxPathGrantAccess(Enum): + "Access a sandbox path grant confers" + # Read access: the path is added to readonlyPaths. + READ = "read" + # Read and write access: the path is added to readwritePaths. + READ_WRITE = "readWrite" + + class PersistedBinaryImageType(Enum): "Binary result type discriminator. Use \"image\" for images and \"resource\" for other binary data." # Binary image data. @@ -15116,6 +15192,8 @@ def session_event_to_dict(x: SessionEvent) -> Any: "PermissionRequestedData", "PermissionResult", "PermissionRule", + "PermissionSandboxPathGrant", + "PermissionSandboxPathGrantAccess", "PersistedBinaryImage", "PersistedBinaryImageType", "PersistedBinaryResult", diff --git a/python/test_client.py b/python/test_client.py index f90b1a1865..4dcb581463 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -138,7 +138,7 @@ class TestClientShutdown: async def test_stop_requests_runtime_shutdown_for_owned_process(self): calls: list[str] = [] process = Mock() - process.poll.return_value = None + process.poll.side_effect = [None, 0] process.wait.return_value = 0 class Runtime: @@ -154,12 +154,9 @@ async def shutdown(self, *, timeout=None): await client.stop() assert calls == ["runtime.shutdown"] - # The runtime never self-exits after runtime.shutdown (it keeps its - # JSON-RPC server alive to send the response and leaves termination to - # the caller), so stop() terminates the owned process. The mocked - # process exits on terminate() (wait returns immediately), so we never - # escalate to kill(). - process.terminate.assert_called_once() + process.stdin.close.assert_called_once() + process.wait.assert_called_once_with(timeout=10) + process.terminate.assert_not_called() process.kill.assert_not_called() @pytest.mark.asyncio diff --git a/python/test_stdio_shutdown.py b/python/test_stdio_shutdown.py new file mode 100644 index 0000000000..1ce8388b1f --- /dev/null +++ b/python/test_stdio_shutdown.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Public lifecycle regressions for host cleanup after stdio EOF.""" + +import asyncio +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from copilot import CopilotClient, RuntimeConnection + +_RUNTIME = Path(__file__).parent.parent / "test" / "harness" / "stdio-shutdown-runtime.cjs" + +_EXIT_PROBE = """ +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const pid = Number(fs.readFileSync(process.argv[1], "utf8")); +const deadline = Date.now() + Number(process.argv[2]); +function check() { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === "ESRCH") return; + throw error; + } + // Force-stop does not reap children; a zombie has already exited. + if (process.platform === "linux") { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + // The parenthesized command name can itself contain spaces and parentheses. + if (stat.charAt(stat.lastIndexOf(")") + 2) === "Z") return; + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + } else if (process.platform !== "win32") { + const status = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }); + if (status.status === 0 && status.stdout.trim().startsWith("Z")) return; + } + if (Date.now() >= deadline) throw new Error("Child still running"); + setTimeout(check, 25); +} +check(); +""" + + +async def _assert_child_exited(directory: Path, wait_millis: int = 0) -> None: + node = shutil.which("node") + assert node is not None + result = await asyncio.to_thread( + subprocess.run, + [node, "-e", _EXIT_PROBE, str(directory / "pid"), str(wait_millis)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +async def test_exit_probe_distinguishes_running_and_exited_child(tmp_path, monkeypatch): + node = shutil.which("node") + assert node is not None + if sys.platform == "linux": + # Linux must not depend on procps options absent from Alpine's BusyBox ps. + (tmp_path / "node").symlink_to(node) + monkeypatch.setenv("PATH", str(tmp_path)) + child = subprocess.Popen([node, "-e", "setInterval(() => {}, 1000)"]) + try: + (tmp_path / "pid").write_text(str(child.pid)) + with pytest.raises(AssertionError, match="Child still running"): + await _assert_child_exited(tmp_path) + child.kill() + # Keep the Popen alive without wait/poll so POSIX retains an exited zombie. + await _assert_child_exited(tmp_path, wait_millis=5000) + finally: + child.kill() + child.wait(timeout=10) + + +def _client(directory: Path, mode: str) -> CopilotClient: + node = shutil.which("node") + assert node is not None, "Node.js is required for the shared SDK shutdown fixture" + return CopilotClient( + connection=RuntimeConnection.for_stdio( + path=node, + args=[ + str(_RUNTIME), + str(directory / "cleanup.jsonl"), + mode, + str(directory / "pid"), + ], + ) + ) + + +@pytest.mark.parametrize("mode", ["stop", "dispose"]) +@pytest.mark.timeout(60) +async def test_graceful_shutdown_waits_for_host_cleanup(tmp_path, mode): + client = _client(tmp_path, mode) + try: + if mode == "dispose": + async with client: + assert not (tmp_path / "cleanup.jsonl").exists() + else: + await client.start() + assert not (tmp_path / "cleanup.jsonl").exists() + await client.stop() + assert (tmp_path / "cleanup.jsonl").read_text() == '{"type":"span"}\n' + await _assert_child_exited(tmp_path) + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.rpc + finally: + await client.force_stop() + + +@pytest.mark.timeout(60) +async def test_force_stop_does_not_run_graceful_host_cleanup(tmp_path): + client = _client(tmp_path, "force") + try: + await client.start() + started = time.monotonic() + await asyncio.wait_for(client.force_stop(), timeout=30) + assert time.monotonic() - started < 10 + await _assert_child_exited(tmp_path, wait_millis=5000) + assert not (tmp_path / "cleanup.jsonl").exists() + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.rpc + finally: + await client.force_stop() + + +@pytest.mark.timeout(60) +async def test_stop_terminates_uncooperative_child_after_grace_period(tmp_path): + client = _client(tmp_path, "fallback") + try: + await client.start() + started = time.monotonic() + await asyncio.wait_for(client.stop(), timeout=45) + assert time.monotonic() - started >= 10 + assert (tmp_path / "cleanup.jsonl").read_text() == '{"type":"span"}\n' + await _assert_child_exited(tmp_path) + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.rpc + finally: + await client.force_stop() + + +@pytest.mark.timeout(60) +async def test_force_stop_cleans_up_after_startup_failure(tmp_path): + client = _client(tmp_path, "start-failure") + try: + with pytest.raises(RuntimeError, match="[Pp]rotocol"): + await client.start() + await asyncio.wait_for(client.force_stop(), timeout=30) + await _assert_child_exited(tmp_path, wait_millis=5000) + assert not (tmp_path / "cleanup.jsonl").exists() + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.rpc + finally: + await client.force_stop() diff --git a/rust/README.md b/rust/README.md index 1a3bc2fce5..705162b71a 100644 --- a/rust/README.md +++ b/rust/README.md @@ -51,6 +51,14 @@ Your Application The SDK manages the CLI process lifecycle: spawning, health-checking, and graceful shutdown. Communication uses [JSON-RPC 2.0](https://www.jsonrpc.org/specification) over stdin/stdout with `Content-Length` framing (the same protocol used by LSP). TCP transport is also supported. +Await `client.stop()` to flush host-owned telemetry: after requesting runtime +shutdown, the SDK closes its owned stdio child's stdin and waits up to 10 seconds +for cleanup and exit before falling back to termination. The shutdown RPC and +final process reap each have a separate 10-second bound. `force_stop()` and +dropping the last client remain immediate termination paths, not telemetry-flush +guarantees. External servers and in-process hosts retain their existing shutdown +behavior. + ## API Reference ### Client diff --git a/rust/build/in_process.rs b/rust/build/in_process.rs index 8037daa22c..00f9450ae6 100644 --- a/rust/build/in_process.rs +++ b/rust/build/in_process.rs @@ -17,8 +17,19 @@ pub(crate) fn main() { println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); - println!("cargo:rerun-if-changed=cli-version.txt"); - println!("cargo:rerun-if-changed=cli-version-in-process.txt"); + + // Declare only the version sources that exist. Cargo treats a missing + // `rerun-if-changed` path as always stale, so for a local package (a path + // dependency, vendored copy, or source checkout) an absent snapshot or + // `../nodejs/package.json` would rerun this script, and recompile the + // crate, on every build. + let manifest_dir = + PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set")); + for snapshot in ["cli-version.txt", "cli-version-in-process.txt"] { + if manifest_dir.join(snapshot).is_file() { + println!("cargo:rerun-if-changed={snapshot}"); + } + } if std::env::var_os("CARGO_FEATURE_LOCAL_RUNTIME").is_some() && std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_none() @@ -29,18 +40,9 @@ pub(crate) fn main() { return; } - // Only declare the package metadata rerun when it actually exists. - // Cargo treats `rerun-if-changed` for a missing path as "always rerun" - // — so unconditionally declaring this on consumers without a sibling - // `nodejs/` (vendored slots, published crates) would force build.rs - // to re-run on every `cargo build` even when nothing has changed. // The package file is only the source-of-truth in this repo's // contributor builds; everywhere else the snapshot files are canonical. - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); - let package_json = Path::new(&manifest_dir) - .join("..") - .join("nodejs") - .join("package.json"); + let package_json = manifest_dir.join("..").join("nodejs").join("package.json"); if package_json.is_file() { println!("cargo:rerun-if-changed={}", package_json.display()); } diff --git a/rust/scripts/check-fresh-rebuild.sh b/rust/scripts/check-fresh-rebuild.sh new file mode 100755 index 0000000000..387c809e6f --- /dev/null +++ b/rust/scripts/check-fresh-rebuild.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# Fail when an unchanged rebuild reruns build.rs or recompiles the crate. +# +# Cargo checks `rerun-if-changed` paths only for local packages, such as a +# source checkout, a path dependency, or a vendored copy, and treats a missing +# path as always stale. Declaring an absent file therefore reruns build.rs and +# recompiles the crate and its dependents on every build, while registry and +# git consumers are unaffected. Checking this crate from its own directory +# exercises the same local-package behavior as a path dependency. +# +# Usage: scripts/check-fresh-rebuild.sh [cargo check options] +# Example: scripts/check-fresh-rebuild.sh --no-default-features --features test-support,derive + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." + +cargo check "$@" +if ! output="$(cargo check --verbose --color never "$@" 2>&1)"; then + printf '%s\n' "${output}" >&2 + exit 1 +fi +if ! grep -Eq '^ *Fresh github-copilot-sdk v' <<<"${output}"; then + grep -E '^ *[A-Z][a-z]+ github-copilot-sdk v' <<<"${output}" >&2 || printf '%s\n' "${output}" >&2 + echo "error: an unchanged rebuild did not keep github-copilot-sdk fresh" >&2 + exit 1 +fi +echo "An unchanged rebuild kept github-copilot-sdk fresh." diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 188c80ed17..a947b0f7e6 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -254,6 +254,8 @@ pub mod rpc_methods { pub const SESSION_SANDBOX_GETENFORCEMENTSTATUS: &str = "session.sandbox.getEnforcementStatus"; /// `session.sandbox.disableForSession` pub const SESSION_SANDBOX_DISABLEFORSESSION: &str = "session.sandbox.disableForSession"; + /// `session.sandbox.grantPathForRequest` + pub const SESSION_SANDBOX_GRANTPATHFORREQUEST: &str = "session.sandbox.grantPathForRequest"; /// `session.sendSystemNotification` pub const SESSION_SENDSYSTEMNOTIFICATION: &str = "session.sendSystemNotification"; /// `session.abort` @@ -18170,6 +18172,39 @@ pub struct SandboxEnforcementStatus { pub required: bool, } +/// Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt. +/// +///

+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxGrantPathForRequestRequest { + /// Optional attribution for the permission decision. + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_context: Option, + /// Identifier of the exact pending sandbox escalation permission request whose sandboxPathGrant to accept. + pub request_id: RequestId, +} + +/// Result of accepting a sandbox path grant. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxGrantPathForRequestResult { + /// Whether this call resolved the pending request and added the path to the session's sandbox policy. + pub success: bool, +} + /// Whether this host can run one sandbox policy feature. A session whose effective policy uses an unsupported feature fails each sandboxed command with `reason`. /// ///
@@ -20383,6 +20418,13 @@ pub struct SessionOpenOptions { #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_config: Option, /// Origin of the sandbox choice. Settings-derived origins (never_configured, user_enabled, user_disabled, repository_policy) let managed policy floor a host preference; explicit below-floor changes remain policy conflicts unless a session opt-out is authorized. Also used for telemetry provenance. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
#[serde(skip_serializing_if = "Option::is_none")] pub sandbox_config_source: Option, /// Capabilities enabled for this session. @@ -21885,6 +21927,13 @@ pub struct SessionUpdateOptionsParams { #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_config: Option, /// Origin of the sandbox choice. Settings-derived origins (never_configured, user_enabled, user_disabled, repository_policy) let managed policy floor a host preference; explicit below-floor changes remain policy conflicts unless a session opt-out is authorized. Also used for telemetry provenance. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
#[serde(skip_serializing_if = "Option::is_none")] pub sandbox_config_source: Option, /// Replaces the session's capability set with the given list. Use to enable or disable capabilities mid-session (e.g., remove `memory` for reproducible scripted runs). Omit the field to leave the existing capability set unchanged. @@ -27581,6 +27630,21 @@ pub struct SessionSandboxDisableForSessionResult { pub success: bool, } +/// Result of accepting a sandbox path grant. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSandboxGrantPathForRequestResult { + /// Whether this call resolved the pending request and added the path to the session's sandbox policy. + pub success: bool, +} + /// Result of aborting the current turn /// ///
@@ -40772,7 +40836,7 @@ pub enum ResponseFormatType { JsonSchema, } -/// Origin of the sandbox choice supplied by the host. Settings-derived origins let managed policy floor the host preference; do not tag explicit session overrides as settings-derived. +/// Origin of the sandbox choice supplied by the host. This value describes preference or session intent; it does not authorize bypassing managed policy. /// ///
/// @@ -40791,7 +40855,7 @@ pub enum SandboxConfigSource { /// The user's persisted settings disabled the sandbox. #[serde(rename = "user_disabled")] UserDisabled, - /// A command-line flag selected the sandbox state for this session. + /// An explicit session-scoped choice selected the sandbox state, such as a command-line flag. #[serde(rename = "session_flag")] SessionFlag, /// The user disabled the sandbox for the current session. diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 2c915334c1..24704dde98 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -11583,6 +11583,42 @@ impl<'a> SessionRpcSandbox<'a> { .await?; Ok(serde_json::from_value(_value)?) } + + /// Adds the path offered by a pending sandbox escalation permission request's sandboxPathGrant to the session's sandbox policy and approves the request, so the blocked operation re-runs inside the sandbox rather than outside it. The request is rejected unless the exact request is still pending, carries a sandboxPathGrant, and the grant still takes effect under the current managed policy. Does not persist the path; hosts that store sandbox settings save it themselves. + /// + /// Wire method: `session.sandbox.grantPathForRequest`. + /// + /// # Parameters + /// + /// * `params` - Request to accept the sandbox path grant offered on an active sandbox escalation permission prompt. + /// + /// # Returns + /// + /// Result of accepting a sandbox path grant. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn grant_path_for_request( + &self, + params: SandboxGrantPathForRequestRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_SANDBOX_GRANTPATHFORREQUEST, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } } /// `session.schedule.*` RPCs. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index e1f798c858..1181f6fdd3 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -5346,6 +5346,29 @@ pub struct PermissionRequestShellPossibleUrl { pub url: String, } +/// A sandbox filesystem policy edit that would let a blocked operation run inside the sandbox instead of outside it. Offered only on a sandbox escalation request whose denial adding this path lifts, and only when managed policy permits the grant. A host accepts it with session.sandbox.grantPathForRequest, which adds the path to the session's sandbox policy and re-runs the operation sandboxed; a host that persists sandbox settings may also save the path there. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionSandboxPathGrant { + /// Which access the grant confers, and so which policy list the path is added to + pub access: PermissionSandboxPathGrantAccess, + /// The path the sandbox refused, present only when it differs from path. That happens when a write under a read-only folder moves the folder to the read-write paths, when a path that does not exist yet is granted through its nearest existing folder, because the OS sandbox cannot grant a path before it exists, and when either is spelled through a symlink, because a grant covers its path as written, so path is then the resolved location. Hosts should then name path in the offer, since the denial names this one. + #[serde(skip_serializing_if = "Option::is_none")] + pub denied_path: Option, + /// Absolute path to add to the sandbox filesystem policy + pub path: String, + /// readonlyPaths entries the grant removes, exactly as written in the policy, because a read-only entry for the same location would otherwise keep the path read-only. A host that persists the path must remove these entries from its stored readonlyPaths too. + #[serde(skip_serializing_if = "Option::is_none")] + pub removed_readonly_paths: Option>, +} + /// Shell command permission request #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -5401,6 +5424,16 @@ pub struct PermissionRequestShell { ///
#[serde(skip_serializing_if = "Option::is_none")] pub resolved_working_directory: Option, + /// Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_path_grant: Option, /// Tool call ID that triggered this permission request #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, @@ -5445,6 +5478,16 @@ pub struct PermissionRequestWrite { ///
#[serde(skip_serializing_if = "Option::is_none")] pub resolved_path: Option, + /// Sandbox policy edit that would let the write run inside the sandbox. Only present when requestSandboxBypass is true. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_path_grant: Option, /// Tool call ID that triggered this permission request #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, @@ -5479,6 +5522,16 @@ pub struct PermissionRequestRead { ///
#[serde(skip_serializing_if = "Option::is_none")] pub resolved_path: Option, + /// Sandbox policy edit that would let the read run inside the sandbox. Only present when requestSandboxBypass is true. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_path_grant: Option, /// Tool call ID that triggered this permission request #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, @@ -5833,6 +5886,16 @@ pub struct PermissionPromptRequestCommands { /// True when the escalation is a permissive retry that keeps the sandbox and network policy attached while recording file and process accesses instead of blocking them. #[serde(skip_serializing_if = "Option::is_none")] pub request_sandbox_permissive: Option, + /// Sandbox policy edit that would let the command run inside the sandbox. Only present when requestSandboxBypass is true. + /// + ///
+ /// + /// **Experimental.** This type is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. + /// + ///
+ #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_path_grant: Option, /// Tool call ID that triggered this permission request #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, @@ -7635,9 +7698,15 @@ pub struct SessionMcpServersLoadedData { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionMcpServerStatusChangedData { + /// Runtime configuration provenance for a failed connection, or unknown when unavailable. Additional string values may be introduced. + #[serde(skip_serializing_if = "Option::is_none")] + pub config_source: Option, /// Error message if the server entered a failed state #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// Runtime-produced classification for the final failed connection; unclassified means no classification was supplied. Additional string values may be introduced. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_classification: Option, /// Name of the MCP server whose status changed pub server_name: String, /// Connection status: connected, failed, needs-auth, pending, disabled, stopped, or not_configured @@ -9906,6 +9975,21 @@ pub enum PermissionRequestShellKind { Shell, } +/// Access a sandbox path grant confers +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionSandboxPathGrantAccess { + /// Read access: the path is added to readonlyPaths. + #[serde(rename = "read")] + Read, + /// Read and write access: the path is added to readwritePaths. + #[serde(rename = "readWrite")] + ReadWrite, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + /// Permission kind discriminator #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum PermissionRequestWriteKind { diff --git a/rust/src/jsonrpc.rs b/rust/src/jsonrpc.rs index 9fbe0394bc..90c9c2938d 100644 --- a/rust/src/jsonrpc.rs +++ b/rust/src/jsonrpc.rs @@ -356,10 +356,15 @@ impl JsonRpcClient { if let Some(task) = self.read_task.lock().take() { task.abort(); } + self.close_writer(); + self.pending_requests.write().clear(); + } + + /// Release stdin while continuing to drain the owned child's final stdout. + pub(crate) fn close_writer(&self) { if let Some(task) = self.write_task.lock().take() { task.abort(); } - self.pending_requests.write().clear(); } pub(crate) fn connection_closed_token(&self) -> CancellationToken { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4de9cd8bc9..1b797f8855 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1207,6 +1207,8 @@ impl std::fmt::Debug for Client { struct ClientInner { child: parking_lot::Mutex>, + owns_stdio: bool, + force_stop_requested: tokio_util::sync::CancellationToken, process_tree: parking_lot::Mutex>, #[cfg(feature = "in-process")] /// In-process FFI runtime host, set only for [`Transport::InProcess`]. @@ -1254,6 +1256,19 @@ struct ClientInner { startup_timings: OnceLock, } +struct StdioShutdownGuard<'a> { + client: &'a Client, + armed: bool, +} + +impl Drop for StdioShutdownGuard<'_> { + fn drop(&mut self) { + if self.armed { + self.client.force_stop(); + } + } +} + impl Client { /// Start a CLI server process with the given options. /// @@ -1461,6 +1476,7 @@ impl Client { effective_connection_token.clone(), options.mode, options.client_info, + false, )? } Transport::Tcp { @@ -1495,6 +1511,7 @@ impl Client { effective_connection_token.clone(), options.mode, options.client_info, + false, )? } Transport::Stdio => { @@ -1519,6 +1536,7 @@ impl Client { effective_connection_token.clone(), options.mode, options.client_info, + true, )? } Transport::InProcess => { @@ -1587,6 +1605,7 @@ impl Client { effective_connection_token.clone(), options.mode, options.client_info, + false, )?; *client.inner.ffi_host.lock() = Some(shared); client @@ -1717,6 +1736,7 @@ impl Client { None, ClientMode::default(), None, + false, ) } @@ -1745,6 +1765,7 @@ impl Client { None, ClientMode::default(), None, + false, ) } @@ -1794,6 +1815,7 @@ impl Client { None, ClientMode::default(), None, + false, ) } @@ -1822,6 +1844,7 @@ impl Client { token, ClientMode::default(), None, + false, ) } @@ -1850,6 +1873,7 @@ impl Client { None, ClientMode::default(), None, + false, ) } @@ -1889,6 +1913,7 @@ impl Client { None, ClientMode::default(), client_info, + false, ) } @@ -1910,6 +1935,7 @@ impl Client { effective_connection_token: Option, mode: ClientMode, client_info: Option, + owns_stdio: bool, ) -> Result { let setup_start = Instant::now(); let (request_tx, request_rx) = mpsc::unbounded_channel::(); @@ -1935,6 +1961,8 @@ impl Client { let client = Self { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(child), + owns_stdio, + force_stop_requested: tokio_util::sync::CancellationToken::new(), process_tree: parking_lot::Mutex::new(process_tree), #[cfg(feature = "in-process")] ffi_host: parking_lot::Mutex::new(None), @@ -2815,8 +2843,10 @@ impl Client { /// Cooperatively shut down the client and the CLI child process. /// /// Walks every still-registered session and sends `session.detach` - /// for each one, asks SDK-owned runtimes to shut down, terminates the - /// Windows-owned CLI Job Object when present, and reaps the root process. + /// for each one and asks SDK-owned runtimes to shut down. For an owned stdio + /// child, closes stdin and waits up to 10 seconds for host cleanup and exit + /// before falling back to termination. Terminates the Windows-owned CLI + /// Job Object when present and bounds the final root-process reap to 10 seconds. /// Errors from per-session detaches, runtime shutdown, and final process /// termination are collected into [`StopErrors`] rather than /// short-circuiting on the first failure — so callers see the full picture @@ -2824,7 +2854,7 @@ impl Client { /// /// If you have already called [`Session::disconnect`] on every /// session this client created, the per-session destroy step is a - /// no-op (the router map is empty); only the child-kill remains. + /// no-op (the router map is empty); runtime and process shutdown still run. /// /// [`Session::disconnect`]: crate::session::Session::disconnect /// @@ -2839,6 +2869,8 @@ impl Client { /// or call `stop()` again with a fresh future. The documented /// `tokio::time::timeout(..., client.stop())` pattern in the example /// below uses `force_stop` as the fallback for exactly this case. + /// Once owned-stdio exit waiting begins, cancelling `stop()` forcibly + /// terminates that child. Concurrent `force_stop()` also interrupts the wait. pub async fn stop(&self) -> std::result::Result<(), StopErrors> { let pid = self.pid(); info!(pid = ?pid, "stopping CLI process"); @@ -2903,10 +2935,43 @@ impl Client { } } - let child = self.inner.child.lock().take(); - let process_tree = self.inner.process_tree.lock().take(); *self.inner.state.lock() = ConnectionState::Disconnected; *self.inner.models_cache.lock() = Arc::new(tokio::sync::OnceCell::new()); + if self.inner.owns_stdio && self.inner.child.lock().is_some() { + let mut guard = StdioShutdownGuard { + client: self, + armed: true, + }; + // The host flushes telemetry after stdin EOF, not after the shutdown RPC. + // Drop ChildStdin but retain the reader and process tree until exit. + self.inner.rpc.close_writer(); + let wait = async { + tokio::select! { + result = std::future::poll_fn(|cx| { + let mut child = self.inner.child.lock(); + let Some(child) = child.as_mut() else { + return std::task::Poll::Ready(Ok(())); + }; + // Child::wait is cancel-safe; retain ownership between polls so + // synchronous force_stop can still terminate the child and its tree. + std::future::Future::poll(std::pin::pin!(child.wait()), cx) + .map(|result| result.map(|_| ())) + }) => result, + _ = self.inner.force_stop_requested.cancelled() => Ok(()), + } + }; + match tokio::time::timeout(RUNTIME_SHUTDOWN_TIMEOUT, wait).await { + Ok(Ok(_)) => {} + Ok(Err(error)) => errors.push(error.into()), + Err(_) => warn!( + timeout = ?RUNTIME_SHUTDOWN_TIMEOUT, + "CLI did not exit after stdin EOF; terminating" + ), + } + guard.armed = false; + } + let child = self.inner.child.lock().take(); + let process_tree = self.inner.process_tree.lock().take(); if let Some(process_tree) = process_tree && let Err(error) = process_tree.terminate() { @@ -2916,14 +2981,16 @@ impl Client { match child.try_wait() { Ok(Some(_status)) => {} Ok(None) => { - // The runtime completes all cleanup before responding to - // runtime.shutdown and then leaves termination to us; it - // deliberately keeps its JSON-RPC server alive to send the - // response and never self-exits. Waiting for a self-exit - // that will never come just wastes time, so terminate the - // child immediately. - if let Err(e) = child.kill().await { - errors.push(e.into()); + match tokio::time::timeout(RUNTIME_SHUTDOWN_TIMEOUT, child.kill()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => errors.push(error.into()), + Err(_) => errors.push( + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "CLI process reap timed out during Client::stop", + ) + .into(), + ), } } Err(e) => errors.push(e.into()), @@ -2991,6 +3058,7 @@ impl Client { { error!(pid = ?pid, error = %e, "failed to send kill signal"); } + self.inner.force_stop_requested.cancel(); self.inner.rpc.force_close(); #[cfg(feature = "in-process")] { @@ -3752,6 +3820,7 @@ mod tests { None, ClientMode::default(), None, + false, ) .unwrap(); @@ -3826,6 +3895,8 @@ mod tests { Client { inner: Arc::new(ClientInner { child: parking_lot::Mutex::new(None), + owns_stdio: false, + force_stop_requested: tokio_util::sync::CancellationToken::new(), process_tree: parking_lot::Mutex::new(None), #[cfg(feature = "in-process")] ffi_host: parking_lot::Mutex::new(None), diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index b39506ec02..37b5383583 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -651,6 +651,252 @@ async fn remote_resource_mismatch_is_observable_before_resume() { ); } +#[tokio::test] +async fn stdio_stop_waits_for_eof_cleanup() { + let fake = ShutdownCli::new("stop"); + let client = Client::start(fake.options()).await.expect("start fake CLI"); + let pid = client.pid().expect("owned child"); + tokio::time::timeout(Duration::from_secs(40), client.stop()) + .await + .expect("shutdown RPC, graceful exit, and reap must be bounded") + .expect("stop client"); + assert_eq!( + std::fs::read_to_string(&fake.marker).expect("EOF cleanup"), + "{\"type\":\"span\"}\n" + ); + assert!(!process_is_alive(pid).await); + client.stop().await.expect("repeated stop"); +} + +#[tokio::test] +async fn stdio_stop_drains_stdout_during_eof_cleanup() { + let fake = FakeCli::new(); + let client = Client::start(fake.client_options_with_behavior("token", "shutdown-output")) + .await + .expect("start fake CLI"); + tokio::time::timeout(Duration::from_secs(40), client.stop()) + .await + .expect("shutdown RPC, graceful exit, and reap must be bounded") + .expect("stop client"); + assert_eq!( + std::fs::read_to_string(fake.capture_path.with_extension("cleanup")) + .expect("cleanup after draining final stdout"), + "flushed" + ); +} + +#[tokio::test] +async fn stdio_stop_preserves_shutdown_error_after_eof_cleanup() { + let fake = FakeCli::new(); + let client = Client::start(fake.client_options_with_behavior("token", "shutdown-error")) + .await + .expect("start fake CLI"); + let errors = tokio::time::timeout(Duration::from_secs(40), client.stop()) + .await + .expect("shutdown RPC, graceful exit, and reap must be bounded") + .expect_err("shutdown error must be returned"); + assert!(errors.to_string().contains("shutdown rejected"), "{errors}"); + assert_eq!( + std::fs::read_to_string(fake.capture_path.with_extension("cleanup")) + .expect("EOF cleanup despite RPC failure"), + "flushed" + ); +} + +#[tokio::test] +async fn stdio_stop_bounds_unresponsive_shutdown_and_eof() { + let fake = FakeCli::new(); + let client = Client::start(fake.client_options_with_behavior("token", "ignore-shutdown")) + .await + .expect("start fake CLI"); + let pid = client.pid().expect("owned child"); + let start = std::time::Instant::now(); + let errors = tokio::time::timeout(Duration::from_secs(40), client.stop()) + .await + .expect("10s RPC + 10s graceful exit + 10s reap must be bounded") + .expect_err("unanswered shutdown must report its timeout"); + assert!(errors.to_string().contains("timed out"), "{errors}"); + assert!(start.elapsed() >= Duration::from_secs(20)); + assert!(!process_is_alive(pid).await); + assert!(!fake.capture_path.with_extension("cleanup").exists()); +} + +#[tokio::test] +async fn stdio_stop_terminates_child_that_does_not_exit_after_eof() { + let fake = ShutdownCli::new("fallback"); + let client = Client::start(fake.options()).await.expect("start fake CLI"); + let pid = client.pid().expect("owned child"); + let start = std::time::Instant::now(); + tokio::time::timeout(Duration::from_secs(40), client.stop()) + .await + .expect("shutdown RPC, graceful exit, and reap must be bounded") + .expect("fallback termination succeeds"); + assert!(start.elapsed() >= Duration::from_secs(10)); + assert!(!process_is_alive(pid).await); + assert!( + fake.marker.exists(), + "fixture must observe shutdown and EOF" + ); + assert_eq!( + std::fs::read_to_string(&fake.marker).expect("EOF cleanup"), + "{\"type\":\"span\"}\n" + ); +} + +#[tokio::test] +async fn stdio_force_stop_interrupts_graceful_exit_wait() { + let fake = ShutdownCli::new("fallback"); + let client = Client::start(fake.options()).await.expect("start fake CLI"); + let pid = client.pid().expect("owned child"); + let stopping = tokio::spawn({ + let client = client.clone(); + async move { client.stop().await } + }); + wait_for_cleanup_marker(&fake.marker).await; + + client.force_stop(); + tokio::time::timeout(Duration::from_secs(5), async { + stopping.await.expect("stop task").expect("stop client"); + wait_for_process_exit(pid).await; + }) + .await + .expect("force stop must interrupt grace before its 10-second deadline"); +} + +#[tokio::test] +async fn stdio_cancelled_graceful_exit_wait_terminates_child() { + let fake = ShutdownCli::new("fallback"); + let client = Client::start(fake.options()).await.expect("start fake CLI"); + let pid = client.pid().expect("owned child"); + let stopping = tokio::spawn({ + let client = client.clone(); + async move { client.stop().await } + }); + wait_for_cleanup_marker(&fake.marker).await; + + stopping.abort(); + assert!( + stopping + .await + .expect_err("stop task cancelled") + .is_cancelled() + ); + wait_for_process_exit(pid).await; + assert!(client.pid().is_none()); +} + +async fn wait_for_cleanup_marker(path: &std::path::Path) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match std::fs::read_to_string(path) { + Ok(content) if content == "{\"type\":\"span\"}\n" => return, + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("read cleanup marker: {error}"), + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("fixture must reach EOF cleanup during graceful shutdown"); +} + +#[tokio::test] +async fn stdio_force_stop_and_drop_remain_immediate() { + for force in [true, false] { + let fake = ShutdownCli::new("force"); + let client = Client::start(fake.options()).await.expect("start fake CLI"); + let pid = client.pid().expect("owned child"); + if force { + client.force_stop(); + assert!(client.pid().is_none()); + } + drop(client); + wait_for_process_exit(pid).await; + assert!(!fake.marker.exists()); + } +} + +#[tokio::test] +async fn stdio_startup_failure_terminates_owned_child() { + let fake = ShutdownCli::new("start-failure"); + let result = tokio::time::timeout(Duration::from_secs(5), Client::start(fake.options())) + .await + .expect("startup failure must not await graceful shutdown"); + assert!(result.is_err()); + let pid = std::fs::read_to_string(&fake.pid_file) + .expect("read child PID") + .parse() + .expect("parse child PID"); + wait_for_process_exit(pid).await; + assert!(!fake.marker.exists()); +} + +struct ShutdownCli { + dir: TempDir, + marker: PathBuf, + pid_file: PathBuf, + mode: &'static str, +} + +impl ShutdownCli { + fn new(mode: &'static str) -> Self { + let dir = tempfile::tempdir().expect("create shutdown fixture directory"); + Self { + marker: dir.path().join("cleanup.jsonl"), + pid_file: dir.path().join("child.pid"), + dir, + mode, + } + } + + fn options(&self) -> ClientOptions { + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../test/harness/stdio-shutdown-runtime.cjs"); + ClientOptions::new() + .with_program(CliProgram::Path("node".into())) + .with_prefix_args([ + script.into_os_string(), + self.marker.clone().into_os_string(), + self.mode.into(), + self.pid_file.clone().into_os_string(), + ]) + .with_cwd(self.dir.path()) + .with_use_logged_in_user(false) + .with_transport(Transport::Stdio) + } +} + +async fn process_is_alive(pid: u32) -> bool { + let output = tokio::process::Command::new("node") + .args([ + "-e", + "try { process.kill(Number(process.argv[1]), 0); } catch (e) { if (e.code === 'ESRCH') process.exit(3); throw e; }", + &pid.to_string(), + ]) + .output() + .await + .expect("query child process"); + match output.status.code() { + Some(0) => true, + Some(3) => false, + _ => panic!( + "query child process: {}", + String::from_utf8_lossy(&output.stderr) + ), + } +} + +async fn wait_for_process_exit(pid: u32) { + tokio::time::timeout(Duration::from_secs(5), async { + while process_is_alive(pid).await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("owned child must exit promptly"); +} + struct FakeCli { _dir: TempDir, script_path: PathBuf, @@ -776,6 +1022,29 @@ process.stdin.on("data", chunk => { processBuffer(); }); process.stdin.resume(); +if (["ignore-shutdown", "shutdown-error", "shutdown-output"].includes(behavior)) { + const keepAlive = setInterval(() => {}, 1000); + process.stdin.on("end", () => { + if (behavior === "ignore-shutdown") return; + if (behavior === "shutdown-output") { + const body = JSON.stringify({ + jsonrpc: "2.0", + method: "shutdown.output", + params: { output: "x".repeat(1024 * 1024) }, + }); + process.stdout.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`, error => { + if (error) throw error; + fs.writeFileSync(captureFile.replace(/\.json$/, ".cleanup"), "flushed"); + clearInterval(keepAlive); + }); + return; + } + setTimeout(() => { + fs.writeFileSync(captureFile.replace(/\.json$/, ".cleanup"), "flushed"); + clearInterval(keepAlive); + }, 100); + }); +} function processBuffer() { while (true) { @@ -804,6 +1073,11 @@ function handleMessage(message) { writeResponse(message.id, { ok: true, protocolVersion: 3, version: "fake" }); return; } + if (message.method === "runtime.shutdown" && behavior === "ignore-shutdown") return; + if (message.method === "runtime.shutdown" && behavior === "shutdown-error") { + writeMessage({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "shutdown rejected" } }); + return; + } if (message.method === "ping") { writeResponse(message.id, { message: "pong", protocolVersion: 3, timestamp: Date.now() }); return; diff --git a/rust/tests/e2e/copilot_request_handler.rs b/rust/tests/e2e/copilot_request_handler.rs index 4e4004b9cf..9fd7af5ae8 100644 --- a/rust/tests/e2e/copilot_request_handler.rs +++ b/rust/tests/e2e/copilot_request_handler.rs @@ -15,6 +15,9 @@ //! transport error rather than hanging the turn. //! - `observes_runtime_driven_cancel` — a handler that blocks until the consumer //! aborts observes the runtime-driven cancellation via `ctx.cancel`. +//! - `withdrawn_running_turn_prompt_leaves_persisted_history` — taking back +//! the prompt of a turn the model has not answered removes it from the +//! persisted session, so a resume does not bring it back. use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; @@ -24,7 +27,7 @@ use async_trait::async_trait; use bytes::Bytes; use futures_util::{SinkExt, StreamExt}; use github_copilot_sdk::handler::ApproveAllHandler; -use github_copilot_sdk::rpc::{SendMode, SendRequest}; +use github_copilot_sdk::rpc::{QueueWithdrawMessageRequest, SendMode, SendRequest}; use github_copilot_sdk::session_events::{AssistantMessageData, UserMessageData}; use github_copilot_sdk::{ CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError, @@ -947,3 +950,103 @@ async fn observes_runtime_driven_cancel() { }) .await; } + +// --------------------------------------------------------------------------- +// Scenario 3c: take-back of a running turn's prompt. While the handler holds +// the only model request, the withdrawal interrupts the turn and must remove +// its events from the persisted session, not only from the live one. +// --------------------------------------------------------------------------- + +/// Event types a withdrawn turn records before any model output, all of which +/// the removal must take with the prompt. +const WITHDRAWN_TURN_EVENT_TYPES: &[&str] = &[ + "user.message", + "assistant.turn_start", + "assistant.turn_end", + "abort", +]; + +#[tokio::test] +async fn withdrawn_running_turn_prompt_leaves_persisted_history() { + if super::support::skip_inprocess("LLM inference providers are process-global in-process") { + return; + } + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let handler = Arc::new(CancellingHandler::default()); + let client = ctx.start_llm_client(handler.clone(), &[]).await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + let options = say_ok(); + let prompt = options.prompt.clone(); + let message_id = session.send(options).await.expect("send"); + wait_for_flag(&handler.inference_entered, "inference entered").await; + let result = session + .rpc() + .queue() + .withdraw_message(QueueWithdrawMessageRequest { + message_id, + expected_prompt: prompt.clone(), + }) + .await + .expect("withdraw the running turn's prompt"); + assert!( + result.removed && result.interrupted, + "expected the running turn's prompt to be withdrawn, got {result:?}" + ); + let live_prompts = session + .get_events() + .await + .expect("live events") + .iter() + .filter_map(|event| event.typed_data::()) + .map(|data| data.content) + .collect::>(); + assert!( + !live_prompts.contains(&prompt), + "the withdrawn prompt is still in the live session" + ); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + + // A resume replays this log, so the prompt must not be in it. + let log_path = ctx + .home_dir() + .join("session-state") + .join(session_id.as_str()) + .join("events.jsonl"); + let log = std::fs::read_to_string(&log_path).expect("read persisted events"); + let persisted = log + .lines() + .map(|line| serde_json::from_str::(line).expect("persisted event JSON")) + .collect::>(); + assert!( + persisted + .iter() + .any(|event| event["type"] == "session.start"), + "expected {} to hold the session", + log_path.display() + ); + // The whole turn is removed, not only its prompt: an orphaned turn + // event left behind would be replayed by a resume too. + let persisted_turn_events = persisted + .iter() + .filter(|event| { + event["type"] + .as_str() + .is_some_and(|kind| WITHDRAWN_TURN_EVENT_TYPES.contains(&kind)) + }) + .collect::>(); + assert!( + persisted_turn_events.is_empty(), + "the withdrawn turn is still persisted: {persisted_turn_events:?}" + ); + }) + }) + .await; +} diff --git a/rust/tests/e2e/rpc_workspace_checkpoints.rs b/rust/tests/e2e/rpc_workspace_checkpoints.rs index dab711b0f8..b781f61b2f 100644 --- a/rust/tests/e2e/rpc_workspace_checkpoints.rs +++ b/rust/tests/e2e/rpc_workspace_checkpoints.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use github_copilot_sdk::ResumeSessionConfig; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::rpc::{ - WorkspaceDiffFileChangeType, WorkspaceDiffMode, WorkspacesDiffRequest, + SessionsSaveRequest, WorkspaceDiffFileChangeType, WorkspaceDiffMode, WorkspacesDiffRequest, WorkspacesReadCheckpointRequest, WorkspacesReadFileRequest, WorkspacesSaveLargePasteRequest, WorkspacesWorkspaceDetailsHostType, }; @@ -189,7 +189,9 @@ fn normalize_path(path: &str) -> String { #[tokio::test] async fn should_record_git_context_in_a_new_session_workspace() { - super::support::with_e2e_context_no_snapshot(|ctx| { + // Reuse session.rs::should_have_stateful_conversation's cassette to create persisted history; + // keep the first-turn prompt below aligned with that owner. + super::support::with_e2e_context("session", "should_have_stateful_conversation", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); init_git_repository(ctx.work_dir()); @@ -230,23 +232,18 @@ async fn should_record_git_context_in_a_new_session_workspace() { assert_eq!(workspace.repository.as_deref(), Some("test-org/test-repo")); assert_eq!(workspace.branch.as_deref(), Some("feature/test-branch")); - // The repository was created exactly at the session's working - // directory, so the recorded root is that directory and not an - // ancestor of it. - let git_root = normalize_path(workspace.git_root.as_deref().expect("git root")); + // Git may expand Windows 8.3 paths or resolve platform directory aliases. + // Compare directory identities, not the spellings returned by Git and the host. + let work_dir = ctx.work_dir().canonicalize().expect("canonical work dir"); + let git_root = Path::new(workspace.git_root.as_deref().expect("git root")) + .canonicalize() + .expect("canonical git root"); + assert_eq!(git_root, work_dir); assert_eq!( - Some(git_root.as_str()), - workspace.cwd.as_deref().map(normalize_path).as_deref() - ); - let work_dir_name = ctx - .work_dir() - .file_name() - .expect("work dir name") - .to_string_lossy() - .into_owned(); - assert!( - git_root.ends_with(&format!("/{work_dir_name}")), - "git root {git_root} should be the test work directory {work_dir_name}" + Path::new(workspace.cwd.as_deref().expect("workspace cwd")) + .canonicalize() + .expect("canonical workspace cwd"), + work_dir ); assert_eq!( workspace.host_type, @@ -257,6 +254,22 @@ async fn should_record_git_context_in_a_new_session_workspace() { // The recorded context survives a resume rather than being dropped // or re-derived into something else. let session_id = session.id().clone(); + // Empty sessions are not persisted; complete a replay-backed turn before resuming. + let answer = session + .send_and_wait("What is 1+1?") + .await + .expect("send") + .expect("assistant message"); + // Validate the final assistant response arrived (guards against truncated captures). + assert!(super::support::assistant_message_content(&answer).contains('2')); + client + .rpc() + .sessions() + .save(SessionsSaveRequest { + session_id: session_id.clone(), + }) + .await + .expect("persist session before disconnect"); session.disconnect().await.expect("disconnect session"); let resumed = client .resume_session( @@ -282,6 +295,10 @@ async fn should_record_git_context_in_a_new_session_workspace() { resumed_workspace.branch.as_deref(), Some("feature/test-branch") ); + assert_eq!(resumed_workspace.git_root, workspace.git_root); + assert_eq!(resumed_workspace.cwd, workspace.cwd); + assert_eq!(resumed_workspace.host_type, workspace.host_type); + assert_eq!(resumed_workspace.client_name, workspace.client_name); resumed.disconnect().await.expect("disconnect resumed"); client.stop().await.expect("stop client"); diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index f3aab53e5c..a0f328a750 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -481,6 +481,11 @@ impl E2eContext { self.work_dir.path() } + /// The runtime's `COPILOT_HOME`, which holds `session-state/`. + pub fn home_dir(&self) -> &Path { + self.home_dir.path() + } + pub fn proxy_url(&self) -> &str { self.proxy().url() } diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index 0285959f11..0df44da24e 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -268,6 +268,25 @@ function preservePythonSessionEventConstructorOrder(schema: JSONSchema7): void { (resolvedPath as Record)["x-copilot-sdk-append-last"] = true; } } + + // Added after these constructors were published; sorting it with the + // other optional fields would shift `tool_call_id` and `warning` for + // positional callers. + for (const name of [ + "PermissionPromptRequestCommands", + "PermissionRequestRead", + "PermissionRequestShell", + "PermissionRequestWrite", + ]) { + const definition = definitions[name]; + const sandboxPathGrant = + definition && typeof definition === "object" + ? (definition as JSONSchema7).properties?.sandboxPathGrant + : undefined; + if (sandboxPathGrant && typeof sandboxPathGrant === "object") { + (sandboxPathGrant as Record)["x-copilot-sdk-append-last"] = true; + } + } } } diff --git a/test/harness/stdio-shutdown-runtime.cjs b/test/harness/stdio-shutdown-runtime.cjs new file mode 100644 index 0000000000..99d1c8cec2 --- /dev/null +++ b/test/harness/stdio-shutdown-runtime.cjs @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// Shared SDK shutdown fixture: node