diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
index 3eee5907..c0188858 100644
--- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs
+++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
@@ -41,6 +41,10 @@ public sealed class WorkerConfig
[JsonPropertyName("external_mcp_api_key")]
public string? ExternalMcpApiKey { get; set; }
+ /// Loopback port SSH tunnels forward to; requests there need a per-session token. 0 disables SSH sessions.
+ [JsonPropertyName("remote_mcp_port")]
+ public int RemoteMcpPort { get; set; } = 47_823;
+
[JsonPropertyName("online_inbox")]
public OnlineInboxConfig OnlineInbox { get; set; } = new();
diff --git a/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
index 38b90ff9..49370676 100644
--- a/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
+++ b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
@@ -7,12 +7,28 @@ public sealed class ExternalMcpAuthMiddleware
{
private const string HeaderName = "X-ClaudeDo-Key";
+ /// Header carrying an SSH session's token on the remote MCP listener.
+ public const string RemoteTokenHeader = "X-ClaudeDo-Remote-Token";
+
private readonly RequestDelegate _next;
public ExternalMcpAuthMiddleware(RequestDelegate next) => _next = next;
- public async Task InvokeAsync(HttpContext ctx, WorkerConfig cfg)
+ 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);
diff --git a/src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs b/src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs
new file mode 100644
index 00000000..a07c0b75
--- /dev/null
+++ b/src/ClaudeDo.Worker/External/RemoteSessionTokenRegistry.cs
@@ -0,0 +1,22 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+
+namespace ClaudeDo.Worker.External;
+
+/// Tokens issued to SSH sessions; the only credential accepted on the remote MCP listener.
+public sealed class RemoteSessionTokenRegistry
+{
+ // ponytail: tokens live until worker restart (no revoke on tile close); a leaked token only works while some tunnel to RemoteMcpPort is open.
+ private readonly ConcurrentDictionary _tokens = new();
+
+ /// Mints and registers a fresh 256-bit hex token.
+ public string Issue()
+ {
+ var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
+ _tokens[token] = 0;
+ return token;
+ }
+
+ /// True when the token was issued by this worker instance.
+ public bool IsValid(string? token) => !string.IsNullOrEmpty(token) && _tokens.ContainsKey(token);
+}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 48acc238..b21e5def 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -131,6 +131,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -360,6 +361,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(cfg);
Share();
Share();
+ Share();
Share();
Share();
Share>();
@@ -432,7 +434,9 @@ if (cfg.ExternalMcpPort > 0)
.WithTools()
.WithTools()
.WithTools();
- externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
+ 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}"]);
externalApp = externalBuilder.Build();
externalApp.UseMiddleware();
diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs
new file mode 100644
index 00000000..6878b3d7
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpAuthMiddlewareTests.cs
@@ -0,0 +1,79 @@
+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;
+ }
+}