From 20ff87d3f52c9d6766d66f90340d8a820ec2a9c0 Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Fri, 21 Aug 2026 13:44:52 -0700 Subject: [PATCH 1/9] Rust SDK: Add sandbox config --- rust/src/types.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++--- rust/src/wire.rs | 6 +++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index afcb4d515..83fc54d22 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2095,6 +2095,8 @@ pub struct SessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, + /// Resolved sandbox configuration applied before the session runtime starts. + pub sandbox_config: Option, /// Per-property overrides for model capabilities, deep-merged over /// runtime defaults. pub model_capabilities: Option, @@ -2288,6 +2290,10 @@ impl std::fmt::Debug for SessionConfig { &self.enable_file_change_tracking, ) .field("session_limits", &self.session_limits) + .field( + "sandbox_config", + &self.sandbox_config.as_ref().map(|_| ""), + ) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) .field("config_directory", &self.config_directory) @@ -2408,6 +2414,7 @@ impl Default for SessionConfig { enable_citations: None, enable_file_change_tracking: None, session_limits: None, + sandbox_config: None, model_capabilities: None, memory: None, config_directory: None, @@ -2573,6 +2580,7 @@ impl SessionConfig { enable_citations: self.enable_citations, enable_file_change_tracking: self.enable_file_change_tracking, session_limits: self.session_limits, + sandbox_config: self.sandbox_config, model_capabilities: self.model_capabilities, memory: self.memory, config_dir: self.config_directory, @@ -3388,6 +3396,8 @@ pub struct ResumeSessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, + /// Resolved sandbox configuration applied before the resumed runtime starts. + pub sandbox_config: Option, /// Per-property model capability overrides on resume. pub model_capabilities: Option, /// Per-session configuration for the runtime memory feature on resume. @@ -3554,6 +3564,10 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.enable_file_change_tracking, ) .field("session_limits", &self.session_limits) + .field( + "sandbox_config", + &self.sandbox_config.as_ref().map(|_| ""), + ) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) .field("config_directory", &self.config_directory) @@ -3718,6 +3732,7 @@ impl ResumeSessionConfig { enable_citations: self.enable_citations, enable_file_change_tracking: self.enable_file_change_tracking, session_limits: self.session_limits, + sandbox_config: self.sandbox_config, model_capabilities: self.model_capabilities, memory: self.memory, config_dir: self.config_directory, @@ -3815,6 +3830,7 @@ impl ResumeSessionConfig { enable_citations: None, enable_file_change_tracking: None, session_limits: None, + sandbox_config: None, model_capabilities: None, memory: None, config_directory: None, @@ -5772,7 +5788,10 @@ pub use crate::generated::api_types::{ ModelCapabilitiesSupports, ModelList, ModelPolicy, PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionReject, PermissionDecisionSource, PermissionDecisionSurface, - PermissionDecisionUserNotAvailable, + PermissionDecisionUserNotAvailable, SandboxConfig, SandboxConfigAuth, SandboxConfigUserPolicy, + SandboxConfigUserPolicyExperimental, SandboxConfigUserPolicyExperimentalSeatbelt, + SandboxConfigUserPolicyFilesystem, SandboxConfigUserPolicyNetwork, + SandboxConfigUserPolicyNetworkProxy, SandboxConfigUserPolicySeatbelt, }; /// Permission categories the CLI may request approval for. @@ -5883,9 +5902,10 @@ mod tests { ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, - ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, - SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + ReasoningSummary, ResumeSessionConfig, SandboxConfig, SandboxConfigUserPolicy, + SandboxConfigUserPolicyNetwork, SandboxConfigUserPolicyNetworkProxy, SessionConfig, + SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, + ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -6190,6 +6210,57 @@ mod tests { assert!(unset_resume_json.get("customAgentsLocalOnly").is_none()); } + #[test] + fn sandbox_config_serializes_on_create_and_resume() { + let sandbox_config = SandboxConfig { + enabled: true, + user_policy: Some(SandboxConfigUserPolicy { + network: Some(SandboxConfigUserPolicyNetwork { + allow_outbound: Some(false), + allow_local_network: Some(false), + proxy: Some(SandboxConfigUserPolicyNetworkProxy { + url: "http://127.0.0.1:4321".to_string(), + username: None, + password: None, + }), + }), + ..Default::default() + }), + ..Default::default() + }; + + let create_config = SessionConfig { + sandbox_config: Some(sandbox_config.clone()), + ..Default::default() + }; + let (create_wire, _) = create_config + .into_wire(Some(SessionId::from("create-sandbox"))) + .expect("create config has no duplicate handlers"); + let create_json = serde_json::to_value(&create_wire).unwrap(); + assert_eq!( + create_json["sandboxConfig"]["userPolicy"]["network"]["proxy"]["url"], + "http://127.0.0.1:4321" + ); + + let mut resume_config = ResumeSessionConfig::new(SessionId::from("resume-sandbox")); + resume_config.sandbox_config = Some(sandbox_config); + let (resume_wire, _) = resume_config + .into_wire() + .expect("resume config has no duplicate handlers"); + let resume_json = serde_json::to_value(&resume_wire).unwrap(); + assert_eq!(resume_json["sandboxConfig"]["enabled"], true); + + let (unset_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("sandbox-unset"))) + .expect("unset config has no duplicate handlers"); + assert!( + serde_json::to_value(&unset_wire) + .unwrap() + .get("sandboxConfig") + .is_none() + ); + } + #[test] fn session_config_enable_mcp_apps_sets_wire_flag_and_serializes() { let cfg = SessionConfig::default().with_enable_mcp_apps(true); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 8c08b017b..e8116f6d6 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -20,7 +20,7 @@ use serde::Serialize; use crate::canvas::CanvasDeclaration; use crate::generated::api_types::{ - ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, + ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, SandboxConfig, }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ @@ -159,6 +159,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub session_limits: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, @@ -309,6 +311,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub session_limits: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, From 7c7f31d92e6678c2dfe57c4492c42eea70957f9a Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 10:29:58 -0700 Subject: [PATCH 2/9] Add sandbox config across SDKs --- dotnet/src/Client.cs | 4 ++ dotnet/src/Types.cs | 7 ++++ dotnet/test/E2E/SessionConfigE2ETests.cs | 19 ++++++++++ dotnet/test/Unit/SerializationTests.cs | 32 +++++++++++++++- go/client.go | 2 + go/client_test.go | 37 +++++++++++++++++- go/internal/e2e/session_config_e2e_test.go | 26 +++++++++++++ go/types.go | 8 ++++ .../github/copilot/SessionRequestBuilder.java | 2 + .../copilot/rpc/CreateSessionRequest.java | 17 +++++++++ .../copilot/rpc/ResumeSessionConfig.java | 38 +++++++++++++++++++ .../copilot/rpc/ResumeSessionRequest.java | 17 +++++++++ .../com/github/copilot/rpc/SessionConfig.java | 38 +++++++++++++++++++ .../github/copilot/SessionConfigE2ETest.java | 24 ++++++++++++ .../copilot/SessionRequestBuilderTest.java | 21 ++++++++++ nodejs/src/client.ts | 2 + nodejs/src/types.ts | 15 ++++++++ nodejs/test/client.test.ts | 10 +++++ nodejs/test/e2e/session_config.e2e.test.ts | 16 ++++++++ python/copilot/__init__.py | 18 +++++++++ python/copilot/client.py | 11 ++++++ python/e2e/test_session_config_e2e.py | 17 +++++++++ python/test_client.py | 11 ++++++ ...t_sandbox_config_on_create_and_resume.yaml | 3 ++ 24 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 86178ba74..0d4d9f156 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1215,6 +1215,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.DisabledSkills, config.InfiniteSessions, config.SessionLimits, + SandboxConfig: config.SandboxConfig, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description ?? string.Empty)).ToList(), RequestElicitation: config.OnElicitationRequest != null, RequestMcpApps: config.EnableMcpApps ? true : null, @@ -1436,6 +1437,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.DisabledSkills, config.InfiniteSessions, config.SessionLimits, + SandboxConfig: config.SandboxConfig, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description ?? string.Empty)).ToList(), RequestElicitation: config.OnElicitationRequest != null, RequestMcpApps: config.EnableMcpApps ? true : null, @@ -2795,6 +2797,7 @@ internal record CreateSessionRequest( IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, SessionLimitsConfig? SessionLimits, + SandboxConfig? SandboxConfig = null, IList? Commands = null, bool? RequestElicitation = null, bool? RequestMcpApps = null, @@ -2910,6 +2913,7 @@ internal record ResumeSessionRequest( IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, SessionLimitsConfig? SessionLimits, + SandboxConfig? SandboxConfig = null, IList? Commands = null, bool? RequestElicitation = null, bool? RequestMcpApps = null, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index c0810b387..e290c8794 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3223,6 +3223,7 @@ protected SessionConfigBase(SessionConfigBase? other) PluginDirectories = other.PluginDirectories is not null ? [.. other.PluginDirectories] : null; InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null; SessionLimits = other.SessionLimits; + SandboxConfig = other.SandboxConfig; Streaming = other.Streaming; IncludeSubAgentStreamingEvents = other.IncludeSubAgentStreamingEvents; SystemMessage = other.SystemMessage; @@ -3609,6 +3610,12 @@ protected SessionConfigBase(SessionConfigBase? other) [Experimental(Diagnostics.Experimental)] public SessionLimitsConfig? SessionLimits { get; set; } + /// + /// Resolved sandbox configuration applied before the session runtime starts. + /// + [Experimental(Diagnostics.Experimental)] + public SandboxConfig? SandboxConfig { get; set; } + /// /// Configuration for handling large tool outputs. When a tool produces /// output exceeding the configured size, the output is written to a temp diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index 1bc4c52eb..a4ad6c21f 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -524,6 +524,25 @@ public async Task Should_Apply_Session_Limits_On_Resume() } } + [Fact] + public async Task Should_Accept_Sandbox_Config_On_Create_And_Resume() + { + await using var session1 = await CreateSessionAsync(new SessionConfig + { + SandboxConfig = new SandboxConfig { Enabled = false }, + }); + var sessionId = session1.SessionId; + await SuspendAndUntrackSessionForResumeAsync(session1); + + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + SandboxConfig = new SandboxConfig { Enabled = false }, + }); + + Assert.Equal(sessionId, session2.SessionId); + await session2.DisposeAsync(); + } + [Fact] public async Task Should_Apply_Excluded_Built_In_Agents_On_Create() { diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 6edf16809..42be53558 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -485,7 +485,18 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO ("EnableCitations", true), ("EnableFileChangeTracking", true), ("ExcludedBuiltInAgents", excludedAgents), - ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 })); + ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 }), + ("SandboxConfig", new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Network = new SandboxConfigUserPolicyNetwork + { + Proxy = new SandboxConfigUserPolicyNetworkProxy { Url = "http://127.0.0.1:4321" }, + }, + }, + })); var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options); using var createDocument = JsonDocument.Parse(createJson); @@ -494,6 +505,9 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO Assert.True(createRoot.GetProperty("enableFileChangeTracking").GetBoolean()); Assert.Equal("explore", createRoot.GetProperty("excludedBuiltinAgents")[0].GetString()); Assert.Equal(12.5, createRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble()); + Assert.Equal( + "http://127.0.0.1:4321", + createRoot.GetProperty("sandboxConfig").GetProperty("userPolicy").GetProperty("network").GetProperty("proxy").GetProperty("url").GetString()); var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); var resumeRequest = CreateInternalRequest( @@ -502,7 +516,18 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO ("EnableCitations", true), ("EnableFileChangeTracking", true), ("ExcludedBuiltInAgents", excludedAgents), - ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 })); + ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 }), + ("SandboxConfig", new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Network = new SandboxConfigUserPolicyNetwork + { + Proxy = new SandboxConfigUserPolicyNetworkProxy { Url = "http://127.0.0.1:4322" }, + }, + }, + })); var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options); using var resumeDocument = JsonDocument.Parse(resumeJson); @@ -511,6 +536,9 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO Assert.True(resumeRoot.GetProperty("enableFileChangeTracking").GetBoolean()); Assert.Equal("task", resumeRoot.GetProperty("excludedBuiltinAgents")[1].GetString()); Assert.Equal(7.25, resumeRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble()); + Assert.Equal( + "http://127.0.0.1:4322", + resumeRoot.GetProperty("sandboxConfig").GetProperty("userPolicy").GetProperty("network").GetProperty("proxy").GetProperty("url").GetString()); } [Fact] diff --git a/go/client.go b/go/client.go index fb02897f9..ef32a385b 100644 --- a/go/client.go +++ b/go/client.go @@ -823,6 +823,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.EnableCitations = config.EnableCitations req.EnableFileChangeTracking = config.EnableFileChangeTracking req.SessionLimits = config.SessionLimits + req.SandboxConfig = config.SandboxConfig req.IsExperimentalMode = config.EnableExperimentalMode req.SkipCustomInstructions = config.SkipCustomInstructions req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly @@ -1173,6 +1174,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.EnableCitations = config.EnableCitations req.EnableFileChangeTracking = config.EnableFileChangeTracking req.SessionLimits = config.SessionLimits + req.SandboxConfig = config.SandboxConfig if config.Streaming != nil { req.Streaming = config.Streaming } diff --git a/go/client_test.go b/go/client_test.go index d0139eb11..19430e1d7 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -596,11 +596,19 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { EnableCitations: Bool(true), EnableFileChangeTracking: Bool(true), SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(30)}, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Network: &rpc.SandboxConfigUserPolicyNetwork{ + Proxy: &rpc.SandboxConfigUserPolicyNetworkProxy{URL: "http://127.0.0.1:4321"}, + }, + }, + }, }) if err != nil { t.Fatalf("CreateSession failed: %v", err) } - assertNewSessionOptions(t, <-createParams, true, true, "explore", 30) + assertNewSessionOptions(t, <-createParams, true, true, "explore", 30, "http://127.0.0.1:4321") resumeParams := make(chan json.RawMessage, 1) server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { @@ -613,11 +621,19 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { EnableCitations: Bool(false), EnableFileChangeTracking: Bool(false), SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(15)}, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Network: &rpc.SandboxConfigUserPolicyNetwork{ + Proxy: &rpc.SandboxConfigUserPolicyNetworkProxy{URL: "http://127.0.0.1:4322"}, + }, + }, + }, }) if err != nil { t.Fatalf("ResumeSessionWithOptions failed: %v", err) } - assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15) + assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15, "http://127.0.0.1:4322") } func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { @@ -644,6 +660,7 @@ func assertNewSessionOptions( expectedFileChangeTracking bool, expectedAgent string, expectedCredits float64, + expectedProxyURL string, ) { t.Helper() @@ -668,6 +685,22 @@ func assertNewSessionOptions( if limits["maxAiCredits"] != expectedCredits { t.Fatalf("expected sessionLimits.maxAiCredits=%v, got %v", expectedCredits, limits["maxAiCredits"]) } + sandbox, ok := decoded["sandboxConfig"].(map[string]any) + if !ok { + t.Fatalf("expected sandboxConfig object, got %T", decoded["sandboxConfig"]) + } + policy, ok := sandbox["userPolicy"].(map[string]any) + if !ok { + t.Fatalf("expected sandboxConfig.userPolicy object, got %T", sandbox["userPolicy"]) + } + network, ok := policy["network"].(map[string]any) + if !ok { + t.Fatalf("expected sandboxConfig.userPolicy.network object, got %T", policy["network"]) + } + proxy, ok := network["proxy"].(map[string]any) + if !ok || proxy["url"] != expectedProxyURL { + t.Fatalf("expected sandboxConfig proxy URL %q, got %#v", expectedProxyURL, network["proxy"]) + } } func float64Ptr(value float64) *float64 { diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 2ce48e3b3..5ef643e46 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -337,6 +337,32 @@ func TestSessionConfigNewOptionsE2E(t *testing.T) { assertSessionLimitsStatus(t, exchange, "30 AI credits") }) + t.Run("should accept sandbox config on create and resume", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SandboxConfig: &rpc.SandboxConfig{Enabled: false}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session1.Disconnect() + + session2, err := client.ResumeSessionWithOptions(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SandboxConfig: &rpc.SandboxConfig{Enabled: false}, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + defer session2.Disconnect() + + if session2.SessionID != session1.SessionID { + t.Errorf("Expected resumed session ID %q, got %q", session1.SessionID, session2.SessionID) + } + }) + t.Run("should apply excluded built in agents on create", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/types.go b/go/types.go index 60781d1da..bad114d9b 100644 --- a/go/types.go +++ b/go/types.go @@ -1380,6 +1380,9 @@ type SessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig + // SandboxConfig is the resolved sandbox configuration applied before the + // session runtime starts. + SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the // runtime decides. @@ -1848,6 +1851,9 @@ type ResumeSessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig + // SandboxConfig is the resolved sandbox configuration applied before the + // resumed session runtime starts. + SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the // runtime decides. @@ -2471,6 +2477,7 @@ type createSessionRequest struct { EnableCitations *bool `json:"enableCitations,omitempty"` EnableFileChangeTracking *bool `json:"enableFileChangeTracking,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` + SandboxConfig *rpc.SandboxConfig `json:"sandboxConfig,omitempty"` IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` @@ -2567,6 +2574,7 @@ type resumeSessionRequest struct { EnableCitations *bool `json:"enableCitations,omitempty"` EnableFileChangeTracking *bool `json:"enableFileChangeTracking,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` + SandboxConfig *rpc.SandboxConfig `json:"sandboxConfig,omitempty"` IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index 4254c04ec..17a718044 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -132,6 +132,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess config.getEnableCitations().ifPresent(request::setEnableCitations); config.getEnableFileChangeTracking().ifPresent(request::setEnableFileChangeTracking); request.setSessionLimits(config.getSessionLimits()); + request.setSandboxConfig(config.getSandboxConfig()); experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { @@ -268,6 +269,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo config.getEnableCitations().ifPresent(request::setEnableCitations); config.getEnableFileChangeTracking().ifPresent(request::setEnableFileChangeTracking); request.setSessionLimits(config.getSessionLimits()); + request.setSandboxConfig(config.getSandboxConfig()); experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 2eab977db..4fa02f827 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -86,6 +87,9 @@ public final class CreateSessionRequest { @JsonProperty("sessionLimits") private SessionLimitsConfig sessionLimits; + @JsonProperty("sandboxConfig") + private SandboxConfig sandboxConfig; + @JsonProperty("requestPermission") private Boolean requestPermission; @@ -462,6 +466,19 @@ public void setSessionLimits(SessionLimitsConfig sessionLimits) { this.sessionLimits = sessionLimits; } + /** Gets the sandbox configuration. @return the sandbox configuration */ + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the sandbox configuration. @param sandboxConfig the sandbox + * configuration + */ + public void setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + } + /** * Clears the enableSessionTelemetry setting, reverting to the default behavior. */ diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index a18803637..5cb375d91 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -16,6 +16,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -55,6 +56,7 @@ public class ResumeSessionConfig { private Boolean enableCitations; private Boolean enableFileChangeTracking; private SessionLimitsConfig sessionLimits; + private SandboxConfig sandboxConfig; private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; @@ -523,6 +525,41 @@ public ResumeSessionConfig clearSessionLimits() { return this; } + /** + * Gets the resolved sandbox configuration. + * + * @return the sandbox configuration, or {@code null} if not set + */ + @CopilotExperimental + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the resolved sandbox configuration applied before the resumed runtime + * starts. + * + * @param sandboxConfig + * the sandbox configuration + * @return this config instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + return this; + } + + /** + * Clears the sandbox configuration, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig clearSandboxConfig() { + this.sandboxConfig = null; + return this; + } + /** * Controls whether the session enables experimental features. * @@ -1996,6 +2033,7 @@ public ResumeSessionConfig clone() { copy.enableCitations = this.enableCitations; copy.enableFileChangeTracking = this.enableFileChangeTracking; copy.sessionLimits = this.sessionLimits; + copy.sandboxConfig = this.sandboxConfig; copy.enableExperimentalMode = this.enableExperimentalMode; copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index e52892477..3bc2c15cf 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -88,6 +89,9 @@ public final class ResumeSessionRequest { @JsonProperty("sessionLimits") private SessionLimitsConfig sessionLimits; + @JsonProperty("sandboxConfig") + private SandboxConfig sandboxConfig; + @JsonProperty("requestPermission") private Boolean requestPermission; @@ -467,6 +471,19 @@ public void setSessionLimits(SessionLimitsConfig sessionLimits) { this.sessionLimits = sessionLimits; } + /** Gets the sandbox configuration. @return the sandbox configuration */ + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the sandbox configuration. @param sandboxConfig the sandbox + * configuration + */ + public void setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + } + /** * Clears the enableSessionTelemetry setting, reverting to the default behavior. */ diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index 1127e6777..79f6f42e1 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -16,6 +16,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -59,6 +60,7 @@ public class SessionConfig { private Boolean enableCitations; private Boolean enableFileChangeTracking; private SessionLimitsConfig sessionLimits; + private SandboxConfig sandboxConfig; private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; @@ -625,6 +627,41 @@ public SessionConfig clearSessionLimits() { return this; } + /** + * Gets the resolved sandbox configuration. + * + * @return the sandbox configuration, or {@code null} if not set + */ + @CopilotExperimental + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the resolved sandbox configuration applied before the session runtime + * starts. + * + * @param sandboxConfig + * the sandbox configuration + * @return this config instance for method chaining + */ + @CopilotExperimental + public SessionConfig setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + return this; + } + + /** + * Clears the sandbox configuration, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public SessionConfig clearSandboxConfig() { + this.sandboxConfig = null; + return this; + } + /** * Controls whether the session enables experimental features. * @@ -2135,6 +2172,7 @@ public SessionConfig clone() { copy.enableCitations = this.enableCitations; copy.enableFileChangeTracking = this.enableFileChangeTracking; copy.sessionLimits = this.sessionLimits; + copy.sandboxConfig = this.sandboxConfig; copy.enableExperimentalMode = this.enableExperimentalMode; copy.skipCustomInstructions = this.skipCustomInstructions; copy.customAgentsLocalOnly = this.customAgentsLocalOnly; diff --git a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java index 925fd6d87..2a9e7cb13 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.BlobAttachment; import com.github.copilot.rpc.MessageOptions; @@ -207,6 +208,29 @@ void testShouldApplySessionLimitsOnResume() throws Exception { } } + @Test + void testShouldAcceptSandboxConfigOnCreateAndResume() throws Exception { + ctx.configureForTest("session_config", "should_accept_sandbox_config_on_create_and_resume"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session1 = client.createSession( + new SessionConfig().setSandboxConfig(new SandboxConfig(false, null, null, null, null)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + CopilotSession session2 = client.resumeSession(session1.getSessionId(), + new ResumeSessionConfig().setSandboxConfig(new SandboxConfig(false, null, null, null, null)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + try { + assertEquals(session1.getSessionId(), session2.getSessionId()); + } finally { + session2.close(); + session1.close(); + } + } + } + @Test void testShouldApplyExcludedBuiltInAgentsOnCreate() throws Exception { ctx.configureForTest("session_config", "should_apply_excluded_built_in_agents_on_create"); diff --git a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 0525786de..ad6718868 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; @@ -1074,4 +1075,24 @@ void githubMcpToolConfigIsMappedAndSerializedForCreateAndResume() throws Excepti mapper.writeValueAsString(SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "session-2")) .contains("\"githubMcpToolConfig\"")); } + + @Test + void sandboxConfigIsMappedAndSerializedForCreateAndResume() throws Exception { + var createSandbox = new SandboxConfig(true, null, false, null, null); + var resumeSandbox = new SandboxConfig(false, null, null, null, null); + var createRequest = SessionRequestBuilder + .buildCreateRequest(new SessionConfig().setSandboxConfig(createSandbox), "session-1"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("session-1", + new ResumeSessionConfig().setSandboxConfig(resumeSandbox)); + + assertSame(createSandbox, createRequest.getSandboxConfig()); + assertSame(resumeSandbox, resumeRequest.getSandboxConfig()); + var mapper = JsonRpcClient.getObjectMapper(); + assertTrue(mapper.writeValueAsString(createRequest) + .contains("\"sandboxConfig\":{\"enabled\":true,\"addCurrentWorkingDirectory\":false}")); + assertTrue(mapper.writeValueAsString(resumeRequest).contains("\"sandboxConfig\":{\"enabled\":false}")); + assertFalse( + mapper.writeValueAsString(SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "session-2")) + .contains("\"sandboxConfig\"")); + } } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 1be2cbb94..e63d224c2 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1584,6 +1584,7 @@ export class CopilotClient { enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, + sandboxConfig: config.sandboxConfig, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), requestPermission: !!config.onPermissionRequest, @@ -1807,6 +1808,7 @@ export class CopilotClient { enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, + sandboxConfig: config.sandboxConfig, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 678cd5863..5a3fecae3 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -25,10 +25,22 @@ import type { ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, + SandboxConfig, CurrentToolMetadata, } from "./generated/rpc.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; +export type { + SandboxConfig, + SandboxConfigAuth, + SandboxConfigUserPolicy, + SandboxConfigUserPolicyExperimental, + SandboxConfigUserPolicyExperimentalSeatbelt, + SandboxConfigUserPolicyFilesystem, + SandboxConfigUserPolicyNetwork, + SandboxConfigUserPolicyNetworkProxy, + SandboxConfigUserPolicySeatbelt, +} from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; export type { GitHubTelemetryNotification, @@ -2251,6 +2263,9 @@ export interface SessionConfigBase { /** Per-property overrides for model capabilities, deep-merged over runtime defaults. */ modelCapabilities?: ModelCapabilitiesOverride; + /** Resolved sandbox configuration applied before the session runtime starts. */ + sandboxConfig?: SandboxConfig; + /** * Configuration for handling large tool outputs. When a tool produces * output exceeding the configured size, the output is written to a temp diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index e2d630ba0..5a29d2ac3 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1019,6 +1019,10 @@ describe("CopilotClient", () => { enableFileChangeTracking: true, excludedBuiltinAgents: ["explore"], sessionLimits: { maxAiCredits: 30 }, + sandboxConfig: { + enabled: true, + userPolicy: { network: { allowOutbound: false } }, + }, }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, @@ -1026,6 +1030,7 @@ describe("CopilotClient", () => { enableFileChangeTracking: false, excludedBuiltinAgents: ["task"], sessionLimits: { maxAiCredits: 15 }, + sandboxConfig: { enabled: false }, }); const createPayload = spy.mock.calls.find( @@ -1038,10 +1043,15 @@ describe("CopilotClient", () => { expect(createPayload.enableFileChangeTracking).toBe(true); expect(createPayload.excludedBuiltinAgents).toEqual(["explore"]); expect(createPayload.sessionLimits).toEqual({ maxAiCredits: 30 }); + expect(createPayload.sandboxConfig).toEqual({ + enabled: true, + userPolicy: { network: { allowOutbound: false } }, + }); expect(resumePayload.enableCitations).toBe(false); expect(resumePayload.enableFileChangeTracking).toBe(false); expect(resumePayload.excludedBuiltinAgents).toEqual(["task"]); expect(resumePayload.sessionLimits).toEqual({ maxAiCredits: 15 }); + expect(resumePayload.sandboxConfig).toEqual({ enabled: false }); }); it("opts into GitHub telemetry forwarding when onGitHubTelemetry is provided", async () => { diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 85137e0ff..8c3c58340 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -866,6 +866,22 @@ describe("Session Configuration", async () => { } }); + it("should accept sandbox config on create and resume", async () => { + const session1 = await client.createSession({ + onPermissionRequest: approveAll, + sandboxConfig: { enabled: false }, + }); + const session2 = await client.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + sandboxConfig: { enabled: false }, + }); + + expect(session2.sessionId).toBe(session1.sessionId); + + await session2.disconnect(); + await session1.disconnect(); + }); + it("should apply GitHub MCP tool config on create", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index f7a71ebe9..83cd7f712 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -91,6 +91,15 @@ PermissionDecisionOutcome, PermissionDecisionSource, PermissionDecisionSurface, + SandboxConfig, + SandboxConfigAuth, + SandboxConfigUserPolicy, + SandboxConfigUserPolicyExperimental, + SandboxConfigUserPolicyExperimentalSeatbelt, + SandboxConfigUserPolicyFilesystem, + SandboxConfigUserPolicyNetwork, + SandboxConfigUserPolicyNetworkProxy, + SandboxConfigUserPolicySeatbelt, ) from .generated.session_events import ( PermissionRequest, @@ -326,6 +335,15 @@ "RemoteSessionMode", "RuntimeConnection", "rpc", + "SandboxConfig", + "SandboxConfigAuth", + "SandboxConfigUserPolicy", + "SandboxConfigUserPolicyExperimental", + "SandboxConfigUserPolicyExperimentalSeatbelt", + "SandboxConfigUserPolicyFilesystem", + "SandboxConfigUserPolicyNetwork", + "SandboxConfigUserPolicyNetworkProxy", + "SandboxConfigUserPolicySeatbelt", "session_events", "SessionBackgroundEvent", "SessionCapabilities", diff --git a/python/copilot/client.py b/python/copilot/client.py index 2654c1447..55332e142 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -73,6 +73,7 @@ ModelBillingTokenPricesLongContext, # noqa: F401 OpenCanvasInstance, RemoteSessionMode, + SandboxConfig, ServerRpc, _ConnectResult, _HookInvokeRequest, @@ -2119,6 +2120,7 @@ async def create_session( enable_file_change_tracking: bool | None = None, excluded_builtin_agents: list[str] | None = None, session_limits: SessionLimitsConfig | None = None, + sandbox_config: SandboxConfig | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, coauthor_enabled: bool | None = None, @@ -2241,6 +2243,8 @@ async def create_session( name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. + sandbox_config: Resolved sandbox configuration applied before the session + runtime starts. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -2531,6 +2535,8 @@ async def create_session( payload["excludedBuiltinAgents"] = excluded_builtin_agents if session_limits is not None: payload["sessionLimits"] = _session_limits_to_wire(session_limits) + if sandbox_config is not None: + payload["sandboxConfig"] = sandbox_config.to_dict() # Add model capabilities override if provided if model_capabilities: @@ -2847,6 +2853,7 @@ async def resume_session( enable_file_change_tracking: bool | None = None, excluded_builtin_agents: list[str] | None = None, session_limits: SessionLimitsConfig | None = None, + sandbox_config: SandboxConfig | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, coauthor_enabled: bool | None = None, @@ -2971,6 +2978,8 @@ async def resume_session( same name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. + sandbox_config: Resolved sandbox configuration applied before the resumed + runtime starts. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -3176,6 +3185,8 @@ async def resume_session( payload["excludedBuiltinAgents"] = excluded_builtin_agents if session_limits is not None: payload["sessionLimits"] = _session_limits_to_wire(session_limits) + if sandbox_config is not None: + payload["sandboxConfig"] = sandbox_config.to_dict() if model_capabilities: payload["modelCapabilities"] = _capabilities_to_dict(model_capabilities) if streaming is not None: diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 62dc67189..4d55d2458 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -14,6 +14,7 @@ ModelCapabilitiesOverride, ModelSupportsOverride, RuntimeConnection, + SandboxConfig, ) from copilot.copilot_request_handler import CopilotRequestContext from copilot.session import PermissionHandler @@ -417,6 +418,22 @@ async def test_should_apply_session_limits_on_resume(self, ctx: E2ETestContext): await session2.disconnect() await session1.disconnect() + async def test_should_accept_sandbox_config_on_create_and_resume(self, ctx: E2ETestContext): + session1 = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + sandbox_config=SandboxConfig(enabled=False), + ) + session2 = await ctx.client.resume_session( + session1.session_id, + on_permission_request=PermissionHandler.approve_all, + sandbox_config=SandboxConfig(enabled=False), + ) + + assert session2.session_id == session1.session_id + + await session2.disconnect() + await session1.disconnect() + async def test_should_apply_excluded_built_in_agents_on_create(self, ctx: E2ETestContext): excluded_agent = "explore" prompt = "What is 1+1?" diff --git a/python/test_client.py b/python/test_client.py index cf4bdf192..4dfa108be 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -21,6 +21,7 @@ ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, RuntimeConnection, + SandboxConfig, StdioRuntimeConnection, define_tool, ) @@ -1028,6 +1029,10 @@ async def mock_request(method, params, **kwargs): enable_file_change_tracking=True, excluded_builtin_agents=["explore"], session_limits={"max_ai_credits": 30}, + sandbox_config=SandboxConfig( + enabled=True, + add_current_working_directory=False, + ), ) await client.resume_session( session.session_id, @@ -1036,16 +1041,22 @@ async def mock_request(method, params, **kwargs): enable_file_change_tracking=False, excluded_builtin_agents=["task"], session_limits={"max_ai_credits": 15}, + sandbox_config=SandboxConfig(enabled=False), ) assert captured["session.create"]["enableCitations"] is True assert captured["session.create"]["enableFileChangeTracking"] is True assert captured["session.create"]["excludedBuiltinAgents"] == ["explore"] assert captured["session.create"]["sessionLimits"] == {"maxAiCredits": 30} + assert captured["session.create"]["sandboxConfig"] == { + "enabled": True, + "addCurrentWorkingDirectory": False, + } assert captured["session.resume"]["enableCitations"] is False assert captured["session.resume"]["enableFileChangeTracking"] is False assert captured["session.resume"]["excludedBuiltinAgents"] == ["task"] assert captured["session.resume"]["sessionLimits"] == {"maxAiCredits": 15} + assert captured["session.resume"]["sandboxConfig"] == {"enabled": False} finally: await client.force_stop() diff --git a/test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml b/test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml new file mode 100644 index 000000000..4e8abf7ed --- /dev/null +++ b/test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] \ No newline at end of file From ab650580d1fe4b535153d2cbb0f6828577534cf7 Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 11:08:04 -0700 Subject: [PATCH 3/9] Strengthen sandbox config E2E coverage --- dotnet/test/E2E/SessionConfigE2ETests.cs | 44 +++++- go/internal/e2e/session_config_e2e_test.go | 61 +++++++-- .../github/copilot/SessionConfigE2ETest.java | 43 ++++-- nodejs/test/e2e/session_config.e2e.test.ts | 64 +++++++-- python/e2e/test_session_config_e2e.py | 48 +++++-- ...t_sandbox_config_on_create_and_resume.yaml | 3 - ...y_sandbox_config_on_create_and_resume.yaml | 128 ++++++++++++++++++ 7 files changed, 346 insertions(+), 45 deletions(-) delete mode 100644 test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml create mode 100644 test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index a4ad6c21f..70e99e549 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -21,6 +21,18 @@ public class SessionConfigE2ETests(E2ETestFixture fixture, ITestOutputHelper out private static readonly byte[] Png1X1 = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); + private static async Task AssertNextShellExecutionSandboxedAsync( + CopilotSession session, + string prompt, + bool expected) + { + var eventCount = (await session.GetEventsAsync()).Count; + await session.SendAndWaitAsync(new MessageOptions { Prompt = prompt }); + var completion = Assert.Single( + (await session.GetEventsAsync()).Skip(eventCount).OfType()); + Assert.Equal(expected, completion.Data.Sandboxed == true); + } + [Fact] // TODO(BYOK): Anthropic Messages history diverged after enabling vision via SetModel. Verify // that model capability overrides work for provider-backed sessions before keeping this CAPI-only. @@ -525,19 +537,41 @@ public async Task Should_Apply_Session_Limits_On_Resume() } [Fact] - public async Task Should_Accept_Sandbox_Config_On_Create_And_Resume() + public async Task Should_Apply_Sandbox_Config_On_Create_And_Resume() { - await using var session1 = await CreateSessionAsync(new SessionConfig + if (OperatingSystem.IsWindows()) + { + return; + } + + await using var enabledSession = await CreateSessionAsync(new SessionConfig + { + SandboxConfig = new SandboxConfig { Enabled = true }, + }); + await AssertNextShellExecutionSandboxedAsync( + enabledSession, + "Run 'echo sandbox-create-enabled' and report the output.", + true); + + await using var disabledSession = await CreateSessionAsync(new SessionConfig { SandboxConfig = new SandboxConfig { Enabled = false }, }); - var sessionId = session1.SessionId; - await SuspendAndUntrackSessionForResumeAsync(session1); + await AssertNextShellExecutionSandboxedAsync( + disabledSession, + "Run 'echo sandbox-create-disabled' and report the output.", + false); + var sessionId = disabledSession.SessionId; + await SuspendAndUntrackSessionForResumeAsync(disabledSession); var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { - SandboxConfig = new SandboxConfig { Enabled = false }, + SandboxConfig = new SandboxConfig { Enabled = true }, }); + await AssertNextShellExecutionSandboxedAsync( + session2, + "Run 'echo sandbox-resume-enabled' and report the output.", + true); Assert.Equal(sessionId, session2.SessionId); await session2.DisposeAsync(); diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 5ef643e46..3984882d2 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -159,6 +160,35 @@ func float64Ref(value float64) *float64 { return &value } +func assertNextShellExecutionSandboxed(t *testing.T, session *copilot.Session, prompt string, expected bool) { + t.Helper() + + existingEvents, err := session.GetEvents(t.Context()) + if err != nil { + t.Fatalf("GetEvents before shell execution failed: %v", err) + } + if _, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: prompt}); err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + + events, err := session.GetEvents(t.Context()) + if err != nil { + t.Fatalf("GetEvents failed: %v", err) + } + for _, event := range events[len(existingEvents):] { + completed, ok := event.Data.(*copilot.ToolExecutionCompleteData) + if !ok { + continue + } + actual := completed.Sandboxed != nil && *completed.Sandboxed + if actual != expected { + t.Fatalf("Expected tool call %q sandboxed=%v, got %v", completed.ToolCallID, expected, completed.Sandboxed) + } + return + } + t.Fatal("Expected tool.execution_complete after sandbox shell prompt") +} + func TestSessionConfigE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient() @@ -337,29 +367,44 @@ func TestSessionConfigNewOptionsE2E(t *testing.T) { assertSessionLimitsStatus(t, exchange, "30 AI credits") }) - t.Run("should accept sandbox config on create and resume", func(t *testing.T) { + t.Run("should apply sandbox config on create and resume", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("process sandboxing is not supported on Windows") + } ctx.ConfigureForTest(t) - session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + enabledSession, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - SandboxConfig: &rpc.SandboxConfig{Enabled: false}, + SandboxConfig: &rpc.SandboxConfig{Enabled: true}, }) if err != nil { t.Fatalf("CreateSession failed: %v", err) } - defer session1.Disconnect() + defer enabledSession.Disconnect() + assertNextShellExecutionSandboxed(t, enabledSession, "Run 'echo sandbox-create-enabled' and report the output.", true) - session2, err := client.ResumeSessionWithOptions(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + disabledSession, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SandboxConfig: &rpc.SandboxConfig{Enabled: false}, }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer disabledSession.Disconnect() + assertNextShellExecutionSandboxed(t, disabledSession, "Run 'echo sandbox-create-disabled' and report the output.", false) + + resumedSession, err := client.ResumeSessionWithOptions(t.Context(), disabledSession.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SandboxConfig: &rpc.SandboxConfig{Enabled: true}, + }) if err != nil { t.Fatalf("ResumeSessionWithOptions failed: %v", err) } - defer session2.Disconnect() + defer resumedSession.Disconnect() + assertNextShellExecutionSandboxed(t, resumedSession, "Run 'echo sandbox-resume-enabled' and report the output.", true) - if session2.SessionID != session1.SessionID { - t.Errorf("Expected resumed session ID %q, got %q", session1.SessionID, session2.SessionID) + if resumedSession.SessionID != disabledSession.SessionID { + t.Errorf("Expected resumed session ID %q, got %q", disabledSession.SessionID, resumedSession.SessionID) } }) diff --git a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java index 2a9e7cb13..c085f0059 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -8,6 +8,7 @@ import static com.github.copilot.CopilotRequestTestSupport.newLlmClient; import static com.github.copilot.CopilotRequestTestSupport.setupCapiAuth; import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -24,6 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.ToolExecutionCompleteEvent; import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.BlobAttachment; @@ -42,6 +44,18 @@ public class SessionConfigE2ETest { private static E2ETestContext ctx; + private static void assertNextShellExecutionSandboxed(CopilotSession session, String prompt, boolean expected) + throws Exception { + int eventCount = session.getMessages().get(60, TimeUnit.SECONDS).size(); + session.sendAndWait(new MessageOptions().setPrompt(prompt)).get(60, TimeUnit.SECONDS); + var events = session.getMessages().get(60, TimeUnit.SECONDS); + var completions = events.subList(eventCount, events.size()).stream() + .filter(ToolExecutionCompleteEvent.class::isInstance).map(ToolExecutionCompleteEvent.class::cast) + .toList(); + assertEquals(1, completions.size(), "Expected one tool.execution_complete after sandbox shell prompt"); + assertEquals(expected, Boolean.TRUE.equals(completions.get(0).getData().sandboxed())); + } + @BeforeAll static void setup() throws Exception { ctx = E2ETestContext.create(); @@ -209,24 +223,37 @@ void testShouldApplySessionLimitsOnResume() throws Exception { } @Test - void testShouldAcceptSandboxConfigOnCreateAndResume() throws Exception { - ctx.configureForTest("session_config", "should_accept_sandbox_config_on_create_and_resume"); + void testShouldApplySandboxConfigOnCreateAndResume() throws Exception { + assumeFalse(System.getProperty("os.name", "").toLowerCase().contains("win"), + "Process sandboxing is not supported on Windows"); + ctx.configureForTest("session_config", "should_apply_sandbox_config_on_create_and_resume"); try (CopilotClient client = ctx.createClient()) { - CopilotSession session1 = client.createSession( + CopilotSession enabledSession = client + .createSession(new SessionConfig().setSandboxConfig(new SandboxConfig(true, null, null, null, null)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + assertNextShellExecutionSandboxed(enabledSession, + "Run 'echo sandbox-create-enabled' and report the output.", true); + CopilotSession disabledSession = client.createSession( new SessionConfig().setSandboxConfig(new SandboxConfig(false, null, null, null, null)) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(); - CopilotSession session2 = client.resumeSession(session1.getSessionId(), - new ResumeSessionConfig().setSandboxConfig(new SandboxConfig(false, null, null, null, null)) + assertNextShellExecutionSandboxed(disabledSession, + "Run 'echo sandbox-create-disabled' and report the output.", false); + CopilotSession resumedSession = client.resumeSession(disabledSession.getSessionId(), + new ResumeSessionConfig().setSandboxConfig(new SandboxConfig(true, null, null, null, null)) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(); try { - assertEquals(session1.getSessionId(), session2.getSessionId()); + assertNextShellExecutionSandboxed(resumedSession, + "Run 'echo sandbox-resume-enabled' and report the output.", true); + assertEquals(disabledSession.getSessionId(), resumedSession.getSessionId()); } finally { - session2.close(); - session1.close(); + resumedSession.close(); + disabledSession.close(); + enabledSession.close(); } } } diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 8c3c58340..e491378a8 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -7,6 +7,7 @@ import { CopilotRequestHandler, RuntimeConnection, type CopilotRequestContext, + type CopilotSession, } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; import { retry } from "./harness/sdkTestHelper.js"; @@ -26,6 +27,19 @@ describe("Session Configuration", async () => { return openAiEndpoint.getExchanges(); } + async function expectNextShellExecutionSandboxed( + session: CopilotSession, + prompt: string, + expected: boolean + ) { + const eventCount = (await session.getEvents()).length; + await session.sendAndWait({ prompt }); + const completion = (await session.getEvents()) + .slice(eventCount) + .find((event) => event.type === "tool.execution_complete"); + expect(completion?.data.sandboxed ?? false).toBe(expected); + } + it("should use workingDirectory for tool execution", async () => { const subDir = join(workDir, "subproject"); await mkdir(subDir, { recursive: true }); @@ -866,21 +880,45 @@ describe("Session Configuration", async () => { } }); - it("should accept sandbox config on create and resume", async () => { - const session1 = await client.createSession({ - onPermissionRequest: approveAll, - sandboxConfig: { enabled: false }, - }); - const session2 = await client.resumeSession(session1.sessionId, { - onPermissionRequest: approveAll, - sandboxConfig: { enabled: false }, - }); + it.skipIf(process.platform === "win32")( + "should apply sandbox config on create and resume", + async () => { + const enabledSession = await client.createSession({ + onPermissionRequest: approveAll, + sandboxConfig: { enabled: true }, + }); + await expectNextShellExecutionSandboxed( + enabledSession, + "Run 'echo sandbox-create-enabled' and report the output.", + true + ); - expect(session2.sessionId).toBe(session1.sessionId); + const disabledSession = await client.createSession({ + onPermissionRequest: approveAll, + sandboxConfig: { enabled: false }, + }); + await expectNextShellExecutionSandboxed( + disabledSession, + "Run 'echo sandbox-create-disabled' and report the output.", + false + ); + const resumedSession = await client.resumeSession(disabledSession.sessionId, { + onPermissionRequest: approveAll, + sandboxConfig: { enabled: true }, + }); + await expectNextShellExecutionSandboxed( + resumedSession, + "Run 'echo sandbox-resume-enabled' and report the output.", + true + ); - await session2.disconnect(); - await session1.disconnect(); - }); + expect(resumedSession.sessionId).toBe(disabledSession.sessionId); + + await resumedSession.disconnect(); + await disabledSession.disconnect(); + await enabledSession.disconnect(); + } + ); it("should apply GitHub MCP tool config on create", async () => { const session = await client.createSession({ diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 4d55d2458..b64d74c20 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -3,6 +3,7 @@ import base64 import json import os +import sys import uuid import httpx @@ -18,6 +19,7 @@ ) from copilot.copilot_request_handler import CopilotRequestContext from copilot.session import PermissionHandler +from copilot.session_events import ToolExecutionCompleteData from ._copilot_request_helpers import ( build_inference_response, @@ -101,6 +103,18 @@ def _get_tool_names(exchange: dict) -> list[str]: return names +async def _assert_next_shell_execution_sandboxed(session, prompt: str, expected: bool) -> None: + event_count = len(await session.get_events()) + await session.send_and_wait(prompt) + completions = [ + event.data + for event in (await session.get_events())[event_count:] + if isinstance(event.data, ToolExecutionCompleteData) + ] + assert completions, "Expected tool.execution_complete after sandbox shell prompt" + assert (completions[0].sandboxed is True) is expected + + async def _send_and_get_next_exchange(session, ctx: E2ETestContext, prompt: str) -> dict: existing_count = len(await ctx.get_exchanges()) await session.send_and_wait(prompt) @@ -418,21 +432,39 @@ async def test_should_apply_session_limits_on_resume(self, ctx: E2ETestContext): await session2.disconnect() await session1.disconnect() - async def test_should_accept_sandbox_config_on_create_and_resume(self, ctx: E2ETestContext): - session1 = await ctx.client.create_session( + @pytest.mark.skipif( + sys.platform == "win32", reason="process sandboxing is not supported on Windows" + ) + async def test_should_apply_sandbox_config_on_create_and_resume(self, ctx: E2ETestContext): + enabled_session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, - sandbox_config=SandboxConfig(enabled=False), + sandbox_config=SandboxConfig(enabled=True), ) - session2 = await ctx.client.resume_session( - session1.session_id, + await _assert_next_shell_execution_sandboxed( + enabled_session, "Run 'echo sandbox-create-enabled' and report the output.", True + ) + + disabled_session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, sandbox_config=SandboxConfig(enabled=False), ) + await _assert_next_shell_execution_sandboxed( + disabled_session, "Run 'echo sandbox-create-disabled' and report the output.", False + ) + resumed_session = await ctx.client.resume_session( + disabled_session.session_id, + on_permission_request=PermissionHandler.approve_all, + sandbox_config=SandboxConfig(enabled=True), + ) + await _assert_next_shell_execution_sandboxed( + resumed_session, "Run 'echo sandbox-resume-enabled' and report the output.", True + ) - assert session2.session_id == session1.session_id + assert resumed_session.session_id == disabled_session.session_id - await session2.disconnect() - await session1.disconnect() + await resumed_session.disconnect() + await disabled_session.disconnect() + await enabled_session.disconnect() async def test_should_apply_excluded_built_in_agents_on_create(self, ctx: E2ETestContext): excluded_agent = "explore" diff --git a/test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml b/test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml deleted file mode 100644 index 4e8abf7ed..000000000 --- a/test/snapshots/session_config/should_accept_sandbox_config_on_create_and_resume.yaml +++ /dev/null @@ -1,3 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: [] \ No newline at end of file diff --git a/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml new file mode 100644 index 000000000..3490bfc13 --- /dev/null +++ b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml @@ -0,0 +1,128 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo sandbox-create-enabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-create-enabled","description":"Verify enabled sandbox execution"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo sandbox-create-enabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-create-enabled","description":"Verify enabled sandbox execution"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-create-enabled + + - role: assistant + content: sandbox-create-enabled + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo sandbox-create-disabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo sandbox-create-disabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-create-disabled + + - role: assistant + content: sandbox-create-disabled + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo sandbox-create-disabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-create-disabled + + - role: assistant + content: sandbox-create-disabled + - role: user + content: Run 'echo sandbox-resume-enabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-resume-enabled","description":"Verify resumed sandbox execution"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo sandbox-create-disabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-create-disabled + + - role: assistant + content: sandbox-create-disabled + - role: user + content: Run 'echo sandbox-resume-enabled' and report the output. + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo sandbox-resume-enabled","description":"Verify resumed sandbox execution"}' + - role: tool + tool_call_id: toolcall_1 + content: |- + sandbox-resume-enabled + + - role: assistant + content: sandbox-resume-enabled \ No newline at end of file From bccbdd3eb5db1c8f33fa156a2268d7cc33fce465 Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 14:36:24 -0700 Subject: [PATCH 4/9] Apply sandbox config before returning sessions --- dotnet/src/Client.cs | 6 ++ dotnet/src/Types.cs | 2 +- dotnet/test/E2E/SessionConfigE2ETests.cs | 61 +++++++++--- go/client.go | 2 + go/client_test.go | 23 +++++ go/internal/e2e/session_config_e2e_test.go | 49 ++++++++-- go/mode_empty.go | 5 + go/types.go | 8 +- .../com/github/copilot/CopilotClient.java | 20 +++- .../copilot/rpc/ResumeSessionConfig.java | 3 +- .../com/github/copilot/rpc/SessionConfig.java | 3 +- .../github/copilot/SessionConfigE2ETest.java | 52 +++++++--- .../UpdateSessionOptionsForModeTest.java | 16 ++++ nodejs/src/client.ts | 3 + nodejs/src/types.ts | 2 +- nodejs/test/client.test.ts | 15 ++- nodejs/test/e2e/session_config.e2e.test.ts | 94 +++++++++++------- python/copilot/client.py | 15 ++- python/e2e/test_session_config_e2e.py | 96 +++++++++++++------ python/test_client.py | 8 ++ rust/src/session.rs | 8 +- rust/src/types.rs | 4 +- ...y_sandbox_config_on_create_and_resume.yaml | 52 +++++----- 23 files changed, 395 insertions(+), 152 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 0d4d9f156..4ba028b7b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1042,6 +1042,11 @@ private async Task UpdateSessionOptionsForModeAsync(CopilotSession session, Sess bool? manageScheduleEnabled = null; IList? installedPlugins = null; + if (config.SandboxConfig is not null) + { + hasAnyPatch = true; + } + if (_options.Mode == CopilotClientMode.Empty) { skipCustomInstructions = config.SkipCustomInstructions ?? true; @@ -1070,6 +1075,7 @@ await session.Rpc.Options.UpdateAsync( coauthorEnabled: coauthorEnabled, manageScheduleEnabled: manageScheduleEnabled, installedPlugins: installedPlugins, + sandboxConfig: config.SandboxConfig, cancellationToken: cancellationToken).ConfigureAwait(false); #pragma warning restore GHCP001 } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index e290c8794..23df4b4c4 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3611,7 +3611,7 @@ protected SessionConfigBase(SessionConfigBase? other) public SessionLimitsConfig? SessionLimits { get; set; } /// - /// Resolved sandbox configuration applied before the session runtime starts. + /// Resolved sandbox configuration applied when the session is created or resumed. /// [Experimental(Diagnostics.Experimental)] public SandboxConfig? SandboxConfig { get; set; } diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index 70e99e549..3db705ff8 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -21,16 +21,16 @@ public class SessionConfigE2ETests(E2ETestFixture fixture, ITestOutputHelper out private static readonly byte[] Png1X1 = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); - private static async Task AssertNextShellExecutionSandboxedAsync( + private static async Task AssertNextShellExecutionResultAsync( CopilotSession session, string prompt, - bool expected) + string expected) { var eventCount = (await session.GetEventsAsync()).Count; await session.SendAndWaitAsync(new MessageOptions { Prompt = prompt }); var completion = Assert.Single( (await session.GetEventsAsync()).Skip(eventCount).OfType()); - Assert.Equal(expected, completion.Data.Sandboxed == true); + Assert.Contains(expected, completion.Data.Result?.Content ?? string.Empty, StringComparison.Ordinal); } [Fact] @@ -544,37 +544,68 @@ public async Task Should_Apply_Sandbox_Config_On_Create_And_Resume() return; } + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var enabledProbe = Path.Join(homeDirectory, "sandbox-create-enabled.txt"); + var disabledProbe = Path.Join(homeDirectory, "sandbox-create-disabled.txt"); + var resumeProbe = Path.Join(homeDirectory, "sandbox-resume-enabled.txt"); + var probes = new[] { enabledProbe, disabledProbe, resumeProbe }; + foreach (var probe in probes) File.Delete(probe); await using var enabledSession = await CreateSessionAsync(new SessionConfig { - SandboxConfig = new SandboxConfig { Enabled = true }, + WorkingDirectory = Ctx.WorkDir, + SandboxConfig = new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [enabledProbe] }, + }, + }, }); - await AssertNextShellExecutionSandboxedAsync( + await AssertNextShellExecutionResultAsync( enabledSession, - "Run 'echo sandbox-create-enabled' and report the output.", - true); + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked"); await using var disabledSession = await CreateSessionAsync(new SessionConfig { - SandboxConfig = new SandboxConfig { Enabled = false }, + WorkingDirectory = Ctx.WorkDir, + SandboxConfig = new SandboxConfig + { + Enabled = false, + UserPolicy = new SandboxConfigUserPolicy + { + Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [disabledProbe] }, + }, + }, }); - await AssertNextShellExecutionSandboxedAsync( + await AssertNextShellExecutionResultAsync( disabledSession, - "Run 'echo sandbox-create-disabled' and report the output.", - false); + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible"); var sessionId = disabledSession.SessionId; await SuspendAndUntrackSessionForResumeAsync(disabledSession); var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { - SandboxConfig = new SandboxConfig { Enabled = true }, + WorkingDirectory = Ctx.WorkDir, + SandboxConfig = new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [resumeProbe] }, + }, + }, }); - await AssertNextShellExecutionSandboxedAsync( + await AssertNextShellExecutionResultAsync( session2, - "Run 'echo sandbox-resume-enabled' and report the output.", - true); + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked"); Assert.Equal(sessionId, session2.SessionId); await session2.DisposeAsync(); + foreach (var probe in probes) File.Delete(probe); } [Fact] diff --git a/go/client.go b/go/client.go index ef32a385b..a196e93db 100644 --- a/go/client.go +++ b/go/client.go @@ -1100,6 +1100,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, CoauthorEnabled: config.CoauthorEnabled, ManageScheduleEnabled: config.ManageScheduleEnabled, + SandboxConfig: config.SandboxConfig, }); err != nil { return nil, err } @@ -1379,6 +1380,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, CoauthorEnabled: config.CoauthorEnabled, ManageScheduleEnabled: config.ManageScheduleEnabled, + SandboxConfig: config.SandboxConfig, }); err != nil { return nil, err } diff --git a/go/client_test.go b/go/client_test.go index 19430e1d7..7ea7fd16d 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -585,6 +585,11 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { } createParams := make(chan json.RawMessage, 1) + updateParams := make(chan json.RawMessage, 2) + server.SetRequestHandler("session.options.update", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + updateParams <- append(json.RawMessage(nil), params...) + return []byte(`{"success":true}`), nil + }) server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { createParams <- append(json.RawMessage(nil), params...) sessionID := sessionIDFromParams(t, params) @@ -609,6 +614,7 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { t.Fatalf("CreateSession failed: %v", err) } assertNewSessionOptions(t, <-createParams, true, true, "explore", 30, "http://127.0.0.1:4321") + assertSandboxConfig(t, <-updateParams, "http://127.0.0.1:4321") resumeParams := make(chan json.RawMessage, 1) server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { @@ -634,6 +640,7 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { t.Fatalf("ResumeSessionWithOptions failed: %v", err) } assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15, "http://127.0.0.1:4322") + assertSandboxConfig(t, <-updateParams, "http://127.0.0.1:4322") } func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { @@ -685,6 +692,22 @@ func assertNewSessionOptions( if limits["maxAiCredits"] != expectedCredits { t.Fatalf("expected sessionLimits.maxAiCredits=%v, got %v", expectedCredits, limits["maxAiCredits"]) } + assertDecodedSandboxConfig(t, decoded, expectedProxyURL) +} + +func assertSandboxConfig(t *testing.T, params json.RawMessage, expectedProxyURL string) { + t.Helper() + + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + assertDecodedSandboxConfig(t, decoded, expectedProxyURL) +} + +func assertDecodedSandboxConfig(t *testing.T, decoded map[string]any, expectedProxyURL string) { + t.Helper() + sandbox, ok := decoded["sandboxConfig"].(map[string]any) if !ok { t.Fatalf("expected sandboxConfig object, got %T", decoded["sandboxConfig"]) diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 3984882d2..e38d7d2c9 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -160,7 +160,7 @@ func float64Ref(value float64) *float64 { return &value } -func assertNextShellExecutionSandboxed(t *testing.T, session *copilot.Session, prompt string, expected bool) { +func assertNextShellExecutionResult(t *testing.T, session *copilot.Session, prompt string, expected string) { t.Helper() existingEvents, err := session.GetEvents(t.Context()) @@ -180,9 +180,8 @@ func assertNextShellExecutionSandboxed(t *testing.T, session *copilot.Session, p if !ok { continue } - actual := completed.Sandboxed != nil && *completed.Sandboxed - if actual != expected { - t.Fatalf("Expected tool call %q sandboxed=%v, got %v", completed.ToolCallID, expected, completed.Sandboxed) + if completed.Result == nil || !strings.Contains(completed.Result.Content, expected) { + t.Fatalf("Expected tool call %q result to contain %q, got %#v", completed.ToolCallID, expected, completed.Result) } return } @@ -372,36 +371,66 @@ func TestSessionConfigNewOptionsE2E(t *testing.T) { t.Skip("process sandboxing is not supported on Windows") } ctx.ConfigureForTest(t) + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir failed: %v", err) + } + enabledProbe := filepath.Join(homeDir, "sandbox-create-enabled.txt") + disabledProbe := filepath.Join(homeDir, "sandbox-create-disabled.txt") + resumeProbe := filepath.Join(homeDir, "sandbox-resume-enabled.txt") + t.Cleanup(func() { + _ = os.Remove(enabledProbe) + _ = os.Remove(disabledProbe) + _ = os.Remove(resumeProbe) + }) enabledSession, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - SandboxConfig: &rpc.SandboxConfig{Enabled: true}, + WorkingDirectory: ctx.WorkDir, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Filesystem: &rpc.SandboxConfigUserPolicyFilesystem{DeniedPaths: []string{enabledProbe}}, + }, + }, }) if err != nil { t.Fatalf("CreateSession failed: %v", err) } defer enabledSession.Disconnect() - assertNextShellExecutionSandboxed(t, enabledSession, "Run 'echo sandbox-create-enabled' and report the output.", true) + assertNextShellExecutionResult(t, enabledSession, "Check sandbox access for sandbox-create-enabled.txt.", "sandbox-blocked") disabledSession, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - SandboxConfig: &rpc.SandboxConfig{Enabled: false}, + WorkingDirectory: ctx.WorkDir, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: false, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Filesystem: &rpc.SandboxConfigUserPolicyFilesystem{DeniedPaths: []string{disabledProbe}}, + }, + }, }) if err != nil { t.Fatalf("CreateSession failed: %v", err) } defer disabledSession.Disconnect() - assertNextShellExecutionSandboxed(t, disabledSession, "Run 'echo sandbox-create-disabled' and report the output.", false) + assertNextShellExecutionResult(t, disabledSession, "Check sandbox access for sandbox-create-disabled.txt.", "sandbox-accessible") resumedSession, err := client.ResumeSessionWithOptions(t.Context(), disabledSession.SessionID, &copilot.ResumeSessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - SandboxConfig: &rpc.SandboxConfig{Enabled: true}, + WorkingDirectory: ctx.WorkDir, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Filesystem: &rpc.SandboxConfigUserPolicyFilesystem{DeniedPaths: []string{resumeProbe}}, + }, + }, }) if err != nil { t.Fatalf("ResumeSessionWithOptions failed: %v", err) } defer resumedSession.Disconnect() - assertNextShellExecutionSandboxed(t, resumedSession, "Run 'echo sandbox-resume-enabled' and report the output.", true) + assertNextShellExecutionResult(t, resumedSession, "Check sandbox access for sandbox-resume-enabled.txt.", "sandbox-blocked") if resumedSession.SessionID != disabledSession.SessionID { t.Errorf("Expected resumed session ID %q, got %q", disabledSession.SessionID, resumedSession.SessionID) diff --git a/go/mode_empty.go b/go/mode_empty.go index 6057b2661..9de99d908 100644 --- a/go/mode_empty.go +++ b/go/mode_empty.go @@ -229,6 +229,10 @@ func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) { func (c *Client) updateSessionOptionsForMode(ctx context.Context, session *Session, base optBackInFields) error { patch := &rpc.SessionUpdateOptionsParams{} hasAny := false + if base.SandboxConfig != nil { + patch.SandboxConfig = base.SandboxConfig + hasAny = true + } if c.options.Mode == ModeEmpty { if base.SkipCustomInstructions != nil { patch.SkipCustomInstructions = base.SkipCustomInstructions @@ -297,4 +301,5 @@ type optBackInFields struct { CustomAgentsLocalOnly *bool CoauthorEnabled *bool ManageScheduleEnabled *bool + SandboxConfig *rpc.SandboxConfig } diff --git a/go/types.go b/go/types.go index bad114d9b..23cc0b851 100644 --- a/go/types.go +++ b/go/types.go @@ -1380,8 +1380,8 @@ type SessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig - // SandboxConfig is the resolved sandbox configuration applied before the - // session runtime starts. + // SandboxConfig is the resolved sandbox configuration applied when the + // session is created. SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the @@ -1851,8 +1851,8 @@ type ResumeSessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig - // SandboxConfig is the resolved sandbox configuration applied before the - // resumed session runtime starts. + // SandboxConfig is the resolved sandbox configuration applied when the + // session is resumed. SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the 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 cdd1b9ff3..0c665f851 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -36,6 +36,7 @@ import com.github.copilot.rpc.CreateSessionResponse; import com.github.copilot.generated.rpc.SessionOptionsUpdateParams; import com.github.copilot.generated.rpc.SessionInstalledPlugin; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.ConnectResult; import com.github.copilot.generated.rpc.GitHubTelemetryNotification; import com.github.copilot.generated.rpc.ServerRpc; @@ -1010,7 +1011,7 @@ public CompletableFuture createSession(SessionConfig config) { return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), config.getCustomAgentsLocalOnly().orElse(null), config.getCoauthorEnabled().orElse(null), - config.getManageScheduleEnabled().orElse(null)); + config.getManageScheduleEnabled().orElse(null), config.getSandboxConfig()); }).thenApply(v -> { LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.createSession complete. Elapsed={Elapsed}, SessionId=" @@ -1170,7 +1171,8 @@ public CompletableFuture resumeSession(String sessionId, ResumeS return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), config.getCustomAgentsLocalOnly().orElse(null), config.getCoauthorEnabled().orElse(null), - config.getManageScheduleEnabled().orElse(null)).thenApply(v -> { + config.getManageScheduleEnabled().orElse(null), config.getSandboxConfig()) + .thenApply(v -> { LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.resumeSession complete. Elapsed={Elapsed}, SessionId=" + sessionId, @@ -1211,17 +1213,26 @@ public CompletableFuture resumeSession(String sessionId, ResumeS * caller-supplied value, or {@code null} if not set * @param manageScheduleEnabled * caller-supplied value, or {@code null} if not set + * @param sandboxConfig + * caller-supplied sandbox configuration, or {@code null} if not set * @return a future that completes when the patch has been applied */ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions, Boolean customAgentsLocalOnly, Boolean coauthorEnabled, Boolean manageScheduleEnabled) { + return updateSessionOptionsForMode(session, skipCustomInstructions, customAgentsLocalOnly, coauthorEnabled, + manageScheduleEnabled, null); + } + + CompletableFuture updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions, + Boolean customAgentsLocalOnly, Boolean coauthorEnabled, Boolean manageScheduleEnabled, + SandboxConfig sandboxConfig) { Boolean patchSkip = null; Boolean patchAgents = null; Boolean patchCoauthor = null; Boolean patchSchedule = null; List patchPlugins = null; - boolean hasAnyPatch = false; + boolean hasAnyPatch = sandboxConfig != null; if (options.getMode() == CopilotClientMode.EMPTY) { patchSkip = skipCustomInstructions != null ? skipCustomInstructions : true; @@ -1276,8 +1287,7 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool null, // shell null, // shellInitProfile null, // shellProcessFlags - null, // sandboxConfig - null, // logInteractiveShells + sandboxConfig, null, // logInteractiveShells null, // envValueMode null, // allowAllMcpServerInstructions null, // skillDirectories diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 5cb375d91..7b92ced93 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -536,8 +536,7 @@ public SandboxConfig getSandboxConfig() { } /** - * Sets the resolved sandbox configuration applied before the resumed runtime - * starts. + * Sets the resolved sandbox configuration applied when the session is resumed. * * @param sandboxConfig * the sandbox configuration diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index 79f6f42e1..097ce0a70 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -638,8 +638,7 @@ public SandboxConfig getSandboxConfig() { } /** - * Sets the resolved sandbox configuration applied before the session runtime - * starts. + * Sets the resolved sandbox configuration applied when the session is created. * * @param sandboxConfig * the sandbox configuration diff --git a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java index c085f0059..a8ae311b0 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -27,6 +27,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.generated.ToolExecutionCompleteEvent; import com.github.copilot.generated.rpc.SandboxConfig; +import com.github.copilot.generated.rpc.SandboxConfigUserPolicy; +import com.github.copilot.generated.rpc.SandboxConfigUserPolicyFilesystem; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.BlobAttachment; import com.github.copilot.rpc.MessageOptions; @@ -44,7 +46,7 @@ public class SessionConfigE2ETest { private static E2ETestContext ctx; - private static void assertNextShellExecutionSandboxed(CopilotSession session, String prompt, boolean expected) + private static void assertNextShellExecutionResult(CopilotSession session, String prompt, String expected) throws Exception { int eventCount = session.getMessages().get(60, TimeUnit.SECONDS).size(); session.sendAndWait(new MessageOptions().setPrompt(prompt)).get(60, TimeUnit.SECONDS); @@ -53,7 +55,7 @@ private static void assertNextShellExecutionSandboxed(CopilotSession session, St .filter(ToolExecutionCompleteEvent.class::isInstance).map(ToolExecutionCompleteEvent.class::cast) .toList(); assertEquals(1, completions.size(), "Expected one tool.execution_complete after sandbox shell prompt"); - assertEquals(expected, Boolean.TRUE.equals(completions.get(0).getData().sandboxed())); + assertTrue(completions.get(0).getData().result().content().contains(expected)); } @BeforeAll @@ -229,31 +231,57 @@ void testShouldApplySandboxConfigOnCreateAndResume() throws Exception { ctx.configureForTest("session_config", "should_apply_sandbox_config_on_create_and_resume"); try (CopilotClient client = ctx.createClient()) { + Path homeDir = Path.of(System.getProperty("user.home")); + Path enabledProbePath = homeDir.resolve("sandbox-create-enabled.txt"); + Path disabledProbePath = homeDir.resolve("sandbox-create-disabled.txt"); + Path resumeProbePath = homeDir.resolve("sandbox-resume-enabled.txt"); + List probes = List.of(enabledProbePath, disabledProbePath, resumeProbePath); + for (Path probe : probes) { + Files.deleteIfExists(probe); + } + String enabledProbe = enabledProbePath.toString(); + String disabledProbe = disabledProbePath.toString(); + String resumeProbe = resumeProbePath.toString(); CopilotSession enabledSession = client - .createSession(new SessionConfig().setSandboxConfig(new SandboxConfig(true, null, null, null, null)) + .createSession(new SessionConfig().setWorkingDirectory(ctx.getWorkDir().toString()) + .setSandboxConfig(new SandboxConfig(true, + new SandboxConfigUserPolicy(new SandboxConfigUserPolicyFilesystem(null, null, + List.of(enabledProbe), null), null, null, null), + null, null, null)) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(); - assertNextShellExecutionSandboxed(enabledSession, - "Run 'echo sandbox-create-enabled' and report the output.", true); - CopilotSession disabledSession = client.createSession( - new SessionConfig().setSandboxConfig(new SandboxConfig(false, null, null, null, null)) + assertNextShellExecutionResult(enabledSession, "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked"); + CopilotSession disabledSession = client + .createSession(new SessionConfig().setWorkingDirectory(ctx.getWorkDir().toString()) + .setSandboxConfig(new SandboxConfig(false, + new SandboxConfigUserPolicy(new SandboxConfigUserPolicyFilesystem(null, null, + List.of(disabledProbe), null), null, null, null), + null, null, null)) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(); - assertNextShellExecutionSandboxed(disabledSession, - "Run 'echo sandbox-create-disabled' and report the output.", false); + assertNextShellExecutionResult(disabledSession, "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible"); CopilotSession resumedSession = client.resumeSession(disabledSession.getSessionId(), - new ResumeSessionConfig().setSandboxConfig(new SandboxConfig(true, null, null, null, null)) + new ResumeSessionConfig().setWorkingDirectory(ctx.getWorkDir().toString()) + .setSandboxConfig(new SandboxConfig(true, + new SandboxConfigUserPolicy(new SandboxConfigUserPolicyFilesystem(null, null, + List.of(resumeProbe), null), null, null, null), + null, null, null)) .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) .get(); try { - assertNextShellExecutionSandboxed(resumedSession, - "Run 'echo sandbox-resume-enabled' and report the output.", true); + assertNextShellExecutionResult(resumedSession, "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked"); assertEquals(disabledSession.getSessionId(), resumedSession.getSessionId()); } finally { resumedSession.close(); disabledSession.close(); enabledSession.close(); + for (Path probe : probes) { + Files.deleteIfExists(probe); + } } } } diff --git a/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java b/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java index d02ea3097..5c65ec4f2 100644 --- a/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java +++ b/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.rpc.CopilotClientMode; import com.github.copilot.rpc.CopilotClientOptions; @@ -179,6 +180,21 @@ void copilotCliMode_onlyCoauthorEnabled_patchSent() throws Exception { } } + @Test + void copilotCliMode_sandboxConfigSet_patchContainsSandboxConfig() throws Exception { + try (var pair = new AutoReplyPair()) { + var session = new CopilotSession("sess-1", pair.rpcClient); + var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false)); + var sandboxConfig = new SandboxConfig(true, null, null, null, null); + + client.updateSessionOptionsForMode(session, null, null, null, null, sandboxConfig).get(); + + assertEquals("session.options.update", pair.lastMethod); + assertTrue(pair.lastParams.get("sandboxConfig").get("enabled").asBoolean()); + client.close(); + } + } + // ── EMPTY mode tests ────────────────────────────────────────────────────── @Test diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index e63d224c2..6d5cf4582 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1408,6 +1408,9 @@ export class CopilotClient { config: SessionConfigBase ): Promise { const patch: SessionUpdateOptionsParams = {}; + if (config.sandboxConfig !== undefined) { + patch.sandboxConfig = config.sandboxConfig; + } if (this.options.mode === "empty") { patch.skipCustomInstructions = config.skipCustomInstructions ?? true; patch.customAgentsLocalOnly = config.customAgentsLocalOnly ?? true; diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 5a3fecae3..56a87c2df 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2263,7 +2263,7 @@ export interface SessionConfigBase { /** Per-property overrides for model capabilities, deep-merged over runtime defaults. */ modelCapabilities?: ModelCapabilitiesOverride; - /** Resolved sandbox configuration applied before the session runtime starts. */ + /** Resolved sandbox configuration applied when the session is created or resumed. */ sandboxConfig?: SandboxConfig; /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 5a29d2ac3..8d8d38f0a 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from "node:events"; -import { PassThrough } from "stream"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { PassThrough } from "stream"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, - createAttributedPermissionResult, CopilotClient, + createAttributedPermissionResult, createCanvas, RuntimeConnection, type GitHubTelemetryNotification, @@ -1010,6 +1010,7 @@ describe("CopilotClient", () => { .mockImplementation(async (method: string, params: any) => { if (method === "session.create") return { sessionId: params.sessionId }; if (method === "session.resume") return { sessionId: params.sessionId }; + if (method === "session.options.update") return { success: true }; throw new Error(`Unexpected method: ${method}`); }); @@ -1039,6 +1040,9 @@ describe("CopilotClient", () => { const resumePayload = spy.mock.calls.find( ([method]) => method === "session.resume" )![1] as any; + const updatePayloads = spy.mock.calls + .filter(([method]) => method === "session.options.update") + .map(([, params]) => params as any); expect(createPayload.enableCitations).toBe(true); expect(createPayload.enableFileChangeTracking).toBe(true); expect(createPayload.excludedBuiltinAgents).toEqual(["explore"]); @@ -1052,6 +1056,13 @@ describe("CopilotClient", () => { expect(resumePayload.excludedBuiltinAgents).toEqual(["task"]); expect(resumePayload.sessionLimits).toEqual({ maxAiCredits: 15 }); expect(resumePayload.sandboxConfig).toEqual({ enabled: false }); + expect(updatePayloads.map(({ sandboxConfig }) => sandboxConfig)).toEqual([ + { + enabled: true, + userPolicy: { network: { allowOutbound: false } }, + }, + { enabled: false }, + ]); }); it("opts into GitHub telemetry forwarding when onGitHubTelemetry is provided", async () => { diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index e491378a8..b2f030385 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { writeFile, mkdir } from "fs/promises"; +import { mkdir, rm, writeFile } from "fs/promises"; +import { homedir } from "os"; import { join } from "path"; +import { describe, expect, it } from "vitest"; import { approveAll, CopilotClient, @@ -27,17 +28,17 @@ describe("Session Configuration", async () => { return openAiEndpoint.getExchanges(); } - async function expectNextShellExecutionSandboxed( + async function expectNextShellExecutionResult( session: CopilotSession, prompt: string, - expected: boolean + expected: string ) { const eventCount = (await session.getEvents()).length; await session.sendAndWait({ prompt }); const completion = (await session.getEvents()) .slice(eventCount) .find((event) => event.type === "tool.execution_complete"); - expect(completion?.data.sandboxed ?? false).toBe(expected); + expect(completion?.data.result?.content).toContain(expected); } it("should use workingDirectory for tool execution", async () => { @@ -883,40 +884,61 @@ describe("Session Configuration", async () => { it.skipIf(process.platform === "win32")( "should apply sandbox config on create and resume", async () => { - const enabledSession = await client.createSession({ - onPermissionRequest: approveAll, - sandboxConfig: { enabled: true }, - }); - await expectNextShellExecutionSandboxed( - enabledSession, - "Run 'echo sandbox-create-enabled' and report the output.", - true - ); + const enabledProbe = join(homedir(), "sandbox-create-enabled.txt"); + const disabledProbe = join(homedir(), "sandbox-create-disabled.txt"); + const resumeProbe = join(homedir(), "sandbox-resume-enabled.txt"); + const probes = [enabledProbe, disabledProbe, resumeProbe]; + await Promise.all(probes.map((probe) => rm(probe, { force: true }))); + try { + const enabledSession = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: workDir, + sandboxConfig: { + enabled: true, + userPolicy: { filesystem: { deniedPaths: [enabledProbe] } }, + }, + }); + await expectNextShellExecutionResult( + enabledSession, + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked" + ); - const disabledSession = await client.createSession({ - onPermissionRequest: approveAll, - sandboxConfig: { enabled: false }, - }); - await expectNextShellExecutionSandboxed( - disabledSession, - "Run 'echo sandbox-create-disabled' and report the output.", - false - ); - const resumedSession = await client.resumeSession(disabledSession.sessionId, { - onPermissionRequest: approveAll, - sandboxConfig: { enabled: true }, - }); - await expectNextShellExecutionSandboxed( - resumedSession, - "Run 'echo sandbox-resume-enabled' and report the output.", - true - ); + const disabledSession = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: workDir, + sandboxConfig: { + enabled: false, + userPolicy: { filesystem: { deniedPaths: [disabledProbe] } }, + }, + }); + await expectNextShellExecutionResult( + disabledSession, + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible" + ); + const resumedSession = await client.resumeSession(disabledSession.sessionId, { + onPermissionRequest: approveAll, + workingDirectory: workDir, + sandboxConfig: { + enabled: true, + userPolicy: { filesystem: { deniedPaths: [resumeProbe] } }, + }, + }); + await expectNextShellExecutionResult( + resumedSession, + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked" + ); - expect(resumedSession.sessionId).toBe(disabledSession.sessionId); + expect(resumedSession.sessionId).toBe(disabledSession.sessionId); - await resumedSession.disconnect(); - await disabledSession.disconnect(); - await enabledSession.disconnect(); + await resumedSession.disconnect(); + await disabledSession.disconnect(); + await enabledSession.disconnect(); + } finally { + await Promise.all(probes.map((probe) => rm(probe, { force: true }))); + } } ); diff --git a/python/copilot/client.py b/python/copilot/client.py index 55332e142..04b041802 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2243,8 +2243,8 @@ async def create_session( name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. - sandbox_config: Resolved sandbox configuration applied before the session - runtime starts. + sandbox_config: Resolved sandbox configuration applied when the session is + created. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -2813,6 +2813,7 @@ def _register_inline(raw_response: Any) -> None: custom_agents_local_only, coauthor_enabled, manage_schedule_enabled, + sandbox_config, ) log_timing( @@ -2978,8 +2979,8 @@ async def resume_session( same name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. - sandbox_config: Resolved sandbox configuration applied before the resumed - runtime starts. + sandbox_config: Resolved sandbox configuration applied when the session is + resumed. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -3460,6 +3461,7 @@ async def resume_session( custom_agents_local_only, coauthor_enabled, manage_schedule_enabled, + sandbox_config, ) log_timing( @@ -4515,6 +4517,7 @@ async def _apply_post_create_options_patch( custom_agents_local_only: bool | None, coauthor_enabled: bool | None, manage_schedule_enabled: bool | None, + sandbox_config: SandboxConfig | None, ) -> None: """Apply empty-mode safe defaults (or caller-supplied overrides in copilot-cli mode) via ``session.options.update`` after create/resume. @@ -4531,8 +4534,9 @@ async def _apply_post_create_options_patch( coauthor_enabled, manage_schedule_enabled, ) - if patch is None: + if patch is None and sandbox_config is None: return + patch = patch or {} params = SessionUpdateOptionsParams() if "skipCustomInstructions" in patch: @@ -4548,6 +4552,7 @@ async def _apply_post_create_options_patch( SessionInstalledPlugin.from_dict(p) if isinstance(p, dict) else p for p in patch["installedPlugins"] ] + params.sandbox_config = sandbox_config try: await session.rpc.options.update(params) diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index b64d74c20..0139058fa 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -16,6 +16,8 @@ ModelSupportsOverride, RuntimeConnection, SandboxConfig, + SandboxConfigUserPolicy, + SandboxConfigUserPolicyFilesystem, ) from copilot.copilot_request_handler import CopilotRequestContext from copilot.session import PermissionHandler @@ -103,7 +105,7 @@ def _get_tool_names(exchange: dict) -> list[str]: return names -async def _assert_next_shell_execution_sandboxed(session, prompt: str, expected: bool) -> None: +async def _assert_next_shell_execution_result(session, prompt: str, expected: str) -> None: event_count = len(await session.get_events()) await session.send_and_wait(prompt) completions = [ @@ -112,7 +114,8 @@ async def _assert_next_shell_execution_sandboxed(session, prompt: str, expected: if isinstance(event.data, ToolExecutionCompleteData) ] assert completions, "Expected tool.execution_complete after sandbox shell prompt" - assert (completions[0].sandboxed is True) is expected + assert completions[0].result is not None + assert expected in completions[0].result.content async def _send_and_get_next_exchange(session, ctx: E2ETestContext, prompt: str) -> dict: @@ -436,35 +439,72 @@ async def test_should_apply_session_limits_on_resume(self, ctx: E2ETestContext): sys.platform == "win32", reason="process sandboxing is not supported on Windows" ) async def test_should_apply_sandbox_config_on_create_and_resume(self, ctx: E2ETestContext): - enabled_session = await ctx.client.create_session( - on_permission_request=PermissionHandler.approve_all, - sandbox_config=SandboxConfig(enabled=True), - ) - await _assert_next_shell_execution_sandboxed( - enabled_session, "Run 'echo sandbox-create-enabled' and report the output.", True - ) + home_dir = os.path.expanduser("~") + enabled_probe = os.path.join(home_dir, "sandbox-create-enabled.txt") + disabled_probe = os.path.join(home_dir, "sandbox-create-disabled.txt") + resume_probe = os.path.join(home_dir, "sandbox-resume-enabled.txt") + probes = [enabled_probe, disabled_probe, resume_probe] + for probe in probes: + if os.path.exists(probe): + os.remove(probe) + try: + enabled_session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=ctx.work_dir, + sandbox_config=SandboxConfig( + enabled=True, + user_policy=SandboxConfigUserPolicy( + filesystem=SandboxConfigUserPolicyFilesystem(denied_paths=[enabled_probe]) + ), + ), + ) + await _assert_next_shell_execution_result( + enabled_session, + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked", + ) - disabled_session = await ctx.client.create_session( - on_permission_request=PermissionHandler.approve_all, - sandbox_config=SandboxConfig(enabled=False), - ) - await _assert_next_shell_execution_sandboxed( - disabled_session, "Run 'echo sandbox-create-disabled' and report the output.", False - ) - resumed_session = await ctx.client.resume_session( - disabled_session.session_id, - on_permission_request=PermissionHandler.approve_all, - sandbox_config=SandboxConfig(enabled=True), - ) - await _assert_next_shell_execution_sandboxed( - resumed_session, "Run 'echo sandbox-resume-enabled' and report the output.", True - ) + disabled_session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=ctx.work_dir, + sandbox_config=SandboxConfig( + enabled=False, + user_policy=SandboxConfigUserPolicy( + filesystem=SandboxConfigUserPolicyFilesystem(denied_paths=[disabled_probe]) + ), + ), + ) + await _assert_next_shell_execution_result( + disabled_session, + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible", + ) + resumed_session = await ctx.client.resume_session( + disabled_session.session_id, + on_permission_request=PermissionHandler.approve_all, + working_directory=ctx.work_dir, + sandbox_config=SandboxConfig( + enabled=True, + user_policy=SandboxConfigUserPolicy( + filesystem=SandboxConfigUserPolicyFilesystem(denied_paths=[resume_probe]) + ), + ), + ) + await _assert_next_shell_execution_result( + resumed_session, + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked", + ) - assert resumed_session.session_id == disabled_session.session_id + assert resumed_session.session_id == disabled_session.session_id - await resumed_session.disconnect() - await disabled_session.disconnect() - await enabled_session.disconnect() + await resumed_session.disconnect() + await disabled_session.disconnect() + await enabled_session.disconnect() + finally: + for probe in probes: + if os.path.exists(probe): + os.remove(probe) async def test_should_apply_excluded_built_in_agents_on_create(self, ctx: E2ETestContext): excluded_agent = "explore" diff --git a/python/test_client.py b/python/test_client.py index 4dfa108be..d6a6a203e 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -1011,6 +1011,7 @@ async def test_create_and_resume_session_forward_new_session_options(self): await client.start() try: captured = {} + options_updates = [] async def mock_request(method, params, **kwargs): captured[method] = params @@ -1020,6 +1021,9 @@ async def mock_request(method, params, **kwargs): if callback is not None: callback(result) return result + if method == "session.options.update": + options_updates.append(params) + return {"success": True} return {} client._client.request = mock_request @@ -1057,6 +1061,10 @@ async def mock_request(method, params, **kwargs): assert captured["session.resume"]["excludedBuiltinAgents"] == ["task"] assert captured["session.resume"]["sessionLimits"] == {"maxAiCredits": 15} assert captured["session.resume"]["sandboxConfig"] == {"enabled": False} + assert [update["sandboxConfig"] for update in options_updates] == [ + {"enabled": True, "addCurrentWorkingDirectory": False}, + {"enabled": False}, + ] finally: await client.force_stop() diff --git a/rust/src/session.rs b/rust/src/session.rs index 7676c2ad7..b63ec9dd0 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -903,6 +903,7 @@ impl Client { let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; + let opt_sandbox_config = config.sandbox_config.clone(); let (mut wire, mut runtime) = config.into_wire(local_session_id.clone())?; wire.enable_github_telemetry_forwarding = self.inner.on_github_telemetry.is_some().then_some(true); @@ -1098,6 +1099,7 @@ impl Client { opt_custom_agents_local_only, opt_coauthor_enabled, opt_manage_schedule_enabled, + opt_sandbox_config, ) .await?; Ok(session) @@ -1176,6 +1178,7 @@ impl Client { let opt_custom_agents_local_only = config.custom_agents_local_only; let opt_coauthor_enabled = config.coauthor_enabled; let opt_manage_schedule_enabled = config.manage_schedule_enabled; + let opt_sandbox_config = config.sandbox_config.clone(); let (mut wire, mut runtime) = config.into_wire()?; wire.enable_github_telemetry_forwarding = self.inner.on_github_telemetry.is_some().then_some(true); @@ -1358,6 +1361,7 @@ impl Client { opt_custom_agents_local_only, opt_coauthor_enabled, opt_manage_schedule_enabled, + opt_sandbox_config, ) .await?; Ok(session) @@ -1373,9 +1377,11 @@ async fn apply_mode_post_create_patch( opt_custom_agents_local_only: Option, opt_coauthor_enabled: Option, opt_manage_schedule_enabled: Option, + opt_sandbox_config: Option, ) -> Result<(), Error> { use crate::generated::api_types::SessionUpdateOptionsParams; let mut patch = SessionUpdateOptionsParams::default(); + patch.sandbox_config = opt_sandbox_config; let should_send = if mode == crate::ClientMode::Empty { patch.skip_custom_instructions = Some(opt_skip_custom_instructions.unwrap_or(true)); patch.custom_agents_local_only = Some(opt_custom_agents_local_only.unwrap_or(true)); @@ -1384,7 +1390,7 @@ async fn apply_mode_post_create_patch( patch.installed_plugins = Some(Vec::new()); true } else { - let mut any = false; + let mut any = patch.sandbox_config.is_some(); if let Some(v) = opt_skip_custom_instructions { patch.skip_custom_instructions = Some(v); any = true; diff --git a/rust/src/types.rs b/rust/src/types.rs index 83fc54d22..e5a426b37 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2095,7 +2095,7 @@ pub struct SessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, - /// Resolved sandbox configuration applied before the session runtime starts. + /// Resolved sandbox configuration applied when the session is created. pub sandbox_config: Option, /// Per-property overrides for model capabilities, deep-merged over /// runtime defaults. @@ -3396,7 +3396,7 @@ pub struct ResumeSessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, - /// Resolved sandbox configuration applied before the resumed runtime starts. + /// Resolved sandbox configuration applied when the session is resumed. pub sandbox_config: Option, /// Per-property model capability overrides on resume. pub model_capabilities: Option, diff --git a/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml index 3490bfc13..95cc7eafd 100644 --- a/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml +++ b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml @@ -5,124 +5,124 @@ conversations: - role: system content: ${system} - role: user - content: Run 'echo sandbox-create-enabled' and report the output. + content: Check sandbox access for sandbox-create-enabled.txt. - role: assistant tool_calls: - id: toolcall_0 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-create-enabled","description":"Verify enabled sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' - messages: - role: system content: ${system} - role: user - content: Run 'echo sandbox-create-enabled' and report the output. + content: Check sandbox access for sandbox-create-enabled.txt. - role: assistant tool_calls: - id: toolcall_0 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-create-enabled","description":"Verify enabled sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- - sandbox-create-enabled + sandbox-blocked - role: assistant - content: sandbox-create-enabled + content: sandbox-blocked - messages: - role: system content: ${system} - role: user - content: Run 'echo sandbox-create-disabled' and report the output. + content: Check sandbox access for sandbox-create-disabled.txt. - role: assistant tool_calls: - id: toolcall_0 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - messages: - role: system content: ${system} - role: user - content: Run 'echo sandbox-create-disabled' and report the output. + content: Check sandbox access for sandbox-create-disabled.txt. - role: assistant tool_calls: - id: toolcall_0 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- - sandbox-create-disabled + sandbox-accessible - role: assistant - content: sandbox-create-disabled + content: sandbox-accessible - messages: - role: system content: ${system} - role: user - content: Run 'echo sandbox-create-disabled' and report the output. + content: Check sandbox access for sandbox-create-disabled.txt. - role: assistant tool_calls: - id: toolcall_0 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- - sandbox-create-disabled + sandbox-accessible - role: assistant - content: sandbox-create-disabled + content: sandbox-accessible - role: user - content: Run 'echo sandbox-resume-enabled' and report the output. + content: Check sandbox access for sandbox-resume-enabled.txt. - role: assistant tool_calls: - id: toolcall_1 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-resume-enabled","description":"Verify resumed sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-resume-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' - messages: - role: system content: ${system} - role: user - content: Run 'echo sandbox-create-disabled' and report the output. + content: Check sandbox access for sandbox-create-disabled.txt. - role: assistant tool_calls: - id: toolcall_0 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-create-disabled","description":"Verify disabled sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- - sandbox-create-disabled + sandbox-accessible - role: assistant - content: sandbox-create-disabled + content: sandbox-accessible - role: user - content: Run 'echo sandbox-resume-enabled' and report the output. + content: Check sandbox access for sandbox-resume-enabled.txt. - role: assistant tool_calls: - id: toolcall_1 type: function function: name: ${shell} - arguments: '{"command":"echo sandbox-resume-enabled","description":"Verify resumed sandbox execution"}' + arguments: '{"command":"(printf probe > \"$HOME/sandbox-resume-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' - role: tool tool_call_id: toolcall_1 content: |- - sandbox-resume-enabled + sandbox-blocked - role: assistant - content: sandbox-resume-enabled \ No newline at end of file + content: sandbox-blocked From ad9b35f448bdd33c09b4b9649ddd36bc45292a47 Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 14:49:19 -0700 Subject: [PATCH 5/9] Fix Rust sandbox options Clippy lint --- rust/src/session.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/src/session.rs b/rust/src/session.rs index b63ec9dd0..7331c2af1 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1380,8 +1380,10 @@ async fn apply_mode_post_create_patch( opt_sandbox_config: Option, ) -> Result<(), Error> { use crate::generated::api_types::SessionUpdateOptionsParams; - let mut patch = SessionUpdateOptionsParams::default(); - patch.sandbox_config = opt_sandbox_config; + let mut patch = SessionUpdateOptionsParams { + sandbox_config: opt_sandbox_config, + ..SessionUpdateOptionsParams::default() + }; let should_send = if mode == crate::ClientMode::Empty { patch.skip_custom_instructions = Some(opt_skip_custom_instructions.unwrap_or(true)); patch.custom_agents_local_only = Some(opt_custom_agents_local_only.unwrap_or(true)); From 4a5df8841b1d44c16aee926cca7c9c20c1a19bf7 Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 15:31:05 -0700 Subject: [PATCH 6/9] Address sandbox review feedback and Linux CI --- .github/workflows/dotnet-sdk-tests.yml | 4 ++++ .github/workflows/go-sdk-tests.yml | 4 ++++ .github/workflows/java-sdk-tests.yml | 6 ++++++ .github/workflows/nodejs-sdk-tests.yml | 3 +++ .github/workflows/python-sdk-tests.yml | 4 ++++ go/types.go | 6 ++++++ .../src/main/java/com/github/copilot/CopilotClient.java | 2 -- nodejs/src/types.ts | 6 +++++- python/copilot/client.py | 8 ++++---- rust/src/types.rs | 4 ++-- 10 files changed, 38 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index 4695cae7f..dc59dda6d 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -113,6 +113,10 @@ jobs: cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes bubblewrap + - name: Install Node.js dependencies (for CLI version extraction) working-directory: ./nodejs run: npm ci --ignore-scripts diff --git a/.github/workflows/go-sdk-tests.yml b/.github/workflows/go-sdk-tests.yml index 61d74d257..a5bb6830e 100644 --- a/.github/workflows/go-sdk-tests.yml +++ b/.github/workflows/go-sdk-tests.yml @@ -34,6 +34,10 @@ jobs: with: go-version: "1.24" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes bubblewrap + - name: Run go fmt if: runner.os == 'Linux' working-directory: ./go diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index bd0a34bd2..77dfb72b8 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -40,6 +40,9 @@ jobs: with: node-version: 22 + - name: Install bubblewrap + run: sudo apt-get update && sudo apt-get install --yes bubblewrap + - name: Run Java SDK tests (InProcess) env: CI: "true" @@ -90,6 +93,9 @@ jobs: with: node-version: 22 + - name: Install bubblewrap + run: sudo apt-get update && sudo apt-get install --yes bubblewrap + - name: Test documentation version updater if: matrix.test-jdk == '25' run: ./scripts/test-update-documentation-versions.sh diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 4c31f79cc..7c6c38831 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -36,6 +36,9 @@ jobs: cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" node-version: 22 + - name: Install bubblewrap + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes bubblewrap - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 1ea973975..5cb234dad 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -42,6 +42,10 @@ jobs: cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes bubblewrap + - name: Set up uv uses: astral-sh/setup-uv@v7 with: diff --git a/go/types.go b/go/types.go index 23cc0b851..183c0c2f8 100644 --- a/go/types.go +++ b/go/types.go @@ -1382,6 +1382,9 @@ type SessionConfig struct { SessionLimits *rpc.SessionLimitsConfig // SandboxConfig is the resolved sandbox configuration applied when the // session is created. + // + // Experimental: SandboxConfig is part of an experimental runtime sandboxing + // surface and may change or be removed in future SDK or CLI releases. SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the @@ -1853,6 +1856,9 @@ type ResumeSessionConfig struct { SessionLimits *rpc.SessionLimitsConfig // SandboxConfig is the resolved sandbox configuration applied when the // session is resumed. + // + // Experimental: SandboxConfig is part of an experimental runtime sandboxing + // surface and may change or be removed in future SDK or CLI releases. SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the 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 0c665f851..4728e7144 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -1213,8 +1213,6 @@ public CompletableFuture resumeSession(String sessionId, ResumeS * caller-supplied value, or {@code null} if not set * @param manageScheduleEnabled * caller-supplied value, or {@code null} if not set - * @param sandboxConfig - * caller-supplied sandbox configuration, or {@code null} if not set * @return a future that completes when the patch has been applied */ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 56a87c2df..4ced384e3 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2263,7 +2263,11 @@ export interface SessionConfigBase { /** Per-property overrides for model capabilities, deep-merged over runtime defaults. */ modelCapabilities?: ModelCapabilitiesOverride; - /** Resolved sandbox configuration applied when the session is created or resumed. */ + /** + * Resolved sandbox configuration applied when the session is created or resumed. + * + * @experimental + */ sandboxConfig?: SandboxConfig; /** diff --git a/python/copilot/client.py b/python/copilot/client.py index 04b041802..ef0ff545c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2243,8 +2243,8 @@ async def create_session( name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. - sandbox_config: Resolved sandbox configuration applied when the session is - created. + sandbox_config: **Experimental.** Resolved sandbox configuration applied + when the session is created. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -2979,8 +2979,8 @@ async def resume_session( same name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. - sandbox_config: Resolved sandbox configuration applied when the session is - resumed. + sandbox_config: **Experimental.** Resolved sandbox configuration applied + when the session is resumed. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming diff --git a/rust/src/types.rs b/rust/src/types.rs index e5a426b37..3915b90bd 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2095,7 +2095,7 @@ pub struct SessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, - /// Resolved sandbox configuration applied when the session is created. + /// **Experimental.** Resolved sandbox configuration applied when the session is created. pub sandbox_config: Option, /// Per-property overrides for model capabilities, deep-merged over /// runtime defaults. @@ -3396,7 +3396,7 @@ pub struct ResumeSessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, - /// Resolved sandbox configuration applied when the session is resumed. + /// **Experimental.** Resolved sandbox configuration applied when the session is resumed. pub sandbox_config: Option, /// Per-property model capability overrides on resume. pub model_capabilities: Option, From b2f5375954476624639b4ebc20b169f63889124a Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 15:58:35 -0700 Subject: [PATCH 7/9] Allow Bubblewrap user namespaces in Linux CI --- .github/workflows/dotnet-sdk-tests.yml | 6 +++++- .github/workflows/go-sdk-tests.yml | 6 +++++- .github/workflows/java-sdk-tests.yml | 12 ++++++++++-- .github/workflows/nodejs-sdk-tests.yml | 6 +++++- .github/workflows/python-sdk-tests.yml | 6 +++++- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index dc59dda6d..cbf494938 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -115,7 +115,11 @@ jobs: - name: Install bubblewrap if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install --yes bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict - name: Install Node.js dependencies (for CLI version extraction) working-directory: ./nodejs diff --git a/.github/workflows/go-sdk-tests.yml b/.github/workflows/go-sdk-tests.yml index a5bb6830e..896d38439 100644 --- a/.github/workflows/go-sdk-tests.yml +++ b/.github/workflows/go-sdk-tests.yml @@ -36,7 +36,11 @@ jobs: - name: Install bubblewrap if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install --yes bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict - name: Run go fmt if: runner.os == 'Linux' diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index 0a66ca876..0cb3a4ab7 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -49,7 +49,11 @@ jobs: - name: Install bubblewrap if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install --yes bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict - name: Validate native host run: node copilot-native/scripts/validate-native-host.mjs ${{ matrix.classifier }} @@ -220,7 +224,11 @@ jobs: node-version: 22 - name: Install bubblewrap - run: sudo apt-get update && sudo apt-get install --yes bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict - name: Test documentation version updater if: matrix.test-jdk == '25' diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 7c6c38831..260273a8d 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -38,7 +38,11 @@ jobs: node-version: 22 - name: Install bubblewrap if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install --yes bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 5cb234dad..98800285a 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -44,7 +44,11 @@ jobs: - name: Install bubblewrap if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install --yes bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict - name: Set up uv uses: astral-sh/setup-uv@v7 From adc069f7e9abe505a23ad795a84492312183d7b4 Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 16:35:25 -0700 Subject: [PATCH 8/9] Use ungranted paths for sandbox E2E probes --- dotnet/test/E2E/SessionConfigE2ETests.cs | 7 +++---- go/internal/e2e/session_config_e2e_test.go | 10 +++------- .../com/github/copilot/SessionConfigE2ETest.java | 7 +++---- nodejs/test/e2e/session_config.e2e.test.ts | 7 +++---- python/e2e/test_session_config_e2e.py | 7 +++---- ...pply_sandbox_config_on_create_and_resume.yaml | 16 ++++++++-------- 6 files changed, 23 insertions(+), 31 deletions(-) diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index 3db705ff8..e934d8cef 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -544,10 +544,9 @@ public async Task Should_Apply_Sandbox_Config_On_Create_And_Resume() return; } - var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var enabledProbe = Path.Join(homeDirectory, "sandbox-create-enabled.txt"); - var disabledProbe = Path.Join(homeDirectory, "sandbox-create-disabled.txt"); - var resumeProbe = Path.Join(homeDirectory, "sandbox-resume-enabled.txt"); + var enabledProbe = "/var/tmp/sandbox-create-enabled.txt"; + var disabledProbe = "/var/tmp/sandbox-create-disabled.txt"; + var resumeProbe = "/var/tmp/sandbox-resume-enabled.txt"; var probes = new[] { enabledProbe, disabledProbe, resumeProbe }; foreach (var probe in probes) File.Delete(probe); await using var enabledSession = await CreateSessionAsync(new SessionConfig diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index e38d7d2c9..80b137fb8 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -371,13 +371,9 @@ func TestSessionConfigNewOptionsE2E(t *testing.T) { t.Skip("process sandboxing is not supported on Windows") } ctx.ConfigureForTest(t) - homeDir, err := os.UserHomeDir() - if err != nil { - t.Fatalf("UserHomeDir failed: %v", err) - } - enabledProbe := filepath.Join(homeDir, "sandbox-create-enabled.txt") - disabledProbe := filepath.Join(homeDir, "sandbox-create-disabled.txt") - resumeProbe := filepath.Join(homeDir, "sandbox-resume-enabled.txt") + enabledProbe := "/var/tmp/sandbox-create-enabled.txt" + disabledProbe := "/var/tmp/sandbox-create-disabled.txt" + resumeProbe := "/var/tmp/sandbox-resume-enabled.txt" t.Cleanup(func() { _ = os.Remove(enabledProbe) _ = os.Remove(disabledProbe) diff --git a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java index a8ae311b0..1ee0bc05a 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -231,10 +231,9 @@ void testShouldApplySandboxConfigOnCreateAndResume() throws Exception { ctx.configureForTest("session_config", "should_apply_sandbox_config_on_create_and_resume"); try (CopilotClient client = ctx.createClient()) { - Path homeDir = Path.of(System.getProperty("user.home")); - Path enabledProbePath = homeDir.resolve("sandbox-create-enabled.txt"); - Path disabledProbePath = homeDir.resolve("sandbox-create-disabled.txt"); - Path resumeProbePath = homeDir.resolve("sandbox-resume-enabled.txt"); + Path enabledProbePath = Path.of("/var/tmp/sandbox-create-enabled.txt"); + Path disabledProbePath = Path.of("/var/tmp/sandbox-create-disabled.txt"); + Path resumeProbePath = Path.of("/var/tmp/sandbox-resume-enabled.txt"); List probes = List.of(enabledProbePath, disabledProbePath, resumeProbePath); for (Path probe : probes) { Files.deleteIfExists(probe); diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index b2f030385..8f76ed85e 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -1,5 +1,4 @@ import { mkdir, rm, writeFile } from "fs/promises"; -import { homedir } from "os"; import { join } from "path"; import { describe, expect, it } from "vitest"; import { @@ -884,9 +883,9 @@ describe("Session Configuration", async () => { it.skipIf(process.platform === "win32")( "should apply sandbox config on create and resume", async () => { - const enabledProbe = join(homedir(), "sandbox-create-enabled.txt"); - const disabledProbe = join(homedir(), "sandbox-create-disabled.txt"); - const resumeProbe = join(homedir(), "sandbox-resume-enabled.txt"); + const enabledProbe = "/var/tmp/sandbox-create-enabled.txt"; + const disabledProbe = "/var/tmp/sandbox-create-disabled.txt"; + const resumeProbe = "/var/tmp/sandbox-resume-enabled.txt"; const probes = [enabledProbe, disabledProbe, resumeProbe]; await Promise.all(probes.map((probe) => rm(probe, { force: true }))); try { diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 0139058fa..39fe1c28a 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -439,10 +439,9 @@ async def test_should_apply_session_limits_on_resume(self, ctx: E2ETestContext): sys.platform == "win32", reason="process sandboxing is not supported on Windows" ) async def test_should_apply_sandbox_config_on_create_and_resume(self, ctx: E2ETestContext): - home_dir = os.path.expanduser("~") - enabled_probe = os.path.join(home_dir, "sandbox-create-enabled.txt") - disabled_probe = os.path.join(home_dir, "sandbox-create-disabled.txt") - resume_probe = os.path.join(home_dir, "sandbox-resume-enabled.txt") + enabled_probe = "/var/tmp/sandbox-create-enabled.txt" + disabled_probe = "/var/tmp/sandbox-create-disabled.txt" + resume_probe = "/var/tmp/sandbox-resume-enabled.txt" probes = [enabled_probe, disabled_probe, resume_probe] for probe in probes: if os.path.exists(probe): diff --git a/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml index 95cc7eafd..0a0e5d5c6 100644 --- a/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml +++ b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml @@ -12,7 +12,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' - messages: - role: system content: ${system} @@ -24,7 +24,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- @@ -43,7 +43,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - messages: - role: system content: ${system} @@ -55,7 +55,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- @@ -74,7 +74,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- @@ -90,7 +90,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-resume-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-resume-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' - messages: - role: system content: ${system} @@ -102,7 +102,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-create-disabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' - role: tool tool_call_id: toolcall_0 content: |- @@ -118,7 +118,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"(printf probe > \"$HOME/sandbox-resume-enabled.txt\") 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' + arguments: '{"command":"(printf probe > /var/tmp/sandbox-resume-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' - role: tool tool_call_id: toolcall_1 content: |- From 97dd0d990829402ce96ad3b0bcaafb332a13e3c2 Mon Sep 17 00:00:00 2001 From: Juan Osorio Date: Tue, 25 Aug 2026 18:18:56 -0700 Subject: [PATCH 9/9] Add Rust sandbox behavior E2E --- .github/workflows/rust-sdk-tests.yml | 16 +++ rust/tests/e2e/session_config.rs | 142 ++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 7fdac3b81..d9982927d 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -40,6 +40,14 @@ jobs: toolchain: "1.94.0" components: rustfmt, clippy + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + # Nightly rustfmt for unstable format options (group_imports, # imports_granularity, reorder_impl_items) — pinned in # `.rustfmt.nightly.toml`. @@ -148,6 +156,14 @@ jobs: with: toolchain: "1.94.0" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: workspaces: "rust" diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index c3f6b57ae..da3a93d6b 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -6,19 +6,24 @@ use async_trait::async_trait; use base64::Engine; use bytes::Bytes; use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::session_events::{SessionEventType, ToolExecutionCompleteData}; use github_copilot_sdk::{ Attachment, Client, CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError, CopilotRequestHandler, MessageOptions, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, }; +use github_copilot_sdk::{ + SandboxConfig, SandboxConfigUserPolicy, SandboxConfigUserPolicyFilesystem, +}; use http::{HeaderMap, HeaderValue}; use parking_lot::Mutex; use serde_json::{Value, json}; +use super::support::collect_until_idle; use super::support::{DEFAULT_TEST_TOKEN, E2eContext, with_e2e_context_no_snapshot}; static E2E: super::support::SharedE2eGroup = - super::support::SharedE2eGroup::standard("session_config", 4); + super::support::SharedE2eGroup::standard("session_config", if cfg!(windows) { 4 } else { 5 }); const SYNTHETIC_TEXT: &str = "OK from the synthetic stream."; const CITATION_PROMPT: &str = "Summarize the attached PDF with citations enabled."; @@ -89,6 +94,141 @@ fn task_agent_types(exchange: &Value) -> Vec { panic!("expected task tool in request"); } +fn sandbox_config(enabled: bool, denied_path: &str) -> SandboxConfig { + SandboxConfig { + enabled, + user_policy: Some(SandboxConfigUserPolicy { + filesystem: Some(SandboxConfigUserPolicyFilesystem { + denied_paths: Some(vec![denied_path.to_string()]), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + } +} + +async fn assert_next_shell_execution_result( + session: &github_copilot_sdk::session::Session, + prompt: &str, + expected: &str, +) { + let events = session.subscribe(); + session + .send_and_wait(MessageOptions::new(prompt).with_wait_timeout(Duration::from_secs(120))) + .await + .expect("send_and_wait"); + + let observed = collect_until_idle(events).await; + let completion = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::ToolExecutionComplete) + .and_then(|event| event.typed_data::()) + .expect("tool.execution_complete after sandbox shell prompt"); + let content = &completion + .result + .as_ref() + .expect("sandbox shell result") + .content; + assert!( + content.contains(expected), + "expected sandbox shell result to contain {expected:?}, got {content:?}" + ); +} + +#[tokio::test] +async fn should_apply_sandbox_config_on_create_and_resume() { + if cfg!(windows) { + return; + } + + super::support::with_shared_e2e_context( + &E2E, + "session_config", + "should_apply_sandbox_config_on_create_and_resume", + |ctx| { + Box::pin(async move { + const ENABLED_PROBE: &str = "/var/tmp/sandbox-create-enabled.txt"; + const DISABLED_PROBE: &str = "/var/tmp/sandbox-create-disabled.txt"; + const RESUME_PROBE: &str = "/var/tmp/sandbox-resume-enabled.txt"; + const PROBES: [&str; 3] = [ENABLED_PROBE, DISABLED_PROBE, RESUME_PROBE]; + + for probe in PROBES { + let _ = std::fs::remove_file(probe); + } + + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let mut enabled_config = ctx + .approve_all_session_config() + .with_working_directory(ctx.work_dir()); + enabled_config.sandbox_config = Some(sandbox_config(true, ENABLED_PROBE)); + let enabled_session = client + .create_session(enabled_config) + .await + .expect("create sandbox-enabled session"); + assert_next_shell_execution_result( + &enabled_session, + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked", + ) + .await; + + let mut disabled_config = ctx + .approve_all_session_config() + .with_working_directory(ctx.work_dir()); + disabled_config.sandbox_config = Some(sandbox_config(false, DISABLED_PROBE)); + let disabled_session = client + .create_session(disabled_config) + .await + .expect("create sandbox-disabled session"); + assert_next_shell_execution_result( + &disabled_session, + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible", + ) + .await; + + let mut resume_config = ResumeSessionConfig::new(disabled_session.id().clone()) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_working_directory(ctx.work_dir()); + resume_config.sandbox_config = Some(sandbox_config(true, RESUME_PROBE)); + let resumed_session = client + .resume_session(resume_config) + .await + .expect("resume sandbox-enabled session"); + assert_next_shell_execution_result( + &resumed_session, + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked", + ) + .await; + assert_eq!(resumed_session.id(), disabled_session.id()); + + resumed_session + .disconnect() + .await + .expect("disconnect resumed session"); + disabled_session + .disconnect() + .await + .expect("disconnect disabled session"); + enabled_session + .disconnect() + .await + .expect("disconnect enabled session"); + client.stop().await.expect("stop client"); + + for probe in PROBES { + let _ = std::fs::remove_file(probe); + } + }) + }, + ) + .await; +} + #[tokio::test] async fn should_apply_session_limits_on_create() { super::support::with_shared_e2e_context(