229 lines
11 KiB
C#
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;
|
|
}
|
|
}
|