feat(worker): ssh launch spec with reverse-tunnelled claudedo MCP
This commit is contained in:
@@ -427,6 +427,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(folders ?? new List<string>(), Context.ConnectionAborted);
|
await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(folders ?? new List<string>(), Context.ConnectionAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The configured SSH workspaces.</summary>
|
||||||
|
public async Task<List<SshWorkspace>> GetSshWorkspaces()
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
return await new AppSettingsRepository(ctx).GetSshWorkspacesAsync(Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces the SSH workspaces; rejects any entry that fails <see cref="SshWorkspace.Validate"/>.</summary>
|
||||||
|
public async Task SetSshWorkspaces(List<SshWorkspace> workspaces)
|
||||||
|
{
|
||||||
|
workspaces ??= new List<SshWorkspace>();
|
||||||
|
if (workspaces.Select(SshWorkspace.Validate).FirstOrDefault(f => f is not null) is { } field)
|
||||||
|
throw new HubException($"Invalid SSH workspace {field}.");
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
await new AppSettingsRepository(ctx).SetSshWorkspacesAsync(workspaces, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<SessionSkillDto>> GetSessionSkills()
|
public async Task<List<SessionSkillDto>> GetSessionSkills()
|
||||||
{
|
{
|
||||||
var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted);
|
var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted);
|
||||||
@@ -818,6 +835,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
|
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}, "ssh workspace not found");
|
||||||
|
|
||||||
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||||
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
|
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
// Local wall clock, injectable so the PowerUse time window is testable without sleeping.
|
// Local wall clock, injectable so the PowerUse time window is testable without sleeping.
|
||||||
private readonly Func<DateTime> _now;
|
private readonly Func<DateTime> _now;
|
||||||
|
|
||||||
|
/// <summary>Issues the per-session tokens that gate the reverse-tunnelled remote MCP listener.</summary>
|
||||||
|
private readonly RemoteSessionTokenRegistry _remoteTokens;
|
||||||
|
|
||||||
public InteractiveLaunchSpecService(
|
public InteractiveLaunchSpecService(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
ISessionSkillSeeder skillSeeder,
|
ISessionSkillSeeder skillSeeder,
|
||||||
@@ -60,8 +63,10 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
WorkerConfig cfg,
|
WorkerConfig cfg,
|
||||||
HandoffRoundTracker handoffRounds,
|
HandoffRoundTracker handoffRounds,
|
||||||
HandlerRoundState roundState,
|
HandlerRoundState roundState,
|
||||||
Func<DateTime>? now = null)
|
Func<DateTime>? now = null,
|
||||||
|
RemoteSessionTokenRegistry? remoteTokens = null)
|
||||||
{
|
{
|
||||||
|
_remoteTokens = remoteTokens ?? new RemoteSessionTokenRegistry();
|
||||||
_now = now ?? (() => DateTime.Now);
|
_now = now ?? (() => DateTime.Now);
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_skillSeeder = skillSeeder;
|
_skillSeeder = skillSeeder;
|
||||||
@@ -293,6 +298,54 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
return new LaunchSpec(directory, resolvedClaude, args, env);
|
return new LaunchSpec(directory, resolvedClaude, args, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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).");
|
||||||
|
// paneId lands in the remote shell command; only a bare GUID is safe there.
|
||||||
|
if (!Guid.TryParse(paneId, out _) || !paneId.All(c => char.IsAsciiHexDigit(c) || c == '-'))
|
||||||
|
throw new InvalidOperationException($"Invalid paneId: {paneId}");
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var ws = (await new AppSettingsRepository(ctx).GetSshWorkspacesAsync(ct)).FirstOrDefault(w => w.Id == workspaceId)
|
||||||
|
?? throw new KeyNotFoundException($"SSH workspace not found: {workspaceId}");
|
||||||
|
if (SshWorkspace.Validate(ws) is { } field)
|
||||||
|
throw new InvalidOperationException($"SSH workspace '{ws.Name}' has an invalid {field}.");
|
||||||
|
|
||||||
|
var ssh = WindowsTerminalLauncher.Resolve("ssh")
|
||||||
|
?? throw new InvalidOperationException("ssh executable not found on PATH (install Windows OpenSSH client).");
|
||||||
|
|
||||||
|
var remotePort = Random.Shared.Next(20_000, 61_000);
|
||||||
|
var mcpJson = JsonSerializer.Serialize(new Dictionary<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>());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via
|
/// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via
|
||||||
/// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool
|
/// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool
|
||||||
/// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so
|
/// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so
|
||||||
|
|||||||
@@ -1653,4 +1653,91 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.All(specs, spec =>
|
Assert.All(specs, spec =>
|
||||||
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]));
|
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SSH workspaces ──
|
||||||
|
|
||||||
|
private async Task SeedSshAsync(params SshWorkspace[] ws)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.CreateFactory().CreateDbContext();
|
||||||
|
await new AppSettingsRepository(ctx).SetSshWorkspacesAsync(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InteractiveLaunchSpecService BuildSshService(RemoteSessionTokenRegistry tokens, int remoteMcpPort = 47_823) =>
|
||||||
|
new(_db.CreateFactory(), _seeder, _registry,
|
||||||
|
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildForSshAsync_NonGuidPaneId_Throws()
|
||||||
|
{
|
||||||
|
await SeedSshAsync(new SshWorkspace("w1", "VPS", "vps", "/srv/app"));
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => BuildSshService(new RemoteSessionTokenRegistry()).BuildForSshAsync("w1", "{" + Guid.NewGuid() + "}", CancellationToken.None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user