diff --git a/src/ClawdDotNet.Core/Runtimes/ClaudeCodeRuntime.cs b/src/ClawdDotNet.Core/Runtimes/ClaudeCodeRuntime.cs index 2b10080..7a43854 100644 --- a/src/ClawdDotNet.Core/Runtimes/ClaudeCodeRuntime.cs +++ b/src/ClawdDotNet.Core/Runtimes/ClaudeCodeRuntime.cs @@ -101,7 +101,7 @@ public sealed class ClaudeCodeRuntime(Func settings) : IFore ["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" }; - var readOnly = new List(config.ReadOnlyPaths); + var readOnly = new List(config.ReadOnlyPaths.Concat(context.HelperPaths())); var directory = Path.GetDirectoryName(executable)!; if (!directory.StartsWith("/usr/", StringComparison.Ordinal)) readOnly.Add(directory); diff --git a/src/ClawdDotNet.Core/Runtimes/CommandRuntime.cs b/src/ClawdDotNet.Core/Runtimes/CommandRuntime.cs index 6f12ae1..5baa277 100644 --- a/src/ClawdDotNet.Core/Runtimes/CommandRuntime.cs +++ b/src/ClawdDotNet.Core/Runtimes/CommandRuntime.cs @@ -25,7 +25,7 @@ public sealed class CommandRuntime : IForeignRuntime File.WriteAllText(Path.Combine(context.PrivateDirectory, ForeignRunContext.BriefingFileName), context.Briefing); var command = runtime.Command.Trim(); - var readOnly = new List(); + var readOnly = new List(context.HelperPaths()); if (Path.IsPathRooted(command)) { var directory = Path.GetDirectoryName(command)!; diff --git a/src/ClawdDotNet.Core/Runtimes/ForeignRuntime.cs b/src/ClawdDotNet.Core/Runtimes/ForeignRuntime.cs index 253ea6a..9ffab10 100644 --- a/src/ClawdDotNet.Core/Runtimes/ForeignRuntime.cs +++ b/src/ClawdDotNet.Core/Runtimes/ForeignRuntime.cs @@ -56,6 +56,12 @@ public sealed record ForeignRunContext return mounts; } + /// + /// What must be visible read-only for the MCP bridge: the application. Mounted whenever + /// there is MCP — not only when the launcher runs, which needs network or secrets. + /// + public IReadOnlyList HelperPaths() => HasMcp ? Helper!.ReadOnlyPaths : []; + /// What a program in the sandbox needs to reach the MCP server itself. public IReadOnlyDictionary McpEnvironment() { @@ -72,6 +78,8 @@ public sealed record ForeignRunContext environment["CLAWD_MCP_SOCKET"] = McpSocketInSandbox; environment[Mcp.McpStdioBridge.TokenFileVariable] = Private + "/" + TokenFileName; environment["CLAWD_HELPER"] = Helper!.Executable; + foreach (var (key, value) in Helper.Environment) + environment[key] = value; } return environment; } diff --git a/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs b/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs index 6174c9f..899a44b 100644 --- a/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs +++ b/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs @@ -223,7 +223,7 @@ public sealed class BwrapSandbox : ISandboxRunner // #712: what the launcher needs — the helper itself, the proxy's socket, the secrets. if (request.NeedsLauncher && helper is not null) { - foreach (var path in helper.ReadOnlyPaths) + foreach (var path in helper.ReadOnlyPaths.Except(request.ReadOnlyPaths.Select(Path.GetFullPath))) args.AddRange(["--ro-bind", path, path]); if (launch.EgressSocket is { } socket) args.AddRange(["--bind", socket, SandboxPaths.EgressSocket]); diff --git a/tests/ClawdDotNet.Core.Tests/Runtimes/ForeignRuntimeTests.cs b/tests/ClawdDotNet.Core.Tests/Runtimes/ForeignRuntimeTests.cs index 64770f7..394808c 100644 --- a/tests/ClawdDotNet.Core.Tests/Runtimes/ForeignRuntimeTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Runtimes/ForeignRuntimeTests.cs @@ -452,6 +452,20 @@ public sealed class ForeignRuntimeTests : IDisposable sandbox.Request.Environment["CLAWD_TASK_ID"].ShouldBe("t-7"); } + [Fact] + public void With_mcp_the_bridge_is_there_even_without_the_launcher() + { + // No network, no secrets — no launcher. The command still calls $CLAWD_HELPER. + _agent.Runtime = new AgentRuntimeConfig { Kind = RuntimeKinds.Command, Command = "/bin/sh" }; + + var request = new CommandRuntime().Prepare(Context()); + + request.NeedsLauncher.ShouldBeFalse(); + request.ReadOnlyPaths.ShouldContain("/opt/clawd"); + request.Environment["CLAWD_HELPER"].ShouldBe("/opt/clawd/ClawdDotNet"); + request.Environment["DOTNET_ROOT"].ShouldBe("/usr/lib/dotnet"); + } + [Fact] public async Task A_failing_command_is_a_failed_run_with_its_reason() { diff --git a/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs b/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs index fb03419..fb3e213 100644 --- a/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs +++ b/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs @@ -170,7 +170,7 @@ public sealed class McpSocketTests : IDisposable """m1='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'""", """m2='{"jsonrpc":"2.0","id":2,"method":"tools/list"}'""", """m3='{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"Echo","arguments":{}}}'""", - """printf '%s\n' "$m1" "$m2" "$m3" | "$CLAWD_HELPER" --mcp-bridge --socket "$CLAWD_MCP_SOCKET" """, + """printf '%s\n' "$m1" "$m2" "$m3" | "$CLAWD_HELPER" --mcp-bridge --socket "$CLAWD_MCP_SOCKET" 2>&1""", "echo done > /work/result.txt"); var agent = new AgentConfig {