diff --git a/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs b/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs index fb3e213..14cfcb2 100644 --- a/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs +++ b/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs @@ -186,6 +186,65 @@ public sealed class McpSocketTests : IDisposable outcome.Text.ShouldContain("added: result.txt"); } + /// + /// #712: The Claude Code path in the real sandbox, with a stand-in for claude — + /// the real one does not run on the test machine, and a test has no key. The stand-in + /// checks what Claude Code would be given (arguments, key, home, MCP configuration), + /// reaches the API host through the proxy, calls the MCP server the way Claude Code + /// would (the command from mcp.json) and answers in stream-json. + /// + [ForeignRunFact] + public async Task The_claude_code_path_holds_together_in_the_real_sandbox() + { + await using var host = NewHost(); + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + web.ShouldNotBeNull(); + + var bin = Path.Combine(_root, "fake-claude"); + Directory.CreateDirectory(bin); + var claude = Path.Combine(bin, "claude"); + File.WriteAllText(claude, string.Join("\n", + "#!/bin/sh", + """case " $* " in *dangerously*) echo "bypass passed" >&2; exit 9;; esac""", + """[ -n "$ANTHROPIC_API_KEY" ] || { echo "no key" >&2; exit 8; }""", + """[ -w "$HOME/.claude/settings.json" ] || { echo "no home" >&2; exit 7; }""", + """prompt=$(cat)""", + """api=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 https://api.anthropic.com/ 2>&1)""", + """cmd=$(sed -n 's/.*"command":"\([^"]*\)".*/\1/p' /run/agent/mcp.json)""", + """tools=$(printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | CLAWD_MCP_TOKEN_FILE=/run/agent/mcp-token "$cmd" --mcp-bridge --socket /run/mcp.sock 2>&1 | tr -d '"')""", + """echo "changed by claude" > /work/answer.txt""", + """printf '%s\n' '{"type":"system","subtype":"init","model":"claude-test"}'""", + """printf '{"type":"result","subtype":"success","is_error":false,"num_turns":1,"result":"prompt=%s api=%s tools=%s","total_cost_usd":0.01,"usage":{"input_tokens":10,"output_tokens":5}}\n' "$prompt" "$api" "$tools" """, + "")); + if (OperatingSystem.IsLinux()) + File.SetUnixFileMode(claude, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var sandbox = (Core.Sandbox.BwrapSandbox)Core.Sandbox.BwrapSandbox.Detect(NullLogger.Instance); + var helper = Core.Sandbox.SandboxHelper.For(ForeignRunFactAttribute.HelperPath!); + sandbox.Helper = helper; + var settings = new Core.Runtimes.ClaudeCodeSettings { Executable = claude, ApiKey = "sk-ant-fake" }; + var runner = new Core.Runtimes.ForeignRunner(sandbox, [new Core.Runtimes.ClaudeCodeRuntime(() => settings)], host.Mcp, + InstanceEndpoint.McpSocketPath(host.InstancePath), helper, NullLogger.Instance); + + var agentDir = Path.Combine(host.InstancePath, "Agents", "Agent-coder"); + Directory.CreateDirectory(Path.Combine(agentDir, "Workspace")); + var agent = new AgentConfig + { + AgentId = "coder", AgentDir = agentDir, WorkspacePath = Path.Combine(agentDir, "Workspace"), + Runtime = new Core.Runtimes.AgentRuntimeConfig { Kind = "claude-code", TimeoutMinutes = 2 } + }; + + var outcome = await runner.RunAsync(new Core.Runtimes.ForeignRunRequest(agent, "run-cc", null, "brief", "fix it"), _ => { }, CancellationToken.None); + + outcome.Status.ShouldBe(AgentRunStatus.Completed, outcome.Text); + outcome.Text!.ShouldContain("prompt=fix it"); + outcome.Text.ShouldNotContain("api=000", customMessage: "the API host is reached through the proxy"); + outcome.Text.ShouldContain("name:Echo"); + outcome.Text.ShouldContain("added: answer.txt"); + outcome.Model.ShouldBe("claude-test"); + outcome.CostUsd.ShouldBe(0.01m); + } + private sealed class EchoTool : IAgentTool { public string Name => "Echo";