Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,11 @@ private async Task UpdateSessionOptionsForModeAsync(CopilotSession session, Sess
bool? manageScheduleEnabled = null;
IList<SessionInstalledPlugin>? installedPlugins = null;

if (config.SandboxConfig is not null)
{
hasAnyPatch = true;
}

if (_options.Mode == CopilotClientMode.Empty)
{
skipCustomInstructions = config.SkipCustomInstructions ?? true;
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -1215,6 +1221,7 @@ public async Task<CopilotSession> 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,
Expand Down Expand Up @@ -1436,6 +1443,7 @@ public async Task<CopilotSession> 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,
Expand Down Expand Up @@ -2795,6 +2803,7 @@ internal record CreateSessionRequest(
IList<string>? DisabledSkills,
InfiniteSessionConfig? InfiniteSessions,
SessionLimitsConfig? SessionLimits,
SandboxConfig? SandboxConfig = null,
IList<CommandWireDefinition>? Commands = null,
bool? RequestElicitation = null,
bool? RequestMcpApps = null,
Expand Down Expand Up @@ -2910,6 +2919,7 @@ internal record ResumeSessionRequest(
IList<string>? DisabledSkills,
InfiniteSessionConfig? InfiniteSessions,
SessionLimitsConfig? SessionLimits,
SandboxConfig? SandboxConfig = null,
IList<CommandWireDefinition>? Commands = null,
bool? RequestElicitation = null,
bool? RequestMcpApps = null,
Expand Down
7 changes: 7 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3609,6 +3610,12 @@ protected SessionConfigBase(SessionConfigBase? other)
[Experimental(Diagnostics.Experimental)]
public SessionLimitsConfig? SessionLimits { get; set; }

/// <summary>
/// Resolved sandbox configuration applied when the session is created or resumed.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public SandboxConfig? SandboxConfig { get; set; }

/// <summary>
/// Configuration for handling large tool outputs. When a tool produces
/// output exceeding the configured size, the output is written to a temp
Expand Down
84 changes: 84 additions & 0 deletions dotnet/test/E2E/SessionConfigE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ public class SessionConfigE2ETests(E2ETestFixture fixture, ITestOutputHelper out
private static readonly byte[] Png1X1 = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");

private static async Task AssertNextShellExecutionResultAsync(
CopilotSession session,
string prompt,
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<ToolExecutionCompleteEvent>());
Assert.Contains(expected, completion.Data.Result?.Content ?? string.Empty, StringComparison.Ordinal);
}

[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.
Expand Down Expand Up @@ -524,6 +536,78 @@ public async Task Should_Apply_Session_Limits_On_Resume()
}
}

[Fact]
public async Task Should_Apply_Sandbox_Config_On_Create_And_Resume()
{
if (OperatingSystem.IsWindows())
{
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
{
WorkingDirectory = Ctx.WorkDir,
SandboxConfig = new SandboxConfig
{
Enabled = true,
UserPolicy = new SandboxConfigUserPolicy
{
Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [enabledProbe] },
},
},
});
await AssertNextShellExecutionResultAsync(
enabledSession,
"Check sandbox access for sandbox-create-enabled.txt.",
"sandbox-blocked");

await using var disabledSession = await CreateSessionAsync(new SessionConfig
{
WorkingDirectory = Ctx.WorkDir,
SandboxConfig = new SandboxConfig
{
Enabled = false,
UserPolicy = new SandboxConfigUserPolicy
{
Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [disabledProbe] },
},
},
});
await AssertNextShellExecutionResultAsync(
disabledSession,
"Check sandbox access for sandbox-create-disabled.txt.",
"sandbox-accessible");
var sessionId = disabledSession.SessionId;
await SuspendAndUntrackSessionForResumeAsync(disabledSession);

var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig
{
WorkingDirectory = Ctx.WorkDir,
SandboxConfig = new SandboxConfig
{
Enabled = true,
UserPolicy = new SandboxConfigUserPolicy
{
Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [resumeProbe] },
},
},
});
await AssertNextShellExecutionResultAsync(
session2,
"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]
public async Task Should_Apply_Excluded_Built_In_Agents_On_Create()
{
Expand Down
32 changes: 30 additions & 2 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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);
Expand All @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1099,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
}
Expand Down Expand Up @@ -1173,6 +1175,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
}
Expand Down Expand Up @@ -1377,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
}
Expand Down
60 changes: 58 additions & 2 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -596,11 +601,20 @@ 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")
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) {
Expand All @@ -613,11 +627,20 @@ 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")
assertSandboxConfig(t, <-updateParams, "http://127.0.0.1:4322")
}

func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) {
Expand All @@ -644,6 +667,7 @@ func assertNewSessionOptions(
expectedFileChangeTracking bool,
expectedAgent string,
expectedCredits float64,
expectedProxyURL string,
) {
t.Helper()

Expand All @@ -668,6 +692,38 @@ 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"])
}
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 {
Expand Down
Loading
Loading