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
+25
View File
@@ -427,6 +427,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(folders ?? new List<string>(), Context.ConnectionAborted); await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(folders ?? new List<string>(), Context.ConnectionAborted);
} }
/// <summary>The configured SSH workspaces.</summary>
public async Task<List<SshWorkspace>> GetSshWorkspaces()
{
using var ctx = _dbFactory.CreateDbContext();
return await new AppSettingsRepository(ctx).GetSshWorkspacesAsync(Context.ConnectionAborted);
}
/// <summary>Replaces the SSH workspaces; rejects any entry that fails <see cref="SshWorkspace.Validate"/>.</summary>
public async Task SetSshWorkspaces(List<SshWorkspace> workspaces)
{
workspaces ??= new List<SshWorkspace>();
if (workspaces.Select(SshWorkspace.Validate).FirstOrDefault(f => f is not null) is { } field)
throw new HubException($"Invalid SSH workspace {field}.");
using var ctx = _dbFactory.CreateDbContext();
await new AppSettingsRepository(ctx).SetSshWorkspacesAsync(workspaces, Context.ConnectionAborted);
}
public async Task<List<SessionSkillDto>> GetSessionSkills() public async Task<List<SessionSkillDto>> GetSessionSkills()
{ {
var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted); var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted);
@@ -818,6 +835,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted); return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
}); });
/// <summary>Launch spec for an interactive session on an SSH workspace's server.</summary>
public Task<LaunchSpec> GetSshLaunchSpec(string workspaceId, string paneId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForSshAsync(workspaceId, paneId, Context.ConnectionAborted);
}, "ssh workspace not found");
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary> /// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() => public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
{ {
@@ -51,6 +51,9 @@ public sealed class InteractiveLaunchSpecService
// Local wall clock, injectable so the PowerUse time window is testable without sleeping. // Local wall clock, injectable so the PowerUse time window is testable without sleeping.
private readonly Func<DateTime> _now; private readonly Func<DateTime> _now;
/// <summary>Issues the per-session tokens that gate the reverse-tunnelled remote MCP listener.</summary>
private readonly RemoteSessionTokenRegistry _remoteTokens;
public InteractiveLaunchSpecService( public InteractiveLaunchSpecService(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
ISessionSkillSeeder skillSeeder, ISessionSkillSeeder skillSeeder,
@@ -60,8 +63,10 @@ public sealed class InteractiveLaunchSpecService
WorkerConfig cfg, WorkerConfig cfg,
HandoffRoundTracker handoffRounds, HandoffRoundTracker handoffRounds,
HandlerRoundState roundState, HandlerRoundState roundState,
Func<DateTime>? now = null) Func<DateTime>? now = null,
RemoteSessionTokenRegistry? remoteTokens = null)
{ {
_remoteTokens = remoteTokens ?? new RemoteSessionTokenRegistry();
_now = now ?? (() => DateTime.Now); _now = now ?? (() => DateTime.Now);
_dbFactory = dbFactory; _dbFactory = dbFactory;
_skillSeeder = skillSeeder; _skillSeeder = skillSeeder;
@@ -293,6 +298,54 @@ public sealed class InteractiveLaunchSpecService
return new LaunchSpec(directory, resolvedClaude, args, env); return new LaunchSpec(directory, resolvedClaude, args, env);
} }
/// <summary>Builds a LaunchSpec that runs claude on an SSH workspace's server, with the
/// claudedo MCP reverse-tunnelled to the token-gated remote listener for the lifetime of the
/// ssh process. Not recorded as a Mission Control session (no resume/handoff for remote tiles).</summary>
public async Task<LaunchSpec> BuildForSshAsync(string workspaceId, string paneId, CancellationToken ct)
{
if (_cfg.RemoteMcpPort <= 0)
throw new InvalidOperationException("SSH sessions are disabled (remote_mcp_port = 0).");
// paneId lands in the remote shell command; only a bare GUID is safe there.
if (!Guid.TryParse(paneId, out _) || !paneId.All(c => char.IsAsciiHexDigit(c) || c == '-'))
throw new InvalidOperationException($"Invalid paneId: {paneId}");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var ws = (await new AppSettingsRepository(ctx).GetSshWorkspacesAsync(ct)).FirstOrDefault(w => w.Id == workspaceId)
?? throw new KeyNotFoundException($"SSH workspace not found: {workspaceId}");
if (SshWorkspace.Validate(ws) is { } field)
throw new InvalidOperationException($"SSH workspace '{ws.Name}' has an invalid {field}.");
var ssh = WindowsTerminalLauncher.Resolve("ssh")
?? throw new InvalidOperationException("ssh executable not found on PATH (install Windows OpenSSH client).");
var remotePort = Random.Shared.Next(20_000, 61_000);
var mcpJson = JsonSerializer.Serialize(new Dictionary<string, object>
{
["mcpServers"] = new Dictionary<string, object>
{
["claudedo"] = new
{
type = "http",
url = $"http://127.0.0.1:{remotePort}/mcp",
headers = new Dictionary<string, string> { [ExternalMcpAuthMiddleware.RemoteTokenHeader] = _remoteTokens.Issue() },
},
},
});
var b64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(mcpJson));
var cfgFile = $"~/.claudedo/mcp-{paneId}.json";
// No double quotes anywhere: ConPTY flattens Args into one Windows command line.
var remote =
$"umask 077 && mkdir -p ~/.claudedo && echo {b64} | base64 -d > {cfgFile}" +
$" && cd {SshWorkspace.QuoteRemotePath(ws.RemotePath)}" +
$" && MCP_TOOL_TIMEOUT={McpToolTimeoutMs} claude --mcp-config {cfgFile}; rm -f {cfgFile}";
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return new LaunchSpec(home, ssh,
["-t", "-o", "ExitOnForwardFailure=yes", "-R", $"{remotePort}:127.0.0.1:{_cfg.RemoteMcpPort}", ws.Host, remote],
new Dictionary<string, string>());
}
/// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via /// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via
/// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool /// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool
/// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so /// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so
@@ -1653,4 +1653,91 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.All(specs, spec => Assert.All(specs, spec =>
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"])); 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));
}
} }