1064 lines
48 KiB
Markdown
1064 lines
48 KiB
Markdown
# 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**
|
|
|
|
```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<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`:
|
|
|
|
```csharp
|
|
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`:
|
|
|
|
```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
|
|
/// <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:
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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;
|
|
|
|
/// <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:
|
|
|
|
```csharp
|
|
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`:
|
|
|
|
```csharp
|
|
/// <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:
|
|
|
|
```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<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`:
|
|
|
|
```csharp
|
|
/// <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`:
|
|
|
|
```csharp
|
|
/// <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`:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default);
|
|
```
|
|
next to `SetRepoImportFoldersAsync`:
|
|
```csharp
|
|
Task<List<SshWorkspace>> GetSshWorkspacesAsync();
|
|
Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces);
|
|
```
|
|
|
|
`WorkerClient.cs` — mirror the neighbours exactly:
|
|
|
|
```csharp
|
|
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`:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```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
|
|
/// <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**
|
|
|
|
```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<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`:
|
|
|
|
```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;
|
|
|
|
/// <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):
|
|
|
|
```xml
|
|
<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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`.
|