# 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