Files
ClawdDotNet/tests/ClawdDotNet.Web.Tests/McpSocketTests.cs
T

229 lines
11 KiB
C#

using System.Collections.Concurrent;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Mcp;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Web.Tests;
/// <summary>
/// #713: The MCP server on the instance's HTTP surface. What must hold: its socket leads to
/// <c>/mcp</c> and nowhere else — the instance socket opens everything without a token, and a
/// foreign runtime must never get that. Without an MCP token nobody is anybody, and a browser
/// is turned away.
/// </summary>
public sealed class McpSocketTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "mcp-" + Guid.NewGuid().ToString("N")[..8]);
private const string WebToken = "ein-hinreichend-langes-testtoken";
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); }
catch { /* cleanup is secondary */ }
}
private AppHost NewHost(int port = 0)
{
var instancesDir = Path.Combine(_root, "Instances");
var instancePath = Path.Combine(instancesDir, "Test");
Directory.CreateDirectory(instancePath);
var tools = new ToolRegistry();
tools.Register(new EchoTool());
var agents = new List<AgentConfig> { new() { AgentId = "coder", Tools = { ["Echo"] = new Dictionary<string, object?>() } } };
var engine = new AgentEngine(new NoModel(), tools, new PermissionGate(), new MemoryState(), NullLoggerFactory.Instance);
engine.SetAgentConfigProvider(() => agents, "test", _ => null);
return new AppHost
{
Settings = new SettingsManager(_root),
Directories = new InstanceDirectoryManager(instancesDir),
Instance = new InstanceConfig
{
InstanceName = "Test", WorkingDirectory = instancePath, Agents = agents,
WebUiEnabled = port > 0, WebUiBinding = "loopback", WebUiToken = port > 0 ? WebToken : "", WebServerPort = port
},
InstancePath = instancePath,
LogDirectory = Path.Combine(instancePath, "Logs"),
LoggerFactory = NullLoggerFactory.Instance,
Tools = tools,
McpToolHost = engine,
Mcp = new McpGrants()
};
}
private static StringContent Rpc(string method)
=> new(new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = method }.ToJsonString(), Encoding.UTF8, "application/json");
[Fact]
public async Task The_mcp_socket_leads_to_mcp_and_nowhere_else()
{
await using var host = NewHost();
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
using var client = InstanceEndpoint.CreateMcpClient(host.InstancePath);
(await client.GetAsync("/api/info")).StatusCode.ShouldBe(HttpStatusCode.NotFound,
"the administration answers without a token on the instance socket — never on this one");
(await client.PostAsync("/api/mcp/tokens", new StringContent("{}", Encoding.UTF8, "application/json")))
.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact]
public async Task Without_a_token_there_is_nobody_with_one_the_agents_tools()
{
await using var host = NewHost();
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
using var client = InstanceEndpoint.CreateMcpClient(host.InstancePath);
(await client.PostAsync("/mcp", Rpc("tools/list"))).StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
using var run = host.Mcp!.OpenRun("coder", "run-1");
client.DefaultRequestHeaders.Add("Authorization", "Bearer " + run.Token);
var answer = await client.PostAsync("/mcp", Rpc("tools/list"));
answer.EnsureSuccessStatusCode();
JsonNode.Parse(await answer.Content.ReadAsStringAsync())!["result"]!["tools"]![0]!["name"]!
.GetValue<string>().ShouldBe("Echo");
}
[Fact]
public async Task Over_tcp_the_web_token_does_not_open_mcp_and_a_browser_is_turned_away()
{
var port = FreePort();
await using var host = NewHost(port);
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
client.DefaultRequestHeaders.Add("Authorization", "Bearer " + WebToken);
(await client.PostAsync("/mcp", Rpc("ping"))).StatusCode.ShouldBe(HttpStatusCode.Unauthorized,
"the WebUI token is not an MCP token — it names no agent");
using var run = host.Mcp!.OpenRun("coder", "run-1");
using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = Rpc("ping") };
request.Headers.Add("Authorization", "Bearer " + run.Token);
request.Headers.Add("Origin", "http://evil.example");
(await client.SendAsync(request)).StatusCode.ShouldBe(HttpStatusCode.Forbidden);
}
[Fact]
public async Task A_notification_is_accepted_without_a_body()
{
await using var host = NewHost();
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
using var client = InstanceEndpoint.CreateMcpClient(host.InstancePath);
using var run = host.Mcp!.OpenRun("coder", "run-1");
client.DefaultRequestHeaders.Add("Authorization", "Bearer " + run.Token);
var answer = await client.PostAsync("/mcp", new StringContent("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", Encoding.UTF8, "application/json"));
answer.StatusCode.ShouldBe(HttpStatusCode.Accepted);
}
private static int FreePort()
{
using var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
/// <summary>
/// #712: The whole way back from a foreign run — a program in the real sandbox starts the
/// MCP bridge, which reaches this instance's MCP server through the mounted socket with
/// the run's token. Linux with a sandbox and the built application only.
/// </summary>
[ForeignRunFact]
public async Task A_program_in_the_sandbox_reaches_the_mcp_server_as_its_agent()
{
await using var host = NewHost();
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
var sandbox = (Core.Sandbox.BwrapSandbox)Core.Sandbox.BwrapSandbox.Detect(NullLogger.Instance);
var helper = Core.Sandbox.SandboxHelper.For(ForeignRunFactAttribute.HelperPath!);
sandbox.Helper = helper;
var runner = new Core.Runtimes.ForeignRunner(sandbox, [new Core.Runtimes.CommandRuntime()], host.Mcp,
InstanceEndpoint.McpSocketPath(host.InstancePath), helper, NullLogger.Instance);
var agentDir = Path.Combine(host.InstancePath, "Agents", "Agent-coder");
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
// One command per line: the file's line endings must not decide what the shell sees.
var script = string.Join("\n",
"""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" 2>&1""",
"echo done > /work/result.txt");
var agent = new AgentConfig
{
AgentId = "coder", AgentDir = agentDir, WorkspacePath = Path.Combine(agentDir, "Workspace"),
Runtime = new Core.Runtimes.AgentRuntimeConfig { Kind = "command", Command = "/bin/sh", Arguments = ["-c", script], TimeoutMinutes = 2 }
};
var outcome = await runner.RunAsync(new Core.Runtimes.ForeignRunRequest(agent, "run-e2e", null, "brief", "go"), _ => { }, CancellationToken.None);
outcome.Status.ShouldBe(AgentRunStatus.Completed, outcome.Text);
outcome.Text!.ShouldContain("\"name\":\"Echo\"");
outcome.Text.ShouldContain("echo");
outcome.Text.ShouldContain("added: result.txt");
}
private sealed class EchoTool : IAgentTool
{
public string Name => "Echo";
public string Description => "Echoes.";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
=> Task.FromResult(ToolResult.Ok("echo"));
}
private sealed class NoModel : IChatCompletionClient
{
public Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
=> throw new InvalidOperationException("No model in this test.");
}
private sealed class MemoryState : IStateStore
{
private readonly ConcurrentDictionary<string, string> _values = new();
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult(_values.GetValueOrDefault(key));
public Task SetAsync(string key, string value, CancellationToken ct) { _values[key] = value; return Task.CompletedTask; }
public Task DeleteAsync(string key, CancellationToken ct) { _values.TryRemove(key, out _); return Task.CompletedTask; }
}
}
/// <summary>#712: Linux with a sandbox, and CLAWD_TEST_HELPER naming the built ClawdDotNet.</summary>
public sealed class ForeignRunFactAttribute : FactAttribute
{
public static string? HelperPath =>
Environment.GetEnvironmentVariable("CLAWD_TEST_HELPER") is { Length: > 0 } path && File.Exists(path) ? path : null;
public ForeignRunFactAttribute()
{
if (!OperatingSystem.IsLinux())
Skip = "Linux only.";
else if (HelperPath is null)
Skip = "Set CLAWD_TEST_HELPER to the built ClawdDotNet executable.";
else if (Core.Sandbox.BwrapSandbox.Detect(NullLogger.Instance).UnavailableReason is { } reason)
Skip = "No sandbox here: " + reason;
}
}