feat(runner): mint per-run MCP token + emit run-scoped --mcp-config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@ public sealed record ClaudeRunConfig(
|
|||||||
string? AgentPath,
|
string? AgentPath,
|
||||||
string? ResumeSessionId,
|
string? ResumeSessionId,
|
||||||
int? MaxTurns = null,
|
int? MaxTurns = null,
|
||||||
string? PermissionMode = null
|
string? PermissionMode = null,
|
||||||
|
string? McpConfigPath = null,
|
||||||
|
string? AllowedTools = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed class ClaudeArgsBuilder
|
public sealed class ClaudeArgsBuilder
|
||||||
@@ -57,6 +59,12 @@ public sealed class ClaudeArgsBuilder
|
|||||||
|
|
||||||
args.Add($"--json-schema {Escape(ResultSchema)}");
|
args.Add($"--json-schema {Escape(ResultSchema)}");
|
||||||
|
|
||||||
|
if (config.McpConfigPath is not null)
|
||||||
|
args.Add($"--mcp-config {Escape(config.McpConfigPath)}");
|
||||||
|
|
||||||
|
if (config.AllowedTools is not null)
|
||||||
|
args.Add($"--allowedTools {config.AllowedTools}");
|
||||||
|
|
||||||
if (config.ResumeSessionId is not null)
|
if (config.ResumeSessionId is not null)
|
||||||
args.Add($"--resume {config.ResumeSessionId}");
|
args.Add($"--resume {config.ResumeSessionId}");
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -19,6 +20,7 @@ public sealed class TaskRunner
|
|||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly ILogger<TaskRunner> _logger;
|
private readonly ILogger<TaskRunner> _logger;
|
||||||
private readonly ITaskStateService _state;
|
private readonly ITaskStateService _state;
|
||||||
|
private readonly TaskRunTokenRegistry _tokens;
|
||||||
|
|
||||||
public TaskRunner(
|
public TaskRunner(
|
||||||
IClaudeProcess claude,
|
IClaudeProcess claude,
|
||||||
@@ -28,7 +30,8 @@ public sealed class TaskRunner
|
|||||||
ClaudeArgsBuilder argsBuilder,
|
ClaudeArgsBuilder argsBuilder,
|
||||||
WorkerConfig cfg,
|
WorkerConfig cfg,
|
||||||
ILogger<TaskRunner> logger,
|
ILogger<TaskRunner> logger,
|
||||||
ITaskStateService state)
|
ITaskStateService state,
|
||||||
|
TaskRunTokenRegistry tokens)
|
||||||
{
|
{
|
||||||
_claude = claude;
|
_claude = claude;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -38,10 +41,13 @@ public sealed class TaskRunner
|
|||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_state = state;
|
_state = state;
|
||||||
|
_tokens = tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
|
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
string? mcpToken = null;
|
||||||
|
string? mcpConfigPath = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ListEntity? list;
|
ListEntity? list;
|
||||||
@@ -75,6 +81,22 @@ public sealed class TaskRunner
|
|||||||
|
|
||||||
var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct);
|
var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct);
|
||||||
|
|
||||||
|
// Improvement-eligible runs get a per-run MCP identity so the agent can file
|
||||||
|
// out-of-scope follow-ups via SuggestImprovement. Children and planning runs do not.
|
||||||
|
if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)
|
||||||
|
{
|
||||||
|
mcpToken = TaskRunTokenRegistry.GenerateToken();
|
||||||
|
_tokens.Register(mcpToken, task.Id);
|
||||||
|
Directory.CreateDirectory(_cfg.LogRoot);
|
||||||
|
mcpConfigPath = Path.Combine(_cfg.LogRoot, $"{task.Id}_mcp.json");
|
||||||
|
await File.WriteAllTextAsync(mcpConfigPath, BuildRunMcpConfigJson(mcpToken), ct);
|
||||||
|
resolvedConfig = resolvedConfig with
|
||||||
|
{
|
||||||
|
McpConfigPath = mcpConfigPath,
|
||||||
|
AllowedTools = "mcp__claudedo_run__SuggestImprovement",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
await _state.StartRunningAsync(task.Id, now, ct);
|
await _state.StartRunningAsync(task.Id, now, ct);
|
||||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||||
@@ -135,6 +157,15 @@ public sealed class TaskRunner
|
|||||||
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
|
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
|
||||||
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}");
|
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (mcpToken is not null)
|
||||||
|
{
|
||||||
|
_tokens.Unregister(mcpToken);
|
||||||
|
if (mcpConfigPath is not null)
|
||||||
|
try { File.Delete(mcpConfigPath); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
|
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
|
||||||
@@ -396,6 +427,26 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string BuildRunMcpConfigJson(string token)
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
mcpServers = new
|
||||||
|
{
|
||||||
|
claudedo_run = new
|
||||||
|
{
|
||||||
|
type = "http",
|
||||||
|
url = $"http://127.0.0.1:{_cfg.SignalRPort}/mcp",
|
||||||
|
headers = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Authorization"] = $"Bearer {token}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ClaudeRunConfig> ResolveConfigAsync(
|
private async Task<ClaudeRunConfig> ResolveConfigAsync(
|
||||||
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
|
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var argsBuilder = new ClaudeArgsBuilder();
|
var argsBuilder = new ClaudeArgsBuilder();
|
||||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
||||||
NullLogger<TaskRunner>.Instance, state);
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
|
||||||
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
||||||
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
||||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
Assert.Contains("--permission-mode auto", args);
|
Assert.Contains("--permission-mode auto", args);
|
||||||
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_emits_mcpConfig_and_allowedTools_when_set()
|
||||||
|
{
|
||||||
|
var args = new ClaudeArgsBuilder().Build(new ClaudeRunConfig(
|
||||||
|
Model: null, SystemPrompt: null, AgentPath: null, ResumeSessionId: null,
|
||||||
|
McpConfigPath: "C:\\tmp\\t_mcp.json",
|
||||||
|
AllowedTools: "mcp__claudedo_run__SuggestImprovement"));
|
||||||
|
Assert.Contains("--mcp-config", args);
|
||||||
|
Assert.Contains("t_mcp.json", args);
|
||||||
|
Assert.Contains("--allowedTools mcp__claudedo_run__SuggestImprovement", args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class MergeInstructionsTests
|
public sealed class MergeInstructionsTests
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable
|
|||||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg,
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg,
|
||||||
NullLogger<TaskRunner>.Instance, state);
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
|
||||||
|
|
||||||
using (var ctx = _db.CreateContext())
|
using (var ctx = _db.CreateContext())
|
||||||
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("p1"))!, "slot-1", default);
|
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("p1"))!, "slot-1", default);
|
||||||
@@ -72,7 +72,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable
|
|||||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt,
|
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt,
|
||||||
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state);
|
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
|
||||||
|
|
||||||
using (var ctx = _db.CreateContext())
|
using (var ctx = _db.CreateContext())
|
||||||
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default);
|
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
|||||||
var argsBuilder = new ClaudeArgsBuilder();
|
var argsBuilder = new ClaudeArgsBuilder();
|
||||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||||
NullLogger<TaskRunner>.Instance, state);
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
|
||||||
_waker = new QueueWaker();
|
_waker = new QueueWaker();
|
||||||
var picker = new QueuePicker(dbFactory);
|
var picker = new QueuePicker(dbFactory);
|
||||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
var argsBuilder = new ClaudeArgsBuilder();
|
var argsBuilder = new ClaudeArgsBuilder();
|
||||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||||
NullLogger<TaskRunner>.Instance, state);
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
|
||||||
_waker = new QueueWaker();
|
_waker = new QueueWaker();
|
||||||
var picker = new QueuePicker(dbFactory);
|
var picker = new QueuePicker(dbFactory);
|
||||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||||
|
|||||||
Reference in New Issue
Block a user