diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 2b9eb9b9..9f541949 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -427,6 +427,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(folders ?? new List(), Context.ConnectionAborted); } + /// The configured SSH workspaces. + public async Task> GetSshWorkspaces() + { + using var ctx = _dbFactory.CreateDbContext(); + return await new AppSettingsRepository(ctx).GetSshWorkspacesAsync(Context.ConnectionAborted); + } + + /// Replaces the SSH workspaces; rejects any entry that fails . + public async Task SetSshWorkspaces(List workspaces) + { + workspaces ??= new List(); + 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> GetSessionSkills() { 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); }); + /// Launch spec for an interactive session on an SSH workspace's server. + public Task 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"); + /// Launch spec for the fresh session a tile hands off to after handoff_session. public Task GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() => { diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index e97fd035..de678c8a 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -51,6 +51,9 @@ public sealed class InteractiveLaunchSpecService // Local wall clock, injectable so the PowerUse time window is testable without sleeping. private readonly Func _now; + /// Issues the per-session tokens that gate the reverse-tunnelled remote MCP listener. + private readonly RemoteSessionTokenRegistry _remoteTokens; + public InteractiveLaunchSpecService( IDbContextFactory dbFactory, ISessionSkillSeeder skillSeeder, @@ -60,8 +63,10 @@ public sealed class InteractiveLaunchSpecService WorkerConfig cfg, HandoffRoundTracker handoffRounds, HandlerRoundState roundState, - Func? now = null) + Func? now = null, + RemoteSessionTokenRegistry? remoteTokens = null) { + _remoteTokens = remoteTokens ?? new RemoteSessionTokenRegistry(); _now = now ?? (() => DateTime.Now); _dbFactory = dbFactory; _skillSeeder = skillSeeder; @@ -293,6 +298,54 @@ public sealed class InteractiveLaunchSpecService return new LaunchSpec(directory, resolvedClaude, args, env); } + /// 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). + public async Task 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 + { + ["mcpServers"] = new Dictionary + { + ["claudedo"] = new + { + type = "http", + url = $"http://127.0.0.1:{remotePort}/mcp", + headers = new Dictionary { [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()); + } + /// 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 /// under ) as the single-line kickoff, and the handoff hint again so diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index f44a033b..964e9501 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -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.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( + () => 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( + () => 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( + () => 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( + () => BuildSshService(new RemoteSessionTokenRegistry()).BuildForSshAsync("w1", "{" + Guid.NewGuid() + "}", CancellationToken.None)); + } }