diff --git a/docs/superpowers/plans/2026-10-02-ssh-workspaces.md b/docs/superpowers/plans/2026-10-02-ssh-workspaces.md new file mode 100644 index 00000000..2bc29ce6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-ssh-workspaces.md @@ -0,0 +1,1063 @@ +# SSH Workspaces Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Start an interactive `claude` session on a server via ssh as a Mission Control tile, with the `claudedo` MCP tunnelled back for the lifetime of the session. + +**Architecture:** SSH workspaces (name, ssh-config host alias, remote path) live as a JSON column on `app_settings`. The worker builds a `LaunchSpec` that runs `ssh.exe -R :127.0.0.1: ""`; the remote command writes a base64-decoded MCP config and starts `claude --mcp-config`. The external MCP app gets a second loopback listener (`RemoteMcpPort`) on which every request must carry a per-session token. + +**Tech Stack:** .NET 8, EF Core SQLite, ASP.NET Core (Kestrel), SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit. + +Spec: `docs/superpowers/specs/2026-10-02-ssh-workspaces-design.md` + +**Build/test rules (from CLAUDE.md):** always `-c Release`; build the whole solution (`dotnet build ClaudeDo.slnx -c Release`), test only with `--no-build --filter`. Stage files explicitly by path, never `git add -A`; commit with `git commit -- ` (shared checkout). + +--- + +## File map + +| File | Change | +|---|---| +| `src/ClaudeDo.Data/Models/SshWorkspace.cs` | **new** — record + `Validate` + `QuoteRemotePath` | +| `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` | add `SshWorkspaces` | +| `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs` | map `ssh_workspaces` | +| `src/ClaudeDo.Data/Migrations/*_AddSshWorkspaces.cs` | **generated** | +| `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs` | `Get/SetSshWorkspacesAsync` | +| `src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs` | **new** | +| `src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs` | remote-port token rule | +| `src/ClaudeDo.Worker/Config/WorkerConfig.cs` | `RemoteMcpPort` | +| `src/ClaudeDo.Worker/Program.cs` | registry DI + Share + second URL | +| `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` | `BuildForSshAsync` | +| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | `GetSshLaunchSpec`, `Get/SetSshWorkspaces` | +| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, `Services/WorkerClient.cs`, `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | 3 new methods | +| `src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs` | SSH targets + `OpenSshConPtySessionAsync` | +| `src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs` | **new** — tab VM, row VM, ssh-config parser | +| `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` | wire tab | +| `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` | SSH TabItem | +| `src/ClaudeDo.Localization/locales/en.json`, `de.json` | keys | + +--- + +### Task 1: Data — SshWorkspace model, column, repository + +**Files:** +- Create: `src/ClaudeDo.Data/Models/SshWorkspace.cs` +- Modify: `src/ClaudeDo.Data/Models/AppSettingsEntity.cs`, `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs`, `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs` +- Generate: migration `AddSshWorkspaces` +- Test: `tests/ClaudeDo.Data.Tests/SshWorkspaceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Tests; + +public sealed class SshWorkspaceTests : IDisposable +{ + private readonly string _dbPath; + private readonly DbContextOptions _options; + + public SshWorkspaceTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ssh_{Guid.NewGuid():N}.db"); + _options = new DbContextOptionsBuilder().UseSqlite($"Data Source={_dbPath}").Options; + using var ctx = new ClaudeDoDbContext(_options); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + [Theory] + [InlineData("VPS", "vps", "/home/mika/app", null)] + [InlineData("VPS", "mika@vps.example.com", "~/app", null)] + [InlineData("VPS", "vps", "~", null)] + [InlineData("", "vps", "/x", "name")] + [InlineData("VPS", "", "/x", "host")] + [InlineData("VPS", "-oProxyCommand=calc", "/x", "host")] + [InlineData("VPS", "vps host", "/x", "host")] + [InlineData("VPS", "vps", "relative/path", "path")] + [InlineData("VPS", "vps", "/it's", "path")] + [InlineData("VPS", "vps", "/a\nb", "path")] + public void Validate_ReturnsFieldOfFirstProblem(string name, string host, string path, string? expected) + => Assert.Equal(expected, SshWorkspace.Validate(new SshWorkspace("id", name, host, path))); + + [Theory] + [InlineData("/home/mika/app", "'/home/mika/app'")] + [InlineData("~", "~")] + [InlineData("~/app dir", "~/'app dir'")] + public void QuoteRemotePath_KeepsTildeExpandable(string path, string expected) + => Assert.Equal(expected, SshWorkspace.QuoteRemotePath(path)); + + [Fact] + public async Task SetThenGet_RoundTrips_AndSurvivesGeneralSettingsUpdate() + { + var ws = new SshWorkspace("w1", "VPS", "vps", "/srv/app"); + await using (var ctx = new ClaudeDoDbContext(_options)) + await new AppSettingsRepository(ctx).SetSshWorkspacesAsync([ws]); + + // A general settings save copies named fields only; it must not clobber the workspaces. + await using (var ctx = new ClaudeDoDbContext(_options)) + await new AppSettingsRepository(ctx).UpdateAsync(new AppSettingsEntity()); + + await using (var ctx = new ClaudeDoDbContext(_options)) + Assert.Equal([ws], await new AppSettingsRepository(ctx).GetSshWorkspacesAsync()); + } + + [Fact] + public async Task Get_WithCorruptJson_ReturnsEmpty() + { + await using (var ctx = new ClaudeDoDbContext(_options)) + { + await new AppSettingsRepository(ctx).GetAsync(); + await ctx.Database.ExecuteSqlRawAsync("UPDATE app_settings SET ssh_workspaces = 'not json'"); + } + await using (var ctx = new ClaudeDoDbContext(_options)) + Assert.Empty(await new AppSettingsRepository(ctx).GetSshWorkspacesAsync()); + } +} +``` + +Note: check that `AppSettingsRepository.GetAsync()` creates the singleton row (it is used that way in `WorkerHub.GetAppSettings`); if it does not, seed the row via `SetSshWorkspacesAsync([])` instead. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet build tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release` +Expected: compile errors (`SshWorkspace` not defined). + +- [ ] **Step 3: Implement** + +`src/ClaudeDo.Data/Models/SshWorkspace.cs`: + +```csharp +using System.Text.RegularExpressions; + +namespace ClaudeDo.Data.Models; + +/// A server an interactive claude session can be started on via ssh; connection details live in the user's ssh config. +public sealed record SshWorkspace(string Id, string Name, string Host, string RemotePath) +{ + // A leading '-' would make ssh parse the alias as an option (-oProxyCommand=... runs local code). + private static readonly Regex HostPattern = new(@"^[A-Za-z0-9._@][A-Za-z0-9._@-]*$", RegexOptions.Compiled); + + /// Returns the first invalid field ("name", "host", "path") or null when valid. + public static string? Validate(SshWorkspace w) + { + if (string.IsNullOrWhiteSpace(w.Name)) return "name"; + if (string.IsNullOrEmpty(w.Host) || !HostPattern.IsMatch(w.Host)) return "host"; + var p = w.RemotePath ?? ""; + if (!(p.StartsWith('/') || p.StartsWith('~')) || p.Contains('\'') || p.Contains('\n') || p.Contains('\r')) + return "path"; + return null; + } + + /// Single-quotes a validated remote path for a POSIX shell, leaving a leading ~ unquoted so it still expands. + public static string QuoteRemotePath(string path) + { + if (path == "~") return "~"; + if (path.StartsWith("~/")) return "~/'" + path[2..] + "'"; + return "'" + path + "'"; + } +} +``` + +`AppSettingsEntity.cs` — add after `RepoImportFolders`: + +```csharp + // JSON array of SshWorkspace rows offered as remote quick sessions in Mission Control. + public string? SshWorkspaces { get; set; } +``` + +`AppSettingsEntityConfiguration.cs` — after the `RepoImportFolders` mapping: + +```csharp + builder.Property(s => s.SshWorkspaces) + .HasColumnName("ssh_workspaces"); +``` + +`AppSettingsRepository.cs` — after `SetRepoImportFoldersAsync`: + +```csharp + /// Reads the configured SSH workspaces; missing or corrupt JSON yields an empty list. + public async Task> GetSshWorkspacesAsync(CancellationToken ct = default) + { + var json = await _context.AppSettings.AsNoTracking() + .Where(s => s.Id == AppSettingsEntity.SingletonId) + .Select(s => s.SshWorkspaces) + .FirstOrDefaultAsync(ct); + + if (string.IsNullOrWhiteSpace(json)) return new List(); + try { return JsonSerializer.Deserialize>(json) ?? new List(); } + catch (JsonException) { return new List(); } + } + + /// Writes only the SSH workspaces column; an empty list clears it. + public async Task SetSshWorkspacesAsync(IEnumerable workspaces, CancellationToken ct = default) + { + var list = workspaces.ToList(); + var row = await GetOrCreateTrackedRowAsync(ct); + row.SshWorkspaces = list.Count == 0 ? null : JsonSerializer.Serialize(list); + await _context.SaveChangesAsync(ct); + } +``` + +Generate the migration: + +```bash +dotnet ef migrations add AddSshWorkspaces --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release +``` + +Expected: new `*_AddSshWorkspaces.cs` with a single `AddColumn(name: "ssh_workspaces", table: "app_settings", nullable: true)`, plus the snapshot update. If the generated migration touches anything else, stop and report. + +- [ ] **Step 4: Run tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~SshWorkspaceTests|FullyQualifiedName~Migration" +``` +Expected: all pass (the migration tests assert "nothing pending"). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Models/SshWorkspace.cs src/ClaudeDo.Data/Models/AppSettingsEntity.cs src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs src/ClaudeDo.Data/Migrations/ tests/ClaudeDo.Data.Tests/SshWorkspaceTests.cs +git commit -m "feat(data): store ssh workspaces on app_settings" -- src/ClaudeDo.Data tests/ClaudeDo.Data.Tests/SshWorkspaceTests.cs +``` + +--- + +### Task 2: Worker — remote-only MCP listener with per-session tokens + +**Files:** +- Create: `src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs` +- Modify: `src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs`, `src/ClaudeDo.Worker/Config/WorkerConfig.cs`, `src/ClaudeDo.Worker/Program.cs` +- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.External; +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class ExternalMcpAuthMiddlewareTests +{ + private const int RemotePort = 47_823; + + private static async Task<(int Status, bool Passed)> Run(WorkerConfig cfg, RemoteSessionTokenRegistry tokens, + int localPort, string? remoteToken = null, string? apiKey = null) + { + var passed = false; + var mw = new ExternalMcpAuthMiddleware(_ => { passed = true; return Task.CompletedTask; }); + var ctx = new DefaultHttpContext(); + ctx.Connection.LocalPort = localPort; + if (remoteToken is not null) ctx.Request.Headers[ExternalMcpAuthMiddleware.RemoteTokenHeader] = remoteToken; + if (apiKey is not null) ctx.Request.Headers["X-ClaudeDo-Key"] = apiKey; + await mw.InvokeAsync(ctx, cfg, tokens); + return (ctx.Response.StatusCode, passed); + } + + private static WorkerConfig Cfg(string? key = null) => + new() { ExternalMcpPort = 47_822, RemoteMcpPort = RemotePort, ExternalMcpApiKey = key }; + + [Fact] + public async Task RemotePort_WithoutToken_Is401() + { + var (status, passed) = await Run(Cfg(), new RemoteSessionTokenRegistry(), RemotePort); + Assert.Equal(401, status); + Assert.False(passed); + } + + [Fact] + public async Task RemotePort_WithUnknownToken_Is401() + { + var (status, passed) = await Run(Cfg(), new RemoteSessionTokenRegistry(), RemotePort, remoteToken: "nope"); + Assert.Equal(401, status); + Assert.False(passed); + } + + [Fact] + public async Task RemotePort_WithIssuedToken_PassesEvenWhenApiKeyConfigured() + { + var tokens = new RemoteSessionTokenRegistry(); + var token = tokens.Issue(); + var (_, passed) = await Run(Cfg(key: "secret"), tokens, RemotePort, remoteToken: token); + Assert.True(passed); + } + + [Fact] + public async Task RemotePort_ApiKeyAlone_DoesNotPass() + { + var (status, passed) = await Run(Cfg(key: "secret"), new RemoteSessionTokenRegistry(), RemotePort, apiKey: "secret"); + Assert.Equal(401, status); + Assert.False(passed); + } + + [Fact] + public async Task NormalPort_WithoutKeyConfigured_StillPasses() + { + var (_, passed) = await Run(Cfg(), new RemoteSessionTokenRegistry(), 47_822); + Assert.True(passed); + } + + [Fact] + public async Task Issue_ReturnsDistinct64HexTokens() + { + var tokens = new RemoteSessionTokenRegistry(); + var a = tokens.Issue(); + var b = tokens.Issue(); + Assert.NotEqual(a, b); + Assert.Matches("^[0-9A-F]{64}$", a); + Assert.True(tokens.IsValid(a)); + Assert.False(tokens.IsValid("")); + await Task.CompletedTask; + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release` +Expected: compile errors (`RemoteSessionTokenRegistry`, `RemoteMcpPort`, `RemoteTokenHeader` missing). + +- [ ] **Step 3: Implement** + +`src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs`: + +```csharp +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace ClaudeDo.Worker.External; + +/// Tokens issued to SSH sessions; the only credential accepted on the remote MCP listener. +public sealed class RemoteSessionTokenRegistry +{ + // ponytail: tokens live until worker restart (no revoke on tile close); a leaked token only works while some tunnel to RemoteMcpPort is open. + private readonly ConcurrentDictionary _tokens = new(); + + /// Mints and registers a fresh 256-bit hex token. + public string Issue() + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + _tokens[token] = 0; + return token; + } + + /// True when the token was issued by this worker instance. + public bool IsValid(string? token) => !string.IsNullOrEmpty(token) && _tokens.ContainsKey(token); +} +``` + +`ExternalMcpAuthMiddleware.cs` — full replacement: + +```csharp +using ClaudeDo.Worker.Config; +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.External; + +public sealed class ExternalMcpAuthMiddleware +{ + private const string HeaderName = "X-ClaudeDo-Key"; + + /// Header carrying an SSH session's token on the remote MCP listener. + public const string RemoteTokenHeader = "X-ClaudeDo-Remote-Token"; + + private readonly RequestDelegate _next; + + public ExternalMcpAuthMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext ctx, WorkerConfig cfg, RemoteSessionTokenRegistry remoteTokens) + { + // Tunnelled traffic arrives as loopback too, so the remote listener never falls back to loopback trust. + if (cfg.RemoteMcpPort > 0 && ctx.Connection.LocalPort == cfg.RemoteMcpPort) + { + if (!remoteTokens.IsValid(ctx.Request.Headers[RemoteTokenHeader].ToString())) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync($"Missing or invalid {RemoteTokenHeader} header"); + return; + } + await _next(ctx); + return; + } + + if (string.IsNullOrEmpty(cfg.ExternalMcpApiKey)) + { + await _next(ctx); + return; + } + + var provided = ctx.Request.Headers[HeaderName].ToString(); + if (!string.Equals(provided, cfg.ExternalMcpApiKey, StringComparison.Ordinal)) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync($"Missing or invalid {HeaderName} header"); + return; + } + + await _next(ctx); + } +} +``` + +(Keep whatever `using` lines the existing file has; the snippet above lists the ones it needs.) + +`WorkerConfig.cs` — after `ExternalMcpApiKey`: + +```csharp + /// Loopback port SSH tunnels forward to; requests there need a per-session token. 0 disables SSH sessions. + [JsonPropertyName("remote_mcp_port")] + public int RemoteMcpPort { get; set; } = 47_823; +``` + +`Program.cs`: +1. Next to `builder.Services.AddSingleton();` (line ~133) add `builder.Services.AddSingleton();` (add `using ClaudeDo.Worker.External;` if not present). +2. In the external-MCP block, after `Share();` add `Share();`. +3. Replace `externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");` with: + +```csharp + externalBuilder.WebHost.UseUrls(cfg.RemoteMcpPort > 0 + ? [$"http://127.0.0.1:{cfg.ExternalMcpPort}", $"http://127.0.0.1:{cfg.RemoteMcpPort}"] + : [$"http://127.0.0.1:{cfg.ExternalMcpPort}"]); +``` + +- [ ] **Step 4: Run tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ExternalMcpAuthMiddlewareTests" +``` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(worker): remote MCP listener gated by per-session tokens" -- src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs src/ClaudeDo.Worker/Config/WorkerConfig.cs src/ClaudeDo.Worker/Program.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs +``` +(New files must be `git add`ed by path first.) + +--- + +### Task 3: Worker — SSH launch spec + hub methods + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, `src/ClaudeDo.Worker/Hub/WorkerHub.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append) + +- [ ] **Step 1: Write the failing tests** (append inside `InteractiveLaunchSpecServiceTests`) + +```csharp + // ── 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)); + } +``` + +Check the actual `DbSet` name for mission-control sessions in `ClaudeDoDbContext` (`MissionControlSessions` assumed) and adjust. The first test needs `ssh.exe` on PATH (Windows OpenSSH is standard on this machine); if `ExecutableResolver` cannot find it the test will fail loudly, which is the desired signal. + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release` +Expected: compile errors (`BuildForSshAsync`, `remoteTokens` parameter missing). + +- [ ] **Step 3: Implement** + +`InteractiveLaunchSpecService.cs`: +- Add field `private readonly RemoteSessionTokenRegistry _remoteTokens;` +- Add an optional last constructor parameter `RemoteSessionTokenRegistry? remoteTokens = null` (after `Func? now = null`) and assign `_remoteTokens = remoteTokens ?? new RemoteSessionTokenRegistry();`. Optional keeps every existing `new InteractiveLaunchSpecService(...)` in tests compiling; MS DI still injects the registered singleton. +- Add the method after `BuildForDirectoryAsync`: + +```csharp + /// 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)."); + + 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()); + } +``` + +`WindowsTerminalLauncher.Resolve` is the static used by `BuildForDirectoryAsync` for the claude path — confirm it accepts a bare command name (`"ssh"`) and resolves it via PATH/PATHEXT; if it only handles the configured claude path, use `ExecutableResolver.Resolve("ssh")?.Path` (`ClaudeDo.Data.Environment`) instead. + +`WorkerHub.cs` — next to `GetAdHocLaunchSpec`: + +```csharp + /// 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); + }); +``` + +Next to `GetRepoImportFolders` / `SetRepoImportFolders`: + +```csharp + public async Task> GetSshWorkspaces() + { + using var ctx = _dbFactory.CreateDbContext(); + return await new AppSettingsRepository(ctx).GetSshWorkspacesAsync(Context.ConnectionAborted); + } + + public Task SetSshWorkspaces(List workspaces) => HubGuard(async () => + { + workspaces ??= new List(); + if (workspaces.Select(SshWorkspace.Validate).FirstOrDefault(f => f is not null) is { } field) + throw new ArgumentException($"Invalid SSH workspace {field}."); + using var ctx = _dbFactory.CreateDbContext(); + await new AppSettingsRepository(ctx).SetSshWorkspacesAsync(workspaces, Context.ConnectionAborted); + }); +``` + +Check how `HubGuard` is overloaded (it is used with `Task`-returning lambdas above); if there is no non-generic `HubGuard(Func)` overload, write `SetSshWorkspaces` as a plain `async Task` like `SetRepoImportFolders` and throw `HubException` instead of `ArgumentException`. **No `CancellationToken` parameter on hub methods** — it breaks every call (see memory `signalr_hub_cancellationtoken_trap`). + +- [ ] **Step 4: Run tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests" +``` +Expected: all pass (existing ones included — the ctor change is additive). + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(worker): ssh launch spec with reverse-tunnelled claudedo MCP" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +``` + +--- + +### Task 4: UI — worker client + Mission Control quick-session entries + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, `src/ClaudeDo.Ui/Services/WorkerClient.cs`, `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs`, `src/ClaudeDo.Localization/locales/en.json`, `de.json` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs` + +- [ ] **Step 1: Write the failing test** (next to `RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse`) + +```csharp + [Fact] + public async Task RefreshSessionTargetsAsync_AppendsSshWorkspaces_AndStartsSshPane() + { + await using (var db = NewContext()) + { + db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + await new AppSettingsRepository(db).SetSshWorkspacesAsync([new SshWorkspace("w1", "VPS", "vps", "/srv/app")]); + } + + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + + await vm.RefreshSessionTargetsAsync(); + + Assert.Equal(4, vm.SessionTargets.Count); // home, browse, Alpha, VPS + Assert.Contains("VPS", vm.SessionTargets[3].Label); + + vm.SessionTargets[3].StartCommand.Execute(null); + var pane = Assert.Single(vm.ConPtySessions); + Assert.Equal("VPS", pane.DisplayTitle); + } +``` + +Adjust to the real names used in this test class: the pane collection (`ConPtySessions` is the one `OpenSessionHandoff` uses), `ConPtyPaneViewModel.DisplayTitle`, and whether `FakeWorker` derives from `StubWorkerClient`. If adding a pane needs a dispatcher/UI thread in tests, look at how other tests in this file exercise `OpenAdHocConPtySessionAsync` and follow that. + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release` +Expected: compiles (Data API exists) but the test fails — only 3 targets. + +- [ ] **Step 3: Implement** + +`IWorkerClient.cs` — next to `GetAdHocLaunchSpecAsync`: + +```csharp + Task GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default); +``` +next to `SetRepoImportFoldersAsync`: +```csharp + Task> GetSshWorkspacesAsync(); + Task SetSshWorkspacesAsync(List workspaces); +``` + +`WorkerClient.cs` — mirror the neighbours exactly: + +```csharp + public async Task GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default) + => await InvokeTimedAsync("GetSshLaunchSpec", () => _hub.InvokeAsync("GetSshLaunchSpec", workspaceId, paneId, ct)); + + public async Task> GetSshWorkspacesAsync() + => await InvokeTimedAsync>("GetSshWorkspaces", () => _hub.InvokeAsync>("GetSshWorkspaces")); + + public Task SetSshWorkspacesAsync(List workspaces) + => InvokeTimedAsync("SetSshWorkspaces", () => _hub.InvokeAsync("SetSshWorkspaces", workspaces)); +``` + +`StubWorkerClient.cs`: + +```csharp + public virtual Task GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default) + => Task.FromResult(new LaunchSpec(".", "ssh", Array.Empty(), new Dictionary())); + public virtual Task> GetSshWorkspacesAsync() => Task.FromResult(new List()); + public virtual Task SetSshWorkspacesAsync(List workspaces) => Task.CompletedTask; +``` + +Then grep both test projects for any other `IWorkerClient` implementations (`grep -rn ": IWorkerClient" tests src`) and add the three members there too. + +`MissionControlViewModel.cs` — in `RefreshSessionTargetsAsync`, inside the existing `try`, after the `foreach (var l in lists)` loop: + +```csharp + foreach (var ws in await new AppSettingsRepository(ctx).GetSshWorkspacesAsync()) + { + var w = ws; + SessionTargets.Add(new SessionTargetViewModel + { + Label = Loc.T("missionControl.sshTarget", w.Name), + StartCommand = new RelayCommand(() => _ = OpenSshConPtySessionAsync(w)), + }); + } +``` + +After `OpenAdHocConPtySessionAsync`: + +```csharp + /// Interactive session on an SSH workspace's server. Never deduped, never recorded for resume. + public System.Threading.Tasks.Task OpenSshConPtySessionAsync(SshWorkspace workspace) + { + var paneId = Guid.NewGuid().ToString("n"); + AddConPtyPane(new ConPtyPaneViewModel(null, workspace.Name, + () => DescribeAsync(() => _worker.GetSshLaunchSpecAsync(workspace.Id, paneId))) { IdentityKey = paneId }); + return System.Threading.Tasks.Task.CompletedTask; + } +``` + +Add `using ClaudeDo.Data.Repositories;` if missing. + +Locales — under the `missionControl` object in both files (keep parity): +- en: `"sshTarget": "{0} (SSH)"` +- de: `"sshTarget": "{0} (SSH)"` + +Validate both JSON files parse (memory `locale_parity_test_was_vacuous`): `node -e "JSON.parse(require('fs').readFileSync('src/ClaudeDo.Localization/locales/de.json','utf8'))"` (same for en). + +- [ ] **Step 4: Run tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~MissionControlViewModelTests" +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release --no-build +``` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(ui): ssh workspaces as quick-session targets in mission control" -- src/ClaudeDo.Ui/Services src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs tests/ClaudeDo.Ui.Tests src/ClaudeDo.Localization/locales +``` + +--- + +### Task 5: UI — SSH settings tab + +**Files:** +- Create: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`, `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`, locales +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Modals.Settings; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public sealed class SshSettingsTabViewModelTests +{ + private sealed class Worker : StubWorkerClient + { + public List Stored = new(); + public override Task> GetSshWorkspacesAsync() => Task.FromResult(Stored.ToList()); + public override Task SetSshWorkspacesAsync(List workspaces) { Stored = workspaces; return Task.CompletedTask; } + } + + [Fact] + public void ParseHosts_SkipsWildcardsAndComments_SplitsMultiAlias() + { + var text = """ + # comment + Host * + User root + Host vps vps-alt + HostName 1.2.3.4 + host gitea + Host *.internal + Match host foo + """; + Assert.Equal(["vps", "vps-alt", "gitea"], SshSettingsTabViewModel.ParseHosts(text)); + } + + [Fact] + public async Task LoadAddSave_RoundTripsRows() + { + var worker = new Worker { Stored = [new SshWorkspace("w1", "VPS", "vps", "/srv/app")] }; + var vm = new SshSettingsTabViewModel(worker, () => "Host vps"); + + await vm.LoadAsync(); + Assert.Single(vm.Workspaces); + Assert.Equal(["vps"], vm.Hosts); + + vm.AddCommand.Execute(null); + vm.Workspaces[1].Name = "Box"; + vm.Workspaces[1].Host = "box"; + vm.Workspaces[1].RemotePath = "~/code"; + Assert.Null(vm.Validate()); + + await vm.SaveAsync(); + Assert.Equal(2, worker.Stored.Count); + Assert.Equal("w1", worker.Stored[0].Id); // existing id preserved + Assert.False(string.IsNullOrEmpty(worker.Stored[1].Id)); // new row got an id + } + + [Fact] + public async Task Validate_ReportsInvalidRow_AndRemoveDropsIt() + { + var vm = new SshSettingsTabViewModel(new Worker(), () => ""); + await vm.LoadAsync(); + vm.AddCommand.Execute(null); + vm.Workspaces[0].Name = "Bad"; + vm.Workspaces[0].Host = "-oProxyCommand=calc"; + vm.Workspaces[0].RemotePath = "/x"; + + Assert.NotNull(vm.Validate()); + + vm.Workspaces[0].RemoveCommand.Execute(null); + Assert.Empty(vm.Workspaces); + Assert.Null(vm.Validate()); + } + + [Fact] + public async Task MissingSshConfig_YieldsNoHosts() + { + var vm = new SshSettingsTabViewModel(new Worker(), () => throw new FileNotFoundException()); + await vm.LoadAsync(); + Assert.Empty(vm.Hosts); + } +} +``` + +`Validate()` returns a localized string; in tests the localizer may return the key — only null/non-null is asserted. + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release` +Expected: compile error (`SshSettingsTabViewModel` missing). + +- [ ] **Step 3: Implement** + +`SshSettingsTabViewModel.cs`: + +```csharp +using System.Collections.ObjectModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals.Settings; + +/// Settings tab for SSH workspaces offered as remote quick sessions in Mission Control. +public sealed partial class SshSettingsTabViewModel : ViewModelBase +{ + private readonly IWorkerClient _worker; + private readonly Func _readSshConfig; + + /// Configured workspaces, edited in place. + public ObservableCollection Workspaces { get; } = new(); + + /// Host aliases from the user's ssh config, offered as suggestions. + public ObservableCollection Hosts { get; } = new(); + + public SshSettingsTabViewModel(IWorkerClient worker, Func? readSshConfig = null) + { + _worker = worker; + _readSshConfig = readSshConfig ?? (() => File.ReadAllText(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "config"))); + } + + /// Loads workspaces from the worker and host aliases from the ssh config. + public async Task LoadAsync() + { + Hosts.Clear(); + try { foreach (var h in ParseHosts(_readSshConfig())) Hosts.Add(h); } + catch (Exception) { /* no ssh config = no suggestions, hosts can still be typed */ } + + Workspaces.Clear(); + foreach (var w in await _worker.GetSshWorkspacesAsync()) + Workspaces.Add(new SshWorkspaceRowViewModel(w.Id, Remove) { Name = w.Name, Host = w.Host, RemotePath = w.RemotePath }); + } + + [RelayCommand] + private void Add() => Workspaces.Add(new SshWorkspaceRowViewModel(Guid.NewGuid().ToString("n"), Remove)); + + private void Remove(SshWorkspaceRowViewModel row) => Workspaces.Remove(row); + + /// Localized error for the first invalid row, or null. + public string? Validate() + { + foreach (var row in Workspaces) + if (SshWorkspace.Validate(row.ToModel()) is { } field) + return Loc.T($"settings.ssh.invalid.{field}", row.Name); + return null; + } + + /// Persists all rows via the worker. + public Task SaveAsync() => _worker.SetSshWorkspacesAsync(Workspaces.Select(r => r.ToModel()).ToList()); + + /// Concrete Host aliases from ssh config text; wildcard patterns and Match blocks are skipped. + public static List ParseHosts(string configText) + { + var hosts = new List(); + foreach (var raw in configText.Split('\n')) + { + var parts = raw.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || !parts[0].Equals("Host", StringComparison.OrdinalIgnoreCase)) continue; + hosts.AddRange(parts.Skip(1).Where(p => p.IndexOfAny(['*', '?', '!']) < 0 && !hosts.Contains(p))); + } + return hosts; + } +} + +/// One editable SSH workspace row. +public sealed partial class SshWorkspaceRowViewModel : ObservableObject +{ + private readonly string _id; + private readonly Action _remove; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _host = ""; + [ObservableProperty] private string _remotePath = ""; + + public SshWorkspaceRowViewModel(string id, Action remove) + { + _id = id; + _remove = remove; + } + + [RelayCommand] + private void Remove() => _remove(this); + + /// Snapshot as the persisted model, trimmed. + public SshWorkspace ToModel() => new(_id, Name.Trim(), Host.Trim(), RemotePath.Trim()); +} +``` + +`SettingsModalViewModel.cs`: +- Property: `public SshSettingsTabViewModel Ssh { get; }` +- Constructor, after `Tickets = ...`: `Ssh = new SshSettingsTabViewModel(worker);` +- Categories: append **last** (TabItem order must match 1:1): + `new("Ssh", new LocalizedString(localizer, "settings.sidebar.categorySsh"), "Erweitert", true, false),` +- `LoadAsync`: after `await Tickets.LoadAsync();` add `await Ssh.LoadAsync();` +- `Save`: validation chain becomes `General.Validate() ?? Worktrees.Validate() ?? Prime.Validate() ?? Ssh.Validate()`; after `await Tickets.SaveAsync();` add `await Ssh.SaveAsync();` + +`SettingsModalView.axaml` — new `TabItem` after the Tickets `` (before ``). Use the same outer ScrollViewer/StackPanel structure as the Tickets tab (copy its opening lines verbatim for margins/classes): + +```xml + + + + + + + + + + + + + + + + + + +