feat(worker): ssh launch spec with reverse-tunnelled claudedo MCP

This commit is contained in:
mika kuns
2026-10-02 14:26:52 +02:00
parent 34970ca600
commit 1b58e628e6
3 changed files with 166 additions and 1 deletions
@@ -1653,4 +1653,91 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.All(specs, spec =>
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]));
}
// ── SSH workspaces ──
private async Task SeedSshAsync(params SshWorkspace[] ws)
{
await using var ctx = _db.CreateFactory().CreateDbContext();
await new AppSettingsRepository(ctx).SetSshWorkspacesAsync(ws);
}
private InteractiveLaunchSpecService BuildSshService(RemoteSessionTokenRegistry tokens, int remoteMcpPort = 47_823) =>
new(_db.CreateFactory(), _seeder, _registry,
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new GitService(),
new WorkerConfig { ClaudeBin = _claudeStubPath, RemoteMcpPort = remoteMcpPort },
new HandoffRoundTracker(), NewRoundState(), remoteTokens: tokens);
[Fact]
public async Task BuildForSshAsync_BuildsTunnelledSshSpec()
{
await SeedSshAsync(new SshWorkspace("w1", "VPS", "vps", "~/app"));
var tokens = new RemoteSessionTokenRegistry();
var paneId = NewPaneId();
var spec = await BuildSshService(tokens).BuildForSshAsync("w1", paneId, CancellationToken.None);
Assert.EndsWith("ssh.exe", spec.Exe, StringComparison.OrdinalIgnoreCase);
Assert.Equal(["-t", "-o", "ExitOnForwardFailure=yes", "-R"], spec.Args.Take(4));
var forward = spec.Args[4].Split(':');
Assert.InRange(int.Parse(forward[0]), 20_000, 60_999);
Assert.Equal(["127.0.0.1", "47823"], forward.Skip(1));
Assert.Equal("vps", spec.Args[5]);
Assert.Equal(7, spec.Args.Count);
// ConPTY flattens Args into one Windows command line; an embedded quote would split it.
Assert.DoesNotContain(spec.Args, a => a.Contains('"'));
var remote = spec.Args[6];
Assert.Contains("cd ~/'app'", remote);
Assert.Contains($"MCP_TOOL_TIMEOUT={InteractiveLaunchSpecService.McpToolTimeoutMs} claude --mcp-config ~/.claudedo/mcp-{paneId}.json", remote);
Assert.EndsWith($"rm -f ~/.claudedo/mcp-{paneId}.json", remote);
var b64 = System.Text.RegularExpressions.Regex.Match(remote, @"echo ([A-Za-z0-9+/=]+) \| base64 -d").Groups[1].Value;
using var json = System.Text.Json.JsonDocument.Parse(Convert.FromBase64String(b64));
var server = json.RootElement.GetProperty("mcpServers").GetProperty("claudedo");
Assert.Equal("http", server.GetProperty("type").GetString());
Assert.Equal($"http://127.0.0.1:{forward[0]}/mcp", server.GetProperty("url").GetString());
var token = server.GetProperty("headers").GetProperty(ExternalMcpAuthMiddleware.RemoteTokenHeader).GetString();
Assert.True(tokens.IsValid(token));
}
[Fact]
public async Task BuildForSshAsync_DoesNotRecordMissionControlSession()
{
await SeedSshAsync(new SshWorkspace("w1", "VPS", "vps", "/srv/app"));
await BuildSshService(new RemoteSessionTokenRegistry()).BuildForSshAsync("w1", NewPaneId(), CancellationToken.None);
await using var ctx = _db.CreateFactory().CreateDbContext();
Assert.Empty(await ctx.MissionControlSessions.ToListAsync());
}
[Fact]
public async Task BuildForSshAsync_UnknownWorkspace_Throws()
=> await Assert.ThrowsAsync<KeyNotFoundException>(
() => BuildSshService(new RemoteSessionTokenRegistry()).BuildForSshAsync("nope", NewPaneId(), CancellationToken.None));
[Fact]
public async Task BuildForSshAsync_InvalidStoredWorkspace_Throws()
{
await SeedSshAsync(new SshWorkspace("w1", "VPS", "-oProxyCommand=calc", "/srv/app"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => BuildSshService(new RemoteSessionTokenRegistry()).BuildForSshAsync("w1", NewPaneId(), CancellationToken.None));
}
[Fact]
public async Task BuildForSshAsync_RemoteMcpDisabled_Throws()
{
await SeedSshAsync(new SshWorkspace("w1", "VPS", "vps", "/srv/app"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => BuildSshService(new RemoteSessionTokenRegistry(), remoteMcpPort: 0).BuildForSshAsync("w1", NewPaneId(), CancellationToken.None));
}
[Fact]
public async Task BuildForSshAsync_NonGuidPaneId_Throws()
{
await SeedSshAsync(new SshWorkspace("w1", "VPS", "vps", "/srv/app"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => BuildSshService(new RemoteSessionTokenRegistry()).BuildForSshAsync("w1", "{" + Guid.NewGuid() + "}", CancellationToken.None));
}
}