Files
ClaudeDo/docs/superpowers/plans/2026-10-02-ssh-workspaces.md
T

48 KiB

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 <rport>:127.0.0.1:<RemoteMcpPort> <host> "<remote cmd>"; 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 -- <paths> (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

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<ClaudeDoDbContext> _options;

    public SshWorkspaceTests()
    {
        _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ssh_{Guid.NewGuid():N}.db");
        _options = new DbContextOptionsBuilder<ClaudeDoDbContext>().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:

using System.Text.RegularExpressions;

namespace ClaudeDo.Data.Models;

/// <summary>A server an interactive claude session can be started on via ssh; connection details live in the user's ssh config.</summary>
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);

    /// <summary>Returns the first invalid field ("name", "host", "path") or null when valid.</summary>
    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;
    }

    /// <summary>Single-quotes a validated remote path for a POSIX shell, leaving a leading <c>~</c> unquoted so it still expands.</summary>
    public static string QuoteRemotePath(string path)
    {
        if (path == "~") return "~";
        if (path.StartsWith("~/")) return "~/'" + path[2..] + "'";
        return "'" + path + "'";
    }
}

AppSettingsEntity.cs — add after RepoImportFolders:

    // JSON array of SshWorkspace rows offered as remote quick sessions in Mission Control.
    public string? SshWorkspaces { get; set; }

AppSettingsEntityConfiguration.cs — after the RepoImportFolders mapping:

        builder.Property(s => s.SshWorkspaces)
            .HasColumnName("ssh_workspaces");

AppSettingsRepository.cs — after SetRepoImportFoldersAsync:

    /// <summary>Reads the configured SSH workspaces; missing or corrupt JSON yields an empty list.</summary>
    public async Task<List<SshWorkspace>> 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<SshWorkspace>();
        try { return JsonSerializer.Deserialize<List<SshWorkspace>>(json) ?? new List<SshWorkspace>(); }
        catch (JsonException) { return new List<SshWorkspace>(); }
    }

    /// <summary>Writes only the SSH workspaces column; an empty list clears it.</summary>
    public async Task SetSshWorkspacesAsync(IEnumerable<SshWorkspace> 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:

dotnet ef migrations add AddSshWorkspaces --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release

Expected: new *_AddSshWorkspaces.cs with a single AddColumn<string>(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
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
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

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:

using System.Collections.Concurrent;
using System.Security.Cryptography;

namespace ClaudeDo.Worker.External;

/// <summary>Tokens issued to SSH sessions; the only credential accepted on the remote MCP listener.</summary>
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<string, byte> _tokens = new();

    /// <summary>Mints and registers a fresh 256-bit hex token.</summary>
    public string Issue()
    {
        var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
        _tokens[token] = 0;
        return token;
    }

    /// <summary>True when the token was issued by this worker instance.</summary>
    public bool IsValid(string? token) => !string.IsNullOrEmpty(token) && _tokens.ContainsKey(token);
}

ExternalMcpAuthMiddleware.cs — full replacement:

using ClaudeDo.Worker.Config;
using Microsoft.AspNetCore.Http;

namespace ClaudeDo.Worker.External;

public sealed class ExternalMcpAuthMiddleware
{
    private const string HeaderName = "X-ClaudeDo-Key";

    /// <summary>Header carrying an SSH session's token on the remote MCP listener.</summary>
    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:

    /// <summary>Loopback port SSH tunnels forward to; requests there need a per-session token. 0 disables SSH sessions.</summary>
    [JsonPropertyName("remote_mcp_port")]
    public int RemoteMcpPort { get; set; } = 47_823;

Program.cs:

  1. Next to builder.Services.AddSingleton<InteractiveLaunchSpecService>(); (line ~133) add builder.Services.AddSingleton<RemoteSessionTokenRegistry>(); (add using ClaudeDo.Worker.External; if not present).
  2. In the external-MCP block, after Share<ConnectedClientTracker>(); add Share<RemoteSessionTokenRegistry>();.
  3. Replace externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); with:
    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
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
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 added 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)

    // ── 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));
    }

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<DateTime>? 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:
    /// <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).");

        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>());
    }

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:

    /// <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);
    });

Next to GetRepoImportFolders / SetRepoImportFolders:

    public async Task<List<SshWorkspace>> GetSshWorkspaces()
    {
        using var ctx = _dbFactory.CreateDbContext();
        return await new AppSettingsRepository(ctx).GetSshWorkspacesAsync(Context.ConnectionAborted);
    }

    public Task SetSshWorkspaces(List<SshWorkspace> workspaces) => HubGuard(async () =>
    {
        workspaces ??= new List<SshWorkspace>();
        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<T>-returning lambdas above); if there is no non-generic HubGuard(Func<Task>) 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
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
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)

    [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:

    Task<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default);

next to SetRepoImportFoldersAsync:

    Task<List<SshWorkspace>> GetSshWorkspacesAsync();
    Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces);

WorkerClient.cs — mirror the neighbours exactly:

    public async Task<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default)
        => await InvokeTimedAsync<LaunchSpec>("GetSshLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSshLaunchSpec", workspaceId, paneId, ct));

    public async Task<List<SshWorkspace>> GetSshWorkspacesAsync()
        => await InvokeTimedAsync<List<SshWorkspace>>("GetSshWorkspaces", () => _hub.InvokeAsync<List<SshWorkspace>>("GetSshWorkspaces"));

    public Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces)
        => InvokeTimedAsync("SetSshWorkspaces", () => _hub.InvokeAsync("SetSshWorkspaces", workspaces));

StubWorkerClient.cs:

    public virtual Task<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default)
        => Task.FromResult(new LaunchSpec(".", "ssh", Array.Empty<string>(), new Dictionary<string, string>()));
    public virtual Task<List<SshWorkspace>> GetSshWorkspacesAsync() => Task.FromResult(new List<SshWorkspace>());
    public virtual Task SetSshWorkspacesAsync(List<SshWorkspace> 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:

            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:

    /// <summary>Interactive session on an SSH workspace's server. Never deduped, never recorded for resume.</summary>
    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
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
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

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<SshWorkspace> Stored = new();
        public override Task<List<SshWorkspace>> GetSshWorkspacesAsync() => Task.FromResult(Stored.ToList());
        public override Task SetSshWorkspacesAsync(List<SshWorkspace> 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:

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;

/// <summary>Settings tab for SSH workspaces offered as remote quick sessions in Mission Control.</summary>
public sealed partial class SshSettingsTabViewModel : ViewModelBase
{
    private readonly IWorkerClient _worker;
    private readonly Func<string> _readSshConfig;

    /// <summary>Configured workspaces, edited in place.</summary>
    public ObservableCollection<SshWorkspaceRowViewModel> Workspaces { get; } = new();

    /// <summary>Host aliases from the user's ssh config, offered as suggestions.</summary>
    public ObservableCollection<string> Hosts { get; } = new();

    public SshSettingsTabViewModel(IWorkerClient worker, Func<string>? readSshConfig = null)
    {
        _worker = worker;
        _readSshConfig = readSshConfig ?? (() => File.ReadAllText(Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "config")));
    }

    /// <summary>Loads workspaces from the worker and host aliases from the ssh config.</summary>
    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);

    /// <summary>Localized error for the first invalid row, or null.</summary>
    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;
    }

    /// <summary>Persists all rows via the worker.</summary>
    public Task SaveAsync() => _worker.SetSshWorkspacesAsync(Workspaces.Select(r => r.ToModel()).ToList());

    /// <summary>Concrete <c>Host</c> aliases from ssh config text; wildcard patterns and <c>Match</c> blocks are skipped.</summary>
    public static List<string> ParseHosts(string configText)
    {
        var hosts = new List<string>();
        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;
    }
}

/// <summary>One editable SSH workspace row.</summary>
public sealed partial class SshWorkspaceRowViewModel : ObservableObject
{
    private readonly string _id;
    private readonly Action<SshWorkspaceRowViewModel> _remove;

    [ObservableProperty] private string _name = "";
    [ObservableProperty] private string _host = "";
    [ObservableProperty] private string _remotePath = "";

    public SshWorkspaceRowViewModel(string id, Action<SshWorkspaceRowViewModel> remove)
    {
        _id = id;
        _remove = remove;
    }

    [RelayCommand]
    private void Remove() => _remove(this);

    /// <summary>Snapshot as the persisted model, trimmed.</summary>
    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 </TabItem> (before </TabControl>). Use the same outer ScrollViewer/StackPanel structure as the Tickets tab (copy its opening lines verbatim for margins/classes):

        <TabItem Header="{loc:Tr settings.sidebar.categorySsh}">
          <ScrollViewer>
            <StackPanel Spacing="12">
              <TextBlock Classes="meta" Text="{loc:Tr settings.ssh.hint}" TextWrapping="Wrap" Opacity="0.6"/>

              <ItemsControl ItemsSource="{Binding Ssh.Workspaces}">
                <ItemsControl.ItemTemplate>
                  <DataTemplate x:DataType="settings:SshWorkspaceRowViewModel">
                    <Grid ColumnDefinitions="160,180,*,Auto" ColumnSpacing="8" Margin="0,0,0,6">
                      <TextBox Grid.Column="0" Text="{Binding Name, Mode=TwoWay}"
                               PlaceholderText="{loc:Tr settings.ssh.name}"/>
                      <AutoCompleteBox Grid.Column="1" Text="{Binding Host, Mode=TwoWay}"
                                       ItemsSource="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Ssh.Hosts}"
                                       MinimumPrefixLength="0" FilterMode="StartsWith"
                                       Watermark="{loc:Tr settings.ssh.host}"/>
                      <TextBox Grid.Column="2" Text="{Binding RemotePath, Mode=TwoWay}"
                               PlaceholderText="{loc:Tr settings.ssh.remotePath}"/>
                      <Button Grid.Column="3" Classes="icon-btn" Command="{Binding RemoveCommand}"
                              ToolTip.Tip="{loc:Tr settings.ssh.remove}">
                        <PathIcon Width="14" Height="14" Data="{Binding Source='mdi:TrashCanOutline', Converter={StaticResource IconKey}}"/>
                      </Button>
                    </Grid>
                  </DataTemplate>
                </ItemsControl.ItemTemplate>
              </ItemsControl>

              <Button Classes="btn" HorizontalAlignment="Left" Command="{Binding Ssh.AddCommand}"
                      Content="{loc:Tr settings.ssh.add}"/>
            </StackPanel>
          </ScrollViewer>
        </TabItem>

Check the xmlns prefixes the view already declares (vm: for ClaudeDo.Ui.ViewModels.Modals? settings: for the Settings sub-namespace?) and add xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings" if absent; the $parent[...] cast must use the prefix mapped to SettingsModalViewModel's namespace. If IconKey is not reachable as StaticResource in this view, copy how other icons in SettingsModalView.axaml are rendered. If AutoCompleteBox does not resolve in Avalonia 12, use ComboBox IsEditable="True" with Text/ItemsSource bindings instead.

Locales (both files, parity):

key en de
settings.sidebar.categorySsh SSH workspaces SSH-Workspaces
settings.ssh.hint Servers you can start an interactive Claude session on from Mission Control. Host is an alias from ~/.ssh/config (user, key and port come from there); Claude must be installed and logged in on the server. Server, auf denen Mission Control eine interaktive Claude-Session starten kann. Host ist ein Alias aus ~/.ssh/config (User, Key und Port kommen von dort); Claude muss auf dem Server installiert und eingeloggt sein.
settings.ssh.name Name Name
settings.ssh.host Host alias Host-Alias
settings.ssh.remotePath Remote path (/… or ~/…) Pfad auf dem Server (/… oder ~/…)
settings.ssh.add Add workspace Workspace hinzufügen
settings.ssh.remove Remove Entfernen
settings.ssh.invalid.name SSH workspace: name is required. SSH-Workspace: Name fehlt.
settings.ssh.invalid.host SSH workspace "{0}": host must be an ssh alias (letters, digits, . _ @ -, not starting with -). SSH-Workspace „{0}“: Host muss ein ssh-Alias sein (Buchstaben, Ziffern, . _ @ -, nicht mit - beginnend).
settings.ssh.invalid.path SSH workspace "{0}": remote path must start with / or ~ and must not contain '. SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten.

The settings keys are nested JSON objects (settings → sidebar → categorySsh, settings → ssh → invalid → name); place them in the existing objects. Validate both files parse with node as in Task 4.

  • Step 4: Run tests
dotnet build ClaudeDo.slnx -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~SshSettingsTabViewModelTests|FullyQualifiedName~SettingsModal"
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release --no-build

Expected: all pass.

  • Step 5: Commit
git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs
git commit -m "feat(ui): ssh workspaces settings tab" -- src/ClaudeDo.Ui/ViewModels/Modals src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml src/ClaudeDo.Localization/locales tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs

Task 6: Docs + manual verification

  • Add a short "SSH sessions" paragraph to docs/explore-notes/conpty-sessions.md (fifth session kind: GetSshLaunchSpec, remote listener + token, not recorded, no handoff) and the new remote_mcp_port key to src/ClaudeDo.Worker/CLAUDE.md where external_mcp_port is documented.
  • Add to docs/open.md under verification items: real-VPS smoke test (tile opens, /mcp shows claudedo connected, list_task_lists works, closing tile tears down tunnel, two parallel tiles to one host) and the visual check of the SSH settings tab.
  • Commit: docs(ssh): document ssh workspace sessions.