feat(worker): ssh launch spec with reverse-tunnelled claudedo MCP
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user