From b8248c7824e1602d08a35f54be05fedaf4d63ea0 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 16:57:20 +0200 Subject: [PATCH 01/36] [AI-68] docs: design for hosted Codex agents on macOS and Linux Captures the strategy-pattern split of AgentOrchestrator into per-vendor IHostedAgentLauncher impls, the Tomlyn-based ~/.codex/config.toml pre-trust writer, LocalPermissionBridge per-vendor URL routing, and the CLI codex-hook PermissionRequest bridge bounce. Includes the confirmed Codex pre-trust spike findings. Out-of-scope follow-ups tracked in AI-632 (PR review) and AI-633 (sandbox/approval selection). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-14-ai-68-codex-hosted-agents-design.md | 587 ++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md diff --git a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md new file mode 100644 index 0000000000..a78ad3cc93 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md @@ -0,0 +1,587 @@ +# AI-68 — Hosted Codex agents on macOS and Linux + +**Status:** design approved, pending implementation plan +**Linear:** [AI-68](https://linear.app/kurrent/issue/AI-68) +**Follow-ups:** [AI-632](https://linear.app/kurrent/issue/AI-632) (PR review), [AI-633](https://linear.app/kurrent/issue/AI-633) (sandbox/approval selection) +**Out of scope (separate tracking):** [AI-72](https://linear.app/kurrent/issue/AI-72) (Windows) + +## 1. Architecture overview + +The CLI binary already carries the Codex-side pieces — `CodexCliRunner` (headless title / what's-done), `CodexPaths` (session discovery), `CodexHookCommand` (hook dispatch), and `--codex` plugin install. What's missing is **the daemon-side path**: hosted-agent launch is still Claude-only and the local permission bridge speaks only Claude's wire shape. + +Vendor-aware structure inside `kapacitor.Daemon`: + +``` +ServerConnection.OnLaunchAgent(LaunchAgentCommand) + │ cmd.Vendor ∈ {"claude", "codex"} + ▼ +AgentOrchestrator.HandleLaunchAgent + │ (cap check, repo-allow check, worktree create, attachment download, + │ common env, AgentInstance bookkeeping, PTY spawn, status/heartbeat, + │ cleanup — all VENDOR-NEUTRAL) + │ + │ IHostedAgentLauncher ← selected by vendor + ├──▶ ClaudeLauncher (claude CLI, ~/.claude.json trust, .claude/ overlay, + │ project-dir symlink, .mcp.json writer) + └──▶ CodexLauncher (codex CLI, ~/.codex/config.toml trust via Tomlyn, + no MCP file overlay needed) + +LocalPermissionBridge + POST /{token}/claude/permission-request → Claude wire shape + POST /{token}/codex/permission-request → Codex wire shape +``` + +Three knock-on changes outside the daemon: + +1. `LaunchAgentCommand` + `AgentRunStarted` get a required `Vendor` field. +2. `DaemonConfig.ClaudePath` is joined by `CodexPath`. Defaults `"claude"` / `"codex"` on `PATH`. +3. `kapacitor codex-hook` `PermissionRequest` stops echoing the stub; when `KAPACITOR_DAEMON_URL` is set it bounces through `{KAPACITOR_DAEMON_URL}/codex/permission-request` and returns the bridge's decision. + +## 2. Spike — Codex pre-trust mechanism (confirmed) + +Verified against codex-cli 0.130.0 on macOS: + +`~/.codex/config.toml` uses TOML with a per-directory table: + +```toml +[projects."/absolute/path/to/dir"] +trust_level = "trusted" +``` + +MCP servers also live in the same file (`[mcp_servers.]`). There is **no** separate `~/.codex.json`. Consequence: Codex needs **no `.mcp.json` overlay** like Claude does — the spawned process inherits the user's MCP servers directly from `~/.codex/config.toml`. + +Codex's interactive launch surface (the daemon needs these): + +- `codex` (no subcommand, interactive TUI) with optional `[PROMPT]` positional +- `-m, --model ` +- `-s, --sandbox ` +- `-a, --ask-for-approval ` — `on-request` is what triggers `PermissionRequest` hooks +- `-C, --cd ` +- `-c model_reasoning_effort=""` — Codex's "effort" knob +- `--no-alt-screen` — disables alternate-screen terminal mode, safer for PTY relay through multiplexers + +## 3. Wire format changes + +### 3.1 `LaunchAgentCommand` (Models.cs) + +Add a required `Vendor`: + +```csharp +public readonly record struct LaunchAgentCommand( + string AgentId, + string? Prompt, + string Model, + string? Effort, + string RepoPath, + string[]? Tools, + string[]? AttachmentIds, + LaunchKind Kind = LaunchKind.Default, + ReviewLaunchInfo? Review = null, + string? BaseRef = null, + string Vendor = "claude" // NEW — required on wire; the default exists for + // C# call-site ergonomics. Server always sends + // explicit vendor. + ); +``` + +Daemon validates at the top of `HandleLaunchAgent`: + +```csharp +if (cmd.Vendor is not ("claude" or "codex")) { + await _server.LaunchFailedAsync(cmd.AgentId, $"Unknown vendor: {cmd.Vendor}"); + return; +} +``` + +### 3.2 `AgentRunStarted` (Models.cs) + +```csharp +record AgentRunStarted( + string? Prompt, + string? Model, + string? Effort, + string? RepoPath, + string? WorktreePath, + string Vendor // NEW — always written by daemon + ); +``` + +Server-side persistence stores it on the run record so the dashboard can render a vendor badge without joining back to the launch command. + +### 3.3 `LocalPermissionBridge` URLs + +Old: `POST /{token}/permission-request`. +New: `POST /{token}/{vendor}/permission-request`. + +`BaseUrl` exposed to spawned agents (`http://127.0.0.1:{port}/{token}`) is unchanged — the CLI hook command appends `/{vendor}/permission-request` itself. + +### 3.4 `DaemonConfig` (DaemonConfig.cs) + +```csharp +public string ClaudePath { get; set; } = "claude"; +public string CodexPath { get; set; } = "codex"; // NEW +``` + +`KapacitorPath` is unchanged — what's-done generation is vendor-neutral. + +## 4. `IHostedAgentLauncher` interface and impls + +A single internal interface in `kapacitor.Daemon.Services`. The surface is the smallest possible — only the vendor-specific bits. + +### 4.1 Interface + +```csharp +internal interface IHostedAgentLauncher { + /// Vendor token this launcher handles ("claude" or "codex"). + string Vendor { get; } + + /// + /// Absolute path or bare command for the CLI. Pulled from DaemonConfig at construction. + /// + string CliPath { get; } + + /// + /// Per-vendor preparation BEFORE the PTY is spawned. Implementations: + /// • Overlay vendor-specific settings dir from source repo into worktree + /// • Pre-trust the worktree path in the vendor's config file + /// • Write any vendor-specific config (MCP, etc.) + /// • Merge dialog-selected tools into vendor-specific permission shape + /// Best-effort: implementations swallow filesystem errors and log; they do + /// NOT throw, so launch never blocks on a settings-overlay glitch. + /// + void Prepare(LauncherContext ctx); + + /// Build the argv array passed to the CLI. + LaunchArgs BuildArgs(LauncherContext ctx); + + /// + /// Per-vendor cleanup AFTER the agent exits / is stopped. Claude removes the + /// ~/.claude/projects symlink; Codex has nothing to undo because the trust + /// entry is intentionally persistent across worktree re-use. + /// + void Cleanup(AgentInstance agent); +} + +internal sealed record LauncherContext( + string AgentId, + string SourceRepoPath, + WorktreeInfo Worktree, + string? Prompt, + string Model, + string? Effort, + string[]? Tools, + bool IsReview, + ReviewLaunchInfo? Review, + ReviewLaunchBuilder.Result? ReviewLaunch + ); + +/// argv + optional cleanup-time temp file path (e.g. ReviewLaunch.McpConfigPath). +internal readonly record struct LaunchArgs(string[] Args, string? McpConfigPath); +``` + +### 4.2 `ClaudeLauncher` — extracted from current orchestrator code + +Pulls in (verbatim, no behavior change) from `AgentOrchestrator.cs`: + +- `OverlayDirectory`, `SymlinkClaudeProjectDir`, `RemoveClaudeProjectSymlink` +- `WriteMcpConfig`, `ReadMcpJsonServerNames` +- `TrustWorktreeInClaudeConfig` (with `TrustWriteLock`, `LoadJsonObject`, `WriteJsonAtomic`) +- `MergeToolPermissions` +- The Claude-args-building section of `HandleLaunchAgent` (lines 273–304 today) + +`Vendor => "claude"`, `CliPath => _config.ClaudePath`, `Cleanup` removes the `~/.claude/projects/` symlink. + +### 4.3 `CodexLauncher` — new + +`Prepare(ctx)`: + +- `OverlayDirectory(source/.codex, worktree/.codex)` — lifted to a shared static helper. +- `CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, logger)` — adds `[projects.""] trust_level = "trusted"` to `~/.codex/config.toml`. Details in section 5. +- No MCP file write (Codex reads MCP from `~/.codex/config.toml`). +- No tool-permission merge in v1. If `Tools` is non-empty, log at Debug so we notice if the UI starts passing them for Codex. + +`BuildArgs(ctx)`: + +For default launches: + +- `--cd ` — keeps Codex anchored even if env CWD drifts +- `--sandbox workspace-write` +- `--ask-for-approval on-request` +- `-m ` if `Model` is set +- `-c model_reasoning_effort=""` with the mapping: Claude's `max` → Codex's `xhigh`, `low|medium|high` pass through. Skipped when `Effort` is null or `"auto"`. +- `--no-alt-screen` +- `--` then `Prompt` positional, if non-empty. + +Review launches: out of scope for v1 — orchestrator fails the launch with `"PR review for Codex is not yet supported"` before calling into the launcher. Tracked in [AI-632](https://linear.app/kurrent/issue/AI-632). + +`Cleanup`: no-op. `[projects.""]` trust entries stay in `~/.codex/config.toml` because worktree paths are unique per run, cumulative entries are harmless, and leaving them simplifies re-launch debugging. Periodic GC of orphan entries is out of scope. + +### 4.4 Orchestrator changes + +`AgentOrchestrator` becomes vendor-neutral: + +- Constructor takes `IReadOnlyDictionary` injected by the composition root. +- `HandleLaunchAgent`: + - Validate `cmd.Vendor`. + - Look up `_launchers[cmd.Vendor]`. + - Do the common prep (worktree create, attachment download, vendor-agnostic env vars). + - Call `launcher.Prepare(ctx)` (best-effort try/catch with vendor-aware log). + - Call `launcher.BuildArgs(ctx)`. + - Spawn PTY with `launcher.CliPath` + args + env. +- `CleanupAgentAsync` calls `launcher.Cleanup(agent)` instead of the inlined Claude-symlink call. +- Env vars (`KAPACITOR_RENDERED_AGENT`, `KAPACITOR_AGENT_ID`, `KAPACITOR_URL`, `KAPACITOR_DAEMON_URL`) stay in the orchestrator. + +### 4.5 Composition root + +In `Program.cs` (daemon entry point), the DI container registers: + +```csharp +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton>(sp => + sp.GetServices().ToDictionary(l => l.Vendor)); +``` + +## 5. `CodexConfigWriter` — Tomlyn-based pre-trust + +Single new file `src/Kapacitor.Daemon/Services/CodexConfigWriter.cs`. + +### 5.1 Tomlyn dependency + +Add to `Directory.Packages.props`: + +```xml + +``` + +And to `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj`: + +```xml + +``` + +(Exact version pinned at implementation time.) + +After adding the dep, the implementation step **must** run `dotnet publish -c Release` and grep for `IL3050|IL2026`. This is non-negotiable. + +### 5.2 Writer + +The model is intentionally narrow — we read the root as `TomlTable` (Tomlyn's dynamic dict) and only mutate the `projects` subtable. Every other root key (`model`, `[mcp_servers.*]`, `[plugins."..."]`, `[marketplaces.*]`, ad-hoc user keys) is preserved through round-trip — but Tomlyn does **not** preserve user comments or original formatting. We accept that loss in exchange for AOT-safe parsing. + +```csharp +internal static partial class CodexConfigWriter { + static readonly Lock _writeLock = new(); + + public static void TrustWorktree(string worktreePath, ILogger logger) { + lock (_writeLock) { + var configPath = Path.Combine(CodexPaths.Home, "config.toml"); + + TomlTable root; + if (File.Exists(configPath)) { + try { + root = Toml.ToModel(File.ReadAllText(configPath)); + } catch (Exception ex) { + logger.LogWarning(ex, "Failed to parse {Path}; aborting pre-trust", configPath); + return; + } + } else { + root = new TomlTable(); + } + + if (root["projects"] is not TomlTable projects) { + projects = new TomlTable(); + root["projects"] = projects; + } + + if (projects[worktreePath] is not TomlTable entry) { + entry = new TomlTable(); + projects[worktreePath] = entry; + } + + var alreadyTrusted = entry["trust_level"] is string s && + string.Equals(s, "trusted", StringComparison.Ordinal); + + if (alreadyTrusted) return; + + entry["trust_level"] = "trusted"; + + try { + WriteTomlAtomic(configPath, root); + } catch (Exception ex) { + logger.LogWarning(ex, "Failed to write {Path}; pre-trust not persisted", configPath); + } + } + } + + static void WriteTomlAtomic(string path, TomlTable root) { + var tmp = path + ".tmp-" + Environment.ProcessId + "-" + Guid.NewGuid().ToString("N"); + File.WriteAllText(tmp, Toml.FromModel(root)); + + try { + File.Move(tmp, path, overwrite: true); + } catch { + try { File.Delete(tmp); } catch { /* best-effort */ } + throw; + } + } +} +``` + +### 5.3 Source-gen fallback + +`Toml.ToModel` / `Toml.FromModel` operate on `TomlTable`, not user POCOs — so reflection should not be engaged. The implementation step verifies this via the AOT publish grep. If `IL3050` / `IL2026` warnings appear, the fallback is a strongly-typed model gated on a `[TomlSerializable]` partial `TomlSerializerContext`. Decision deferred to implementation. + +### 5.4 Failure semantics + +Mirrors the Claude pre-trust path: every error path logs at Warning and continues. The orchestrator wraps `launcher.Prepare(ctx)` in a try/catch so a config-file glitch cannot block launch. If `trust_level` is not set, Codex's first run hits its own trust prompt — visible in the PTY relay, recoverable by re-launch. + +## 6. `LocalPermissionBridge` per-vendor routing + +The bridge keeps its single-listener / single-token / SignalR-fanout design. Only the URL match and the response-shape construction become vendor-aware. + +### 6.1 URL routing + +Path expected: `/{token}/{vendor}/permission-request`. The handler asserts vendor ∈ {`claude`, `codex`} and 404s anything else (including the legacy unprefixed path, which catches incomplete migration). + +### 6.2 Server SignalR call + +The bridge invokes `server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct)`. v1 plan: add a `string vendor` parameter to the hub method and store it on the permission-request row, so the dashboard can render vendor-appropriate prompts without joining back to the agent run. + +Fallback if the server-side change is too entangled for the same Linear issue: omit the vendor parameter — the server already knows vendor via `AgentRunStarted`. Decision noted; the daemon-side bridge can pass vendor regardless. + +### 6.3 Vendor-shaped responses + +Claude response (current): + +```json +{ "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow", "applyPermissions": ..., "updatedInput": ... } +}} +``` + +Codex response (new): same `hookSpecificOutput`/`hookEventName`/`decision`, but `decision` carries only `behavior`. If the server's `PermissionDecision` includes `applyPermissions` or `updatedInput` the Codex branch silently drops them (Debug-log). + +```csharp +static string BuildHookResponseJson(PermissionDecision decision, string vendor) => + vendor switch { + "claude" => BuildClaudeResponse(decision), + "codex" => BuildCodexResponse(decision), + _ => throw new InvalidOperationException($"Unsupported vendor: {vendor}") + }; +``` + +`BuildClaudeResponse` is the current builder verbatim. `BuildCodexResponse` strips to `behavior` only. + +### 6.4 Request parsing for Codex + +Extract `session_id` (required) with the same dashless normalization Claude uses. Forward the rest of the payload to the server's hub method as opaque JSON — the daemon does not need to interpret Codex's command/tool field names. The bridge implementation step should log one real Codex `PermissionRequest` payload to confirm field shapes, then proceed with opaque forwarding. + +## 7. CLI Codex hook → daemon bridge bounce + +`src/kapacitor/Commands/CodexHookCommand.cs` `HandlePermissionRequest` becomes two branches. + +### 7.1 Detection + +```csharp +static async Task HandlePermissionRequest(string baseUrl, JsonNode node) { + var daemonUrl = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + + return daemonUrl is null + ? await HandlePermissionRequestStub(baseUrl, node) + : await HandlePermissionRequestViaBridge(daemonUrl, node); +} +``` + +### 7.2 Stub branch (unchanged) + +User-launched Codex sessions: + +- POST to `{baseUrl}/hooks/permission-request/codex` (informational). +- Print `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` to stdout and return 0. + +### 7.3 Bridge branch (new) + +Daemon-launched (hosted) Codex sessions: + +- POST `node.ToJsonString()` to `{daemonUrl}/codex/permission-request`. +- The HTTP call blocks until the user decides on the dashboard. Timeout is intentionally infinite on the CLI side; the daemon owns the lifetime. +- On success: write the bridge's response body verbatim to stdout, return 0. +- **Skip** the server-side informational POST in this branch. The daemon's SignalR `RequestPermission` call already records the request server-side. +- On error (connection refused / 500 / cancellation): fall back to the allow-stub so the agent does not hang. Log to stderr. + +```csharp +static async Task HandlePermissionRequestViaBridge(string daemonUrl, JsonNode node) { + using var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; + + try { + using var content = new StringContent(node.ToJsonString(), Encoding.UTF8, "application/json"); + using var resp = await client.PostAsync($"{daemonUrl}/codex/permission-request", content); + + if (!resp.IsSuccessStatusCode) { + Console.Error.WriteLine($"[kapacitor] codex-hook permission-request bridge: HTTP {(int)resp.StatusCode}"); + return EmitAllowStub(); + } + + var body = await resp.Content.ReadAsStringAsync(); + Console.Write(body); + return 0; + } catch (Exception ex) { + Console.Error.WriteLine($"[kapacitor] codex-hook permission-request bridge error: {ex.Message}"); + return EmitAllowStub(); + } +} + +static int EmitAllowStub() { + var response = new JsonObject { + ["hookSpecificOutput"] = new JsonObject { + ["hookEventName"] = "PermissionRequest", + ["decision"] = new JsonObject { ["behavior"] = "allow" } + } + }; + Console.Write(response.ToJsonString()); + return 0; +} +``` + +## 8. Tests + +### 8.1 `LaunchAgentCommandWireFormatTests` (extend) + +- `Vendor_field_round_trips_through_signalr_jsonprotocol` +- `Vendor_default_is_claude_when_constructor_omits_value` (ergonomic default; not a wire-compat assertion) +- `AgentRunStarted_vendor_serialises_into_json_body` + +### 8.2 `CodexConfigWriterTests` (new) + +Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. + +- `Writes_initial_projects_table_when_config_toml_missing` +- `Adds_entry_to_existing_config_preserving_other_tables` +- `Updates_trust_level_if_present_but_not_trusted` +- `No_op_when_trust_level_already_trusted` +- `Atomic_rename_leaves_no_tmp_files` +- `Concurrent_writers_serialise_safely` +- `Malformed_existing_config_is_skipped_not_overwritten` + +### 8.3 `LocalPermissionBridgeTests` (extend) + +- `Claude_path_returns_claude_response_shape` +- `Codex_path_returns_codex_response_shape` +- `Legacy_path_without_vendor_returns_404` +- `Unknown_vendor_returns_404` +- `Codex_path_strips_apply_permissions_from_server_decision` + +### 8.4 `CodexHookCommandTests` (extend) + +- `PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forwards_response_to_stdout` +- `PermissionRequest_with_daemon_url_falls_back_to_allow_on_500` +- `PermissionRequest_with_daemon_url_falls_back_to_allow_on_connection_refused` +- `PermissionRequest_without_daemon_url_still_uses_legacy_stub` +- `PermissionRequest_with_daemon_url_does_not_double_post_to_server_hooks_endpoint` + +### 8.5 `AgentOrchestratorVendorTests` (new) + +`IPtyProcessFactory` and `IHostedAgentLauncher` substituted with spies. + +- `Launch_with_vendor_claude_calls_claude_launcher` +- `Launch_with_vendor_codex_calls_codex_launcher` +- `Launch_with_unknown_vendor_emits_launch_failed_and_does_not_spawn_pty` +- `Launch_review_kind_with_vendor_codex_emits_launch_failed` +- `Cleanup_calls_vendor_specific_cleanup_method` + +### 8.6 `CodexLauncherTests` (new) + +- `BuildArgs_includes_workspace_write_sandbox_and_on_request_approval` +- `BuildArgs_maps_effort_max_to_xhigh` +- `BuildArgs_passes_low_medium_high_through_unchanged` +- `BuildArgs_omits_effort_when_null_or_auto` +- `BuildArgs_appends_prompt_after_double_dash_when_present` +- `BuildArgs_emits_no_alt_screen_flag` +- `Prepare_overlays_codex_settings_dir_from_source_repo` +- `Prepare_invokes_codex_config_writer_with_worktree_path` +- `Prepare_logs_and_swallows_filesystem_errors` + +### 8.7 Integration test placeholder + +`test/kapacitor.Tests.Integration/` does not get a new Codex integration test in this PR — existing daemon integration tests cover the lifecycle path that the launchers plug into. The end-to-end smoke test on macOS + Linux with a real `codex` binary is a manual PR-time step (one of AI-68's acceptance checkboxes). + +### 8.8 AOT verification gate + +Implementation step must run: + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +``` + +Zero matches required. + +## 9. Docs + +Per CLAUDE.md's "README sync on CLI changes" rule: + +- `README.md` + - `kapacitor agent start`: add a "Hosted Codex" subsection. + - `kapacitor codex-hook`: note that `PermissionRequest` bounces through the local daemon when `KAPACITOR_DAEMON_URL` is set. + - Daemon config section: document `ClaudePath` and `CodexPath`. + - Quick-start: one-line mention that the daemon supports both vendors. +- `src/Kapacitor.Core/Resources/help-codex-hook.txt` — daemon-bridge behaviour under `PermissionRequest`. + +No CLAUDE.md changes — existing "README sync" and AOT verification rules already cover the new work patterns. + +## 10. File touch list + +**Modify** + +- `src/Kapacitor.Core/Models.cs` — `Vendor` on `LaunchAgentCommand` + `AgentRunStarted`; `JsonSerializerContext` entries +- `src/Kapacitor.Daemon/DaemonConfig.cs` — `CodexPath` +- `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` — extract per-vendor blocks; route via `IHostedAgentLauncher`; vendor validation +- `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` — URL split, vendor-shaped response builders +- `src/kapacitor/Commands/CodexHookCommand.cs` — `PermissionRequest` bridge branch +- `src/kapacitor/Program.cs` — register `ClaudeLauncher` + `CodexLauncher` and the vendor-keyed dictionary +- `Directory.Packages.props` — add `Tomlyn` package version +- `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj` — reference `Tomlyn` +- `README.md` +- `src/Kapacitor.Core/Resources/help-codex-hook.txt` +- `test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs` +- `test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs` +- `test/kapacitor.Tests.Unit/CodexHookCommandTests.cs` + +**Create** + +- `src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs` +- `src/Kapacitor.Daemon/Services/ClaudeLauncher.cs` +- `src/Kapacitor.Daemon/Services/CodexLauncher.cs` +- `src/Kapacitor.Daemon/Services/CodexConfigWriter.cs` +- `test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs` +- `test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs` +- `test/kapacitor.Tests.Unit/CodexLauncherTests.cs` +- `docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md` (this file) + +**No change** + +- `src/Kapacitor.Core/CodexCliRunner.cs` (already complete) +- `src/Kapacitor.Core/CodexPaths.cs` (already complete) +- `src/Kapacitor.Daemon/Pty/*` (vendor-neutral on macOS/Linux) +- `src/Kapacitor.Daemon/Services/EvalRunner.cs` (vendor-neutral via existing `CodexCliRunner` integration) +- Session→agent late binding (already vendor-neutral) + +## 11. Out of scope + +- **Windows hosted Codex** — tracked in [AI-72](https://linear.app/kurrent/issue/AI-72). The `IHostedAgentLauncher` abstraction is the seam for that future work. +- **PR-review-kind hosted Codex launches** — tracked in [AI-632](https://linear.app/kurrent/issue/AI-632). v1 fails fast with `"PR review for Codex is not yet supported"`. +- **Per-MCP propagation from source repo to `~/.codex/config.toml`** — out of scope. Worktrees inherit the user's global MCP servers from `~/.codex/config.toml`. +- **Codex sandbox / approval override via launch dialog** — tracked in [AI-633](https://linear.app/kurrent/issue/AI-633). v1 hardcodes `workspace-write` + `on-request`. +- **Cumulative trust entry GC** — `[projects.""]` entries accumulate in `~/.codex/config.toml` over time. Harmless; out of scope. +- **Tomlyn fallback to typed source-gen context** — only invoked if `dotnet publish` flags AOT warnings. + +## 12. Acceptance mapping (AI-68) + +- [x] Pre-implementation spike: confirmed `[projects.""] trust_level = "trusted"` in `~/.codex/config.toml` (section 2) +- [ ] `HostedAgentLaunch` accepts and routes `vendor: codex` (sections 3.1, 4.4) +- [ ] Daemon launches Codex in a fresh worktree with correct overlay + pre-trust (sections 4.3, 5) +- [ ] `CodexCliRunner` produces a working title — already done +- [ ] Eval pipeline works against a Codex-hosted session — already vendor-neutral +- [ ] `PermissionRequest` round-trip through `LocalPermissionBridge` (sections 6, 7) +- [ ] Terminal output streams to dashboard; user input from dashboard appears in Codex TUI — PTY relay is vendor-neutral +- [ ] Manual smoke test on macOS and Linux — PR-time checklist item From 9ef792906d2281dae14b06cab25ba55e34f10a1e Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 17:15:24 +0200 Subject: [PATCH 02/36] [AI-68] docs: address review findings on hosted-Codex design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six follow-ups from review on /Users/alexey/.../design.md (file:line refs): - Add §3.5 covering the kapacitor-server wire-contract changes (DaemonCommands, CapacitorHub, LaunchAgentDialog, AgentRunStarted read-model) so AI-68 is scoped as a single cross-repo issue with a paired-PR rollout ordering. - §4.3 CodexLauncher.Prepare gains a fail-fast hook preflight that throws CodexHooksNotInstalledException when ~/.codex/hooks.json doesn't reference kapacitor codex-hook for SessionStart / Stop / PermissionRequest — keeps hosted Codex from silently running without session linking, watcher, or permission round-trip. - §4.4 adds AgentInstance.Vendor and routes the failed-launch cleanup path (orchestrator line 361) through launcher.Cleanup instead of inlined Claude-specific calls. - §5.2 switches CodexPaths.Home from init-once to a computed property so HOME overrides take effect mid-test, and creates the parent .codex directory before atomic write so first-time Codex users get pre-trusted. - §7.3 Codex CLI bridge bounce becomes fail-closed (deny + nonzero exit on any HTTP failure), with loopback validation extracted from PermissionRequestCommand.cs:70 as a shared DaemonBridgeUrl helper. - §8 test list updated for the new fail-closed semantics, the directory creation case, the hook preflight, and the failed-after-prepare cleanup regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-14-ai-68-codex-hosted-agents-design.md | 136 ++++++++++++++---- 1 file changed, 108 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md index a78ad3cc93..9be6abb2a1 100644 --- a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md +++ b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md @@ -124,6 +124,25 @@ public string CodexPath { get; set; } = "codex"; // NEW `KapacitorPath` is unchanged — what's-done generation is vendor-neutral. +### 3.5 Server-side wire-contract changes (kapacitor-server repo) + +AI-68 spans both repos. Daemon-side changes are not independently shippable — without server-side propagation, no user can ever request `vendor: codex` from the dashboard. The server PR lands paired with this one; this section documents the wire contract this design assumes the server will honour, so daemon and server can be implemented in parallel. + +Server files touched (paths relative to `kapacitor-server` repo root): + +- **`src/Kurrent.Capacitor/Agents/DaemonCommands.cs`** — the server-side mirror of `LaunchAgentCommand`. Add the required `string Vendor` field with the same `"claude" | "codex"` validation. Reject unknown vendors at the controller boundary so an invalid value never reaches the daemon. +- **`src/Kurrent.Capacitor/Sessions/CapacitorHub.cs`** — the `LaunchAgent` hub method (and any HTTP entry point that ultimately produces a `DaemonCommands.LaunchAgent`) accepts vendor from the request DTO and forwards it through. The `RequestPermission` hub method (called by `LocalPermissionBridge`) gains a `string vendor` parameter; the server persists it on the permission-request row so the dashboard can render vendor-appropriate prompts. +- **`src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor`** — vendor selector in the launch dialog (radio buttons or dropdown — UX choice). Default `"claude"`. The selection populates the new field on the launch DTO. +- **AgentRunStarted read model** — server-side projection of `AgentRunStarted` adds a `Vendor` column / property. Used by the dashboard to render a vendor badge on the agent card. Migration path is whatever the server uses for additive read-model fields (existing rows backfill to `"claude"` since they predate Codex). + +Ordering for paired PR rollout (staging): + +1. Land the server PR first. It teaches the server about `vendor`, but the dashboard still defaults to `"claude"` for every launch, so behaviour is identical to today. +2. Land the daemon PR. Newer daemons handle both vendors; older daemons receiving an explicit `vendor: "claude"` from the new server keep working unchanged. +3. Flip the dashboard's launch dialog to expose the vendor selector. Until this step, hosted Codex remains gated behind the server-side feature even though both ends support it. + +The reverse order (daemon first) is also safe — newer daemons connected to older servers default to Claude on construction (the C# `Vendor = "claude"` default), so older servers that send no `vendor` field see no behavioural change. + ## 4. `IHostedAgentLauncher` interface and impls A single internal interface in `kapacitor.Daemon.Services`. The surface is the smallest possible — only the vendor-specific bits. @@ -195,11 +214,14 @@ Pulls in (verbatim, no behavior change) from `AgentOrchestrator.cs`: `Prepare(ctx)`: -- `OverlayDirectory(source/.codex, worktree/.codex)` — lifted to a shared static helper. -- `CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, logger)` — adds `[projects.""] trust_level = "trusted"` to `~/.codex/config.toml`. Details in section 5. +- **Hook preflight (THROWS on failure).** Read `~/.codex/hooks.json` and confirm that `SessionStart`, `Stop`, and `PermissionRequest` each have at least one entry whose `command` contains `kapacitor codex-hook` (reuse the predicate `PluginCommand.EntryReferencesKapacitorCodexHook`). If any of the three critical events lacks a kapacitor entry, throw a `CodexHooksNotInstalledException` carrying an actionable message: `"Codex hooks not installed. Run `kapacitor plugin install --codex` and try again."`. The orchestrator catches this exception and emits a `LaunchFailed` with the message — no PTY spawn, no worktree leakage. This guards against silent breakage where the agent runs but session linking, watcher startup, and the permission round-trip are all dead. Unlike the filesystem-overlay best-effort branches below, this is a fail-fast preflight: the rest of the launch is meaningless without working hooks. +- `OverlayDirectory(source/.codex, worktree/.codex)` — lifted to a shared static helper. Best-effort. +- `CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, logger)` — adds `[projects.""] trust_level = "trusted"` to `~/.codex/config.toml`. Best-effort. Details in section 5. - No MCP file write (Codex reads MCP from `~/.codex/config.toml`). - No tool-permission merge in v1. If `Tools` is non-empty, log at Debug so we notice if the UI starts passing them for Codex. +Auto-installing hooks is intentionally out of scope: the user has not opted into the daemon mutating their global `~/.codex/hooks.json` just by clicking "launch". The actionable error keeps the user in control. + `BuildArgs(ctx)`: For default launches: @@ -221,14 +243,16 @@ Review launches: out of scope for v1 — orchestrator fails the launch with `"PR `AgentOrchestrator` becomes vendor-neutral: - Constructor takes `IReadOnlyDictionary` injected by the composition root. +- `AgentInstance` gains a `string Vendor` field, set at construction from the resolved launcher. This makes vendor lookup trivial in both cleanup paths below. - `HandleLaunchAgent`: - Validate `cmd.Vendor`. - Look up `_launchers[cmd.Vendor]`. - Do the common prep (worktree create, attachment download, vendor-agnostic env vars). - - Call `launcher.Prepare(ctx)` (best-effort try/catch with vendor-aware log). + - Call `launcher.Prepare(ctx)` — `CodexHooksNotInstalledException` (§4.3 preflight) propagates out as a `LaunchFailed` and aborts; filesystem-overlay errors are caught with a vendor-aware warning log and launch continues. - Call `launcher.BuildArgs(ctx)`. - - Spawn PTY with `launcher.CliPath` + args + env. -- `CleanupAgentAsync` calls `launcher.Cleanup(agent)` instead of the inlined Claude-symlink call. + - Spawn PTY with `launcher.CliPath` + args + env. Store `Vendor` on the resulting `AgentInstance`. +- `CleanupAgentAsync` resolves the launcher via `_launchers[agent.Vendor]` and calls `launcher.Cleanup(agent)`. No inlined `RemoveClaudeProjectSymlink` call. +- **Failed-launch cleanup path** (today at `AgentOrchestrator.cs:361`): the catch-block currently calls `RemoveClaudeProjectSymlink` and `WorktreeManager.RemoveAsync` directly. Updated path: build a synthetic `AgentInstance` (or extract a thin `LaunchedAgentTeardown` helper that takes the launcher + worktree) and dispatch through `launcher.Cleanup` so a failed-after-prepare Codex launch can't leak Claude-specific cleanup. The mcp-config-path cleanup at line 372 also moves into the launcher's responsibility (Claude has one; Codex returns `null`). - Env vars (`KAPACITOR_RENDERED_AGENT`, `KAPACITOR_AGENT_ID`, `KAPACITOR_URL`, `KAPACITOR_DAEMON_URL`) stay in the orchestrator. ### 4.5 Composition root @@ -268,6 +292,19 @@ After adding the dep, the implementation step **must** run `dotnet publish -c Re The model is intentionally narrow — we read the root as `TomlTable` (Tomlyn's dynamic dict) and only mutate the `projects` subtable. Every other root key (`model`, `[mcp_servers.*]`, `[plugins."..."]`, `[marketplaces.*]`, ad-hoc user keys) is preserved through round-trip — but Tomlyn does **not** preserve user comments or original formatting. We accept that loss in exchange for AOT-safe parsing. +**`CodexPaths.Home` becomes computed (not init-once).** Today `CodexPaths.cs:3` initialises `Home` once via field-initializer, which caches the resolved path at first-touch. Tests that scope `HOME` after any caller initialises `CodexPaths` will silently hit the real `~/.codex`. Change to: + +```csharp +// Before +public static string Home { get; } = Path.Combine(PathHelpers.HomeDirectory, ".codex"); +// After +public static string Home => Path.Combine(PathHelpers.HomeDirectory, ".codex"); +``` + +`PathHelpers.HomeDirectory` already honours `HOME`/`USERPROFILE` overrides, so this gives tests full isolation and costs one extra `Path.Combine` per call (negligible). The same change applies to `CodexPaths.Sessions` and `CodexPaths.UserHooksJson` — all three are switched to expression-bodied properties. + +The writer itself ensures the parent directory exists before atomic write so first-time Codex users (who have never run `codex` and therefore have no `~/.codex/` at all) still get pre-trusted: + ```csharp internal static partial class CodexConfigWriter { static readonly Lock _writeLock = new(); @@ -306,6 +343,9 @@ internal static partial class CodexConfigWriter { entry["trust_level"] = "trusted"; try { + // First-time users have no ~/.codex; create it before the atomic rename + // so they get pre-trusted on the first hosted-Codex launch. + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); WriteTomlAtomic(configPath, root); } catch (Exception ex) { logger.LogWarning(ex, "Failed to write {Path}; pre-trust not persisted", configPath); @@ -345,9 +385,7 @@ Path expected: `/{token}/{vendor}/permission-request`. The handler asserts vendo ### 6.2 Server SignalR call -The bridge invokes `server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct)`. v1 plan: add a `string vendor` parameter to the hub method and store it on the permission-request row, so the dashboard can render vendor-appropriate prompts without joining back to the agent run. - -Fallback if the server-side change is too entangled for the same Linear issue: omit the vendor parameter — the server already knows vendor via `AgentRunStarted`. Decision noted; the daemon-side bridge can pass vendor regardless. +The bridge invokes `server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct)`. With cross-repo scope (§3.5), the hub method gains a `string vendor` parameter and the server persists it on the permission-request row so the dashboard can render vendor-appropriate prompts without joining back to the agent run. The daemon-side bridge always passes vendor (derived from the URL segment, validated in §6.1). ### 6.3 Vendor-shaped responses @@ -400,27 +438,36 @@ User-launched Codex sessions: - POST to `{baseUrl}/hooks/permission-request/codex` (informational). - Print `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` to stdout and return 0. -### 7.3 Bridge branch (new) +### 7.3 Bridge branch (new) — **fail closed** Daemon-launched (hosted) Codex sessions: -- POST `node.ToJsonString()` to `{daemonUrl}/codex/permission-request`. +- **Loopback validation first.** Reject any `KAPACITOR_DAEMON_URL` that isn't `http://127.0.0.1:/` (loopback host, `http` scheme). The existing Claude path validates this at `PermissionRequestCommand.cs:70`; extract that helper into a shared static (e.g. `DaemonBridgeUrl.TryParseLoopback`) and call it from both Claude and Codex paths. On a non-loopback or non-http URL: emit `{behavior:"deny"}` to stdout and exit nonzero. We don't want unauthenticated permission payloads leaving the loopback interface even on a misconfigured machine. +- POST `node.ToJsonString()` to `{daemonUrl}/codex/permission-request`. Content-Type `application/json`. - The HTTP call blocks until the user decides on the dashboard. Timeout is intentionally infinite on the CLI side; the daemon owns the lifetime. - On success: write the bridge's response body verbatim to stdout, return 0. - **Skip** the server-side informational POST in this branch. The daemon's SignalR `RequestPermission` call already records the request server-side. -- On error (connection refused / 500 / cancellation): fall back to the allow-stub so the agent does not hang. Log to stderr. +- **On HTTP failure (5xx, non-2xx, connection refused, cancellation, parse error): emit `{behavior:"deny"}` to stdout and exit nonzero.** This matches Claude's two-layer fail-closed behaviour: + - Bridge SignalR-failure path returns `deny` (today at `LocalPermissionBridge.cs:188`). + - CLI HTTP-failure path returns nonzero (today at `PermissionRequestCommand.cs:102`). + Allow-stub is **only** for the no-daemon branch (§7.2). The hosted-agent path never falls back to allow — that would silently disable approvals exactly when the local permission bridge is broken. ```csharp static async Task HandlePermissionRequestViaBridge(string daemonUrl, JsonNode node) { + if (!DaemonBridgeUrl.TryParseLoopback(daemonUrl, out var baseUrl)) { + Console.Error.WriteLine($"[kapacitor] codex-hook permission-request: KAPACITOR_DAEMON_URL must be http loopback, got: {daemonUrl}"); + return EmitDenyAndExitNonzero(); + } + using var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; try { using var content = new StringContent(node.ToJsonString(), Encoding.UTF8, "application/json"); - using var resp = await client.PostAsync($"{daemonUrl}/codex/permission-request", content); + using var resp = await client.PostAsync($"{baseUrl}/codex/permission-request", content); if (!resp.IsSuccessStatusCode) { Console.Error.WriteLine($"[kapacitor] codex-hook permission-request bridge: HTTP {(int)resp.StatusCode}"); - return EmitAllowStub(); + return EmitDenyAndExitNonzero(); } var body = await resp.Content.ReadAsStringAsync(); @@ -428,20 +475,23 @@ static async Task HandlePermissionRequestViaBridge(string daemonUrl, JsonNo return 0; } catch (Exception ex) { Console.Error.WriteLine($"[kapacitor] codex-hook permission-request bridge error: {ex.Message}"); - return EmitAllowStub(); + return EmitDenyAndExitNonzero(); } } -static int EmitAllowStub() { +static int EmitDenyAndExitNonzero() { var response = new JsonObject { ["hookSpecificOutput"] = new JsonObject { ["hookEventName"] = "PermissionRequest", - ["decision"] = new JsonObject { ["behavior"] = "allow" } + ["decision"] = new JsonObject { ["behavior"] = "deny" } } }; Console.Write(response.ToJsonString()); - return 0; + return 1; } + +// User-launched (§7.2) keeps emitting the allow stub on its own dedicated path. +// Hosted Codex never reuses that helper. ``` ## 8. Tests @@ -454,9 +504,10 @@ static int EmitAllowStub() { ### 8.2 `CodexConfigWriterTests` (new) -Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. +Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. The switch of `CodexPaths.Home` to a computed property (§5.2) makes `HOME` overrides take effect mid-test reliably. - `Writes_initial_projects_table_when_config_toml_missing` +- `Writes_to_fresh_home_creates_codex_directory` — `HOME` points to a temp dir with NO `.codex/` subdir; assert `~/.codex/config.toml` is created and contains the trust entry - `Adds_entry_to_existing_config_preserving_other_tables` - `Updates_trust_level_if_present_but_not_trusted` - `No_op_when_trust_level_already_trusted` @@ -474,10 +525,12 @@ Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. ### 8.4 `CodexHookCommandTests` (extend) -- `PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forwards_response_to_stdout` -- `PermissionRequest_with_daemon_url_falls_back_to_allow_on_500` -- `PermissionRequest_with_daemon_url_falls_back_to_allow_on_connection_refused` -- `PermissionRequest_without_daemon_url_still_uses_legacy_stub` +- `PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forwards_response_to_stdout` — exit code 0, body matches WireMock response +- `PermissionRequest_with_daemon_url_emits_deny_and_exits_nonzero_on_500` — fail-closed parity check +- `PermissionRequest_with_daemon_url_emits_deny_and_exits_nonzero_on_connection_refused` +- `PermissionRequest_with_non_loopback_daemon_url_emits_deny_and_exits_nonzero_without_posting` — security guard. Point `KAPACITOR_DAEMON_URL` at `http://example.com/token`; WireMock observes NO outbound traffic +- `PermissionRequest_with_https_daemon_url_emits_deny_and_exits_nonzero_without_posting` — same guard, https variant +- `PermissionRequest_without_daemon_url_still_uses_legacy_stub` — user-launched path stays allow+0 - `PermissionRequest_with_daemon_url_does_not_double_post_to_server_hooks_endpoint` ### 8.5 `AgentOrchestratorVendorTests` (new) @@ -488,7 +541,9 @@ Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. - `Launch_with_vendor_codex_calls_codex_launcher` - `Launch_with_unknown_vendor_emits_launch_failed_and_does_not_spawn_pty` - `Launch_review_kind_with_vendor_codex_emits_launch_failed` -- `Cleanup_calls_vendor_specific_cleanup_method` +- `Cleanup_calls_vendor_specific_cleanup_method` — `AgentInstance.Vendor` is set; cleanup dispatches through the correct launcher +- `Failed_launch_after_prepare_calls_codex_launcher_cleanup_not_claude` — regression for the orchestrator's catch block (today line 361). Spawn-throws scenario with vendor=codex; assert ClaudeLauncher's cleanup spy is never invoked +- `Codex_hooks_not_installed_exception_during_prepare_yields_actionable_launch_failed` — preflight (§4.3) emits the LaunchFailed with the install-instructions message; no PTY spawn ### 8.6 `CodexLauncherTests` (new) @@ -498,9 +553,14 @@ Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. - `BuildArgs_omits_effort_when_null_or_auto` - `BuildArgs_appends_prompt_after_double_dash_when_present` - `BuildArgs_emits_no_alt_screen_flag` +- `Prepare_throws_when_hooks_json_missing` — preflight; assert thrown exception type is `CodexHooksNotInstalledException` and message contains `kapacitor plugin install --codex` +- `Prepare_throws_when_hooks_json_missing_session_start_entry` +- `Prepare_throws_when_hooks_json_missing_stop_entry` +- `Prepare_throws_when_hooks_json_missing_permission_request_entry` +- `Prepare_succeeds_when_hooks_json_has_all_three_critical_events` - `Prepare_overlays_codex_settings_dir_from_source_repo` - `Prepare_invokes_codex_config_writer_with_worktree_path` -- `Prepare_logs_and_swallows_filesystem_errors` +- `Prepare_logs_and_swallows_filesystem_errors` — only filesystem errors are swallowed; preflight failures still throw ### 8.7 Integration test placeholder @@ -533,11 +593,18 @@ No CLAUDE.md changes — existing "README sync" and AOT verification rules alrea **Modify** +### 10.1 CLI / daemon repo (this repo) + +**Modify** + - `src/Kapacitor.Core/Models.cs` — `Vendor` on `LaunchAgentCommand` + `AgentRunStarted`; `JsonSerializerContext` entries +- `src/Kapacitor.Core/CodexPaths.cs` — convert `Home`/`Sessions`/`UserHooksJson` from init-once to computed properties (§5.2) - `src/Kapacitor.Daemon/DaemonConfig.cs` — `CodexPath` -- `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` — extract per-vendor blocks; route via `IHostedAgentLauncher`; vendor validation -- `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` — URL split, vendor-shaped response builders -- `src/kapacitor/Commands/CodexHookCommand.cs` — `PermissionRequest` bridge branch +- `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` — extract per-vendor blocks; route via `IHostedAgentLauncher`; vendor validation; `AgentInstance.Vendor`; failed-launch cleanup through launcher +- `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` — URL split, vendor-shaped response builders, pass vendor to `RequestPermissionAsync` +- `src/Kapacitor.Daemon/Services/ServerConnection.cs` — `RequestPermissionAsync` signature gains `string vendor` +- `src/kapacitor/Commands/CodexHookCommand.cs` — `PermissionRequest` bridge branch (fail-closed, loopback validation) +- `src/kapacitor/Commands/PermissionRequestCommand.cs` — extract loopback-validation helper for reuse by the Codex bridge bounce - `src/kapacitor/Program.cs` — register `ClaudeLauncher` + `CodexLauncher` and the vendor-keyed dictionary - `Directory.Packages.props` — add `Tomlyn` package version - `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj` — reference `Tomlyn` @@ -553,6 +620,8 @@ No CLAUDE.md changes — existing "README sync" and AOT verification rules alrea - `src/Kapacitor.Daemon/Services/ClaudeLauncher.cs` - `src/Kapacitor.Daemon/Services/CodexLauncher.cs` - `src/Kapacitor.Daemon/Services/CodexConfigWriter.cs` +- `src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs` (or inline into `CodexLauncher.cs` — implementation choice) +- `src/kapacitor/Commands/DaemonBridgeUrl.cs` — shared loopback-validation helper (extracted from `PermissionRequestCommand`) - `test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs` - `test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs` - `test/kapacitor.Tests.Unit/CodexLauncherTests.cs` @@ -561,11 +630,22 @@ No CLAUDE.md changes — existing "README sync" and AOT verification rules alrea **No change** - `src/Kapacitor.Core/CodexCliRunner.cs` (already complete) -- `src/Kapacitor.Core/CodexPaths.cs` (already complete) - `src/Kapacitor.Daemon/Pty/*` (vendor-neutral on macOS/Linux) - `src/Kapacitor.Daemon/Services/EvalRunner.cs` (vendor-neutral via existing `CodexCliRunner` integration) - Session→agent late binding (already vendor-neutral) +### 10.2 Server repo (kapacitor-server) + +Detail covered in §3.5. Server-side files touched (paths relative to `kapacitor-server` repo root): + +- `src/Kurrent.Capacitor/Agents/DaemonCommands.cs` — `Vendor` on the server-side mirror of `LaunchAgentCommand`; controller-boundary validation +- `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; `RequestPermission` gains `string vendor` parameter and persists it on the permission-request row +- `src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor` — vendor selector (default `"claude"`) +- `AgentRunStarted` read-model projection — add `Vendor` column/property; existing rows backfill to `"claude"` +- Server-side tests covering vendor wire-shape round-trip and dialog selection + +Tracked in the same Linear issue (AI-68); shipped as a paired PR per the §3.5 rollout ordering. + ## 11. Out of scope - **Windows hosted Codex** — tracked in [AI-72](https://linear.app/kurrent/issue/AI-72). The `IHostedAgentLauncher` abstraction is the seam for that future work. From cfe4c3a4c9b1ceb5a4070cfa6300ad0cdf97c55f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 17:24:38 +0200 Subject: [PATCH 03/36] [AI-68] docs: address second-round review on hosted-Codex design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five follow-ups + the open question from the second review pass: - §7.0 (new) explicitly updates PermissionRequestCommand.cs:63 to post to /claude/permission-request when the bridge URL split lands. Adds the hosted-Claude regression test under §8.3 so the URL migration cannot break Claude silently. - §3.5 server rollout: RequestPermission gains an OPTIONAL fifth `vendor` parameter (defaulted to "claude") instead of a required one. SignalR's positional-arg dispatch lets old daemons (four-arg call sites) keep working against the new server. Server-first ordering is now safe. LaunchAgentCommand.Vendor on the server side also defaults to "claude" for old-daemon JSON-deserialisation compatibility. - §4.3 hook preflight accepts either user-scope (~/.codex/hooks.json) OR project-scope (worktree/.codex/hooks.json after overlay) installs. Overlay runs BEFORE preflight so project hooks copied from the source repo satisfy the check. Splits hooks across the two scopes are also accepted in aggregate. - §4.3 + §10 move EntryReferencesKapacitorCodexHook + CodexHookEvents + hooks.json parsing helpers to new src/Kapacitor.Core/CodexHooksParser.cs so the daemon can use them (Kapacitor.Daemon only references Core, not the CLI project). - §8.8 AOT publish covers BOTH src/kapacitor/kapacitor.csproj AND src/Kapacitor.Daemon/Kapacitor.Daemon.csproj. Daemon is where Tomlyn lands so its publish has to be the one that's grepped for IL3050/IL2026. - Open question resolved: §3.5 + §10.2 pull permission-request UI vendor awareness into AI-68 scope. PendingPermission carries Vendor; PermissionRequestCard hides Always Allow + Update Input for Codex. Server tests added. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-14-ai-68-codex-hosted-agents-design.md | 104 +++++++++++++----- 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md index 9be6abb2a1..e86e41a122 100644 --- a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md +++ b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md @@ -130,18 +130,29 @@ AI-68 spans both repos. Daemon-side changes are not independently shippable — Server files touched (paths relative to `kapacitor-server` repo root): -- **`src/Kurrent.Capacitor/Agents/DaemonCommands.cs`** — the server-side mirror of `LaunchAgentCommand`. Add the required `string Vendor` field with the same `"claude" | "codex"` validation. Reject unknown vendors at the controller boundary so an invalid value never reaches the daemon. -- **`src/Kurrent.Capacitor/Sessions/CapacitorHub.cs`** — the `LaunchAgent` hub method (and any HTTP entry point that ultimately produces a `DaemonCommands.LaunchAgent`) accepts vendor from the request DTO and forwards it through. The `RequestPermission` hub method (called by `LocalPermissionBridge`) gains a `string vendor` parameter; the server persists it on the permission-request row so the dashboard can render vendor-appropriate prompts. +- **`src/Kurrent.Capacitor/Agents/DaemonCommands.cs`** — the server-side mirror of `LaunchAgentCommand`. Add the `string Vendor` field, defaulted to `"claude"` for SignalR JSON-deserialisation compatibility with older daemons that do not send the field. Reject unknown values at the controller boundary so an invalid value never reaches the daemon. +- **`src/Kurrent.Capacitor/Sessions/CapacitorHub.cs`** — the `LaunchAgent` hub method (and any HTTP entry point that ultimately produces a `DaemonCommands.LaunchAgent`) accepts vendor from the request DTO and forwards it through. The `RequestPermission` hub method at `CapacitorHub.cs:369` (called today by `LocalPermissionBridge` with four args: `sessionId, toolName, toolInput, suggestions`) gains a **fifth optional `string vendor = "claude"` parameter** — SignalR's positional-arg dispatch lets old daemons (which still send four args) call the new method without breaking, and the server defaults vendor to `"claude"`. Server persists vendor on the permission-request row so the dashboard renders vendor-appropriate prompts. - **`src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor`** — vendor selector in the launch dialog (radio buttons or dropdown — UX choice). Default `"claude"`. The selection populates the new field on the launch DTO. +- **`src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor`** — thread vendor through `PendingPermission` so the card renders vendor-aware actions. Specifically: Claude-only buttons (`Always Allow` / `Update Input`) are hidden when vendor is `"codex"` because the Codex hook response shape (§6.3) cannot honour `applyPermissions` / `updatedInput` — silently dropping the user's selection on the daemon side is worse than not offering it. See the open-question resolution at the end of this section. +- **`PendingPermission` read-model record** — add `Vendor` field, populated from the new column on the permission-request row. - **AgentRunStarted read model** — server-side projection of `AgentRunStarted` adds a `Vendor` column / property. Used by the dashboard to render a vendor badge on the agent card. Migration path is whatever the server uses for additive read-model fields (existing rows backfill to `"claude"` since they predate Codex). Ordering for paired PR rollout (staging): -1. Land the server PR first. It teaches the server about `vendor`, but the dashboard still defaults to `"claude"` for every launch, so behaviour is identical to today. -2. Land the daemon PR. Newer daemons handle both vendors; older daemons receiving an explicit `vendor: "claude"` from the new server keep working unchanged. +1. Land the server PR first. With both new fields defaulted to `"claude"` (DTO and `RequestPermission` overload), existing daemons keep working unchanged — they invoke the four-arg hub method and the server fills the fifth as `"claude"`. The dashboard still defaults to `"claude"` for every launch, so the user-visible behaviour is identical to today. +2. Land the daemon PR. Newer daemons invoke the five-arg overload, sending an explicit vendor. Newer daemons connecting to a not-yet-upgraded server would fail (because old server has no five-arg overload) — but the rollout order avoids that window. 3. Flip the dashboard's launch dialog to expose the vendor selector. Until this step, hosted Codex remains gated behind the server-side feature even though both ends support it. -The reverse order (daemon first) is also safe — newer daemons connected to older servers default to Claude on construction (the C# `Vendor = "claude"` default), so older servers that send no `vendor` field see no behavioural change. +The reverse order (daemon first) is **not** safe because newer daemons need the server's new `RequestPermission` overload. Server-first is the only correct order for this PR pair. + +**Permission-request UI vendor awareness — open-question resolution.** Today's `PermissionRequestCard.razor:26` shows `Always Allow` unconditionally; that button maps to `applyPermissions` in the Claude response shape and is dropped by the Codex response builder (§6.3). Shipping hosted Codex without UI vendor awareness would silently no-op user clicks. Pulled into AI-68 scope: `PendingPermission` carries `Vendor`, the card hides `Always Allow` and `Update Input` for `vendor == "codex"`, and shows only the `Allow` / `Deny` pair. Tests on the server side cover the card render under both vendors. + +Server-side test coverage paired with the rollout: + +- `RequestPermission_four_arg_overload_defaults_vendor_to_claude` — regression for old-daemon compatibility (call the hub method with four args, assert the persisted row carries `vendor = "claude"`). +- `RequestPermission_five_arg_overload_persists_vendor_codex` — happy path for the new daemon. +- `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent` — wire-shape regression for old daemons that do not send the field. +- `PermissionRequestCard_hides_always_allow_when_vendor_is_codex` — UI vendor awareness. ## 4. `IHostedAgentLauncher` interface and impls @@ -214,8 +225,14 @@ Pulls in (verbatim, no behavior change) from `AgentOrchestrator.cs`: `Prepare(ctx)`: -- **Hook preflight (THROWS on failure).** Read `~/.codex/hooks.json` and confirm that `SessionStart`, `Stop`, and `PermissionRequest` each have at least one entry whose `command` contains `kapacitor codex-hook` (reuse the predicate `PluginCommand.EntryReferencesKapacitorCodexHook`). If any of the three critical events lacks a kapacitor entry, throw a `CodexHooksNotInstalledException` carrying an actionable message: `"Codex hooks not installed. Run `kapacitor plugin install --codex` and try again."`. The orchestrator catches this exception and emits a `LaunchFailed` with the message — no PTY spawn, no worktree leakage. This guards against silent breakage where the agent runs but session linking, watcher startup, and the permission round-trip are all dead. Unlike the filesystem-overlay best-effort branches below, this is a fail-fast preflight: the rest of the launch is meaningless without working hooks. -- `OverlayDirectory(source/.codex, worktree/.codex)` — lifted to a shared static helper. Best-effort. +- `OverlayDirectory(source/.codex, worktree/.codex)` — lifted to a shared static helper. Best-effort. **Runs before the hook preflight** so project-scoped hooks (`/.codex/hooks.json` written by `kapacitor plugin install --project --codex`) get copied into the worktree first; the preflight then sees them. +- **Hook preflight (THROWS on failure).** Look for a `kapacitor codex-hook` entry covering `SessionStart`, `Stop`, and `PermissionRequest` in **either**: + - `/.codex/hooks.json` (project scope — populated by the overlay step above), OR + - `~/.codex/hooks.json` (user scope — the `kapacitor plugin install --codex` default). + + Either location is sufficient. If neither has all three critical events, throw `CodexHooksNotInstalledException` with an actionable message: `"Codex hooks not installed. Run `kapacitor plugin install --codex` (user scope) or `kapacitor plugin install --codex --project` (project scope) and try again."`. The orchestrator catches and emits `LaunchFailed`. + + The parsing predicate (`EntryReferencesKapacitorCodexHook`, today at `PluginCommand.cs:286`) lives in the `kapacitor` CLI project and isn't reachable from `Kapacitor.Daemon`. Move it (plus the `CodexHookEvents` constant array and the `hooks.json` JSON-shape helpers) to a new `Kapacitor.Core/CodexHooksParser.cs` so both CLI and daemon use one shared parser. `PluginCommand` keeps the install/remove file-write logic and calls into the parser. - `CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, logger)` — adds `[projects.""] trust_level = "trusted"` to `~/.codex/config.toml`. Best-effort. Details in section 5. - No MCP file write (Codex reads MCP from `~/.codex/config.toml`). - No tool-permission merge in v1. If `Tools` is non-empty, log at Debug so we notice if the UI starts passing them for Codex. @@ -415,11 +432,21 @@ static string BuildHookResponseJson(PermissionDecision decision, string vendor) Extract `session_id` (required) with the same dashless normalization Claude uses. Forward the rest of the payload to the server's hub method as opaque JSON — the daemon does not need to interpret Codex's command/tool field names. The bridge implementation step should log one real Codex `PermissionRequest` payload to confirm field shapes, then proceed with opaque forwarding. -## 7. CLI Codex hook → daemon bridge bounce +## 7. CLI permission-request hook bridge bounces + +Two CLI commands hit the daemon bridge: the Claude permission-request hook (existing) and the Codex permission-request hook (new). Both change in this PR because §6.1 splits the bridge URL by vendor — the legacy `/{token}/permission-request` path now 404s. -`src/kapacitor/Commands/CodexHookCommand.cs` `HandlePermissionRequest` becomes two branches. +### 7.0 Claude CLI hook URL update (existing path migration) -### 7.1 Detection +`src/kapacitor/Commands/PermissionRequestCommand.cs:63` today posts to `daemonUrl + "/permission-request"`. With the URL split (§6.1), this becomes a 404 the moment the daemon ships the new bridge. Update the post target to `daemonUrl + "/claude/permission-request"`. + +This is a paired-PR-coordination concern: a newer CLI hook talking to an older daemon (which still listens on the legacy unprefixed path) would also 404. Since CLI and daemon ship in the same npm package and upgrade together (`kapacitor` and `kapacitor-daemon` both in `node_modules/.bin`), the version-skew window is the single npm install. Acceptable. + +**Regression test (extend `LocalPermissionBridgeTests`):** `Claude_path_round_trips_a_real_permission_request_payload` — drives `PermissionRequestCommand` against a WireMock-backed bridge listening on `/{token}/claude/permission-request`; asserts the request lands and the Claude response shape is forwarded to stdout. Catches the URL migration breaking hosted Claude. + +### 7.1 Codex CLI hook — detection + +`src/kapacitor/Commands/CodexHookCommand.cs` `HandlePermissionRequest` becomes two branches: ```csharp static async Task HandlePermissionRequest(string baseUrl, JsonNode node) { @@ -431,14 +458,14 @@ static async Task HandlePermissionRequest(string baseUrl, JsonNode node) { } ``` -### 7.2 Stub branch (unchanged) +### 7.2 Codex CLI hook — stub branch (unchanged) User-launched Codex sessions: - POST to `{baseUrl}/hooks/permission-request/codex` (informational). - Print `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` to stdout and return 0. -### 7.3 Bridge branch (new) — **fail closed** +### 7.3 Codex CLI hook — bridge branch (new) — **fail closed** Daemon-launched (hosted) Codex sessions: @@ -553,12 +580,14 @@ Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. The switch - `BuildArgs_omits_effort_when_null_or_auto` - `BuildArgs_appends_prompt_after_double_dash_when_present` - `BuildArgs_emits_no_alt_screen_flag` -- `Prepare_throws_when_hooks_json_missing` — preflight; assert thrown exception type is `CodexHooksNotInstalledException` and message contains `kapacitor plugin install --codex` -- `Prepare_throws_when_hooks_json_missing_session_start_entry` -- `Prepare_throws_when_hooks_json_missing_stop_entry` -- `Prepare_throws_when_hooks_json_missing_permission_request_entry` -- `Prepare_succeeds_when_hooks_json_has_all_three_critical_events` -- `Prepare_overlays_codex_settings_dir_from_source_repo` +- `Prepare_throws_when_no_hooks_json_anywhere` — neither worktree nor user scope; assert thrown exception type is `CodexHooksNotInstalledException` and message mentions both `kapacitor plugin install --codex` and `--project` +- `Prepare_throws_when_user_scope_hooks_json_missing_session_start_entry` +- `Prepare_throws_when_user_scope_hooks_json_missing_stop_entry` +- `Prepare_throws_when_user_scope_hooks_json_missing_permission_request_entry` +- `Prepare_succeeds_when_user_scope_hooks_json_has_all_three_critical_events` +- `Prepare_succeeds_when_project_scope_hooks_json_has_all_three_critical_events` — project-scope install in `/.codex/hooks.json` overlays into worktree and satisfies the preflight; user-scope `~/.codex/hooks.json` is intentionally empty for this test +- `Prepare_succeeds_when_critical_events_are_split_across_user_and_project_scope` — e.g. SessionStart in user scope, Stop + PermissionRequest in project scope; aggregated check passes +- `Prepare_overlays_codex_settings_dir_from_source_repo` — verifies overlay happens BEFORE preflight (ordering matters) - `Prepare_invokes_codex_config_writer_with_worktree_path` - `Prepare_logs_and_swallows_filesystem_errors` — only filesystem errors are swallowed; preflight failures still throw @@ -568,13 +597,17 @@ Uses a scoped `HOME` so the real `~/.codex/config.toml` is untouched. The switch ### 8.8 AOT verification gate -Implementation step must run: +The Tomlyn dep is added to `Kapacitor.Daemon.csproj`, which produces a separate AOT-published binary (`kapacitor-daemon`) than the CLI (`kapacitor`). Publishing only the CLI hides any Tomlyn-related trim/AOT warnings that live in the daemon's hot path. + +Implementation step must run **both** publishes and grep across both outputs: ```bash -dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}' +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | tee /tmp/aot-cli.log +dotnet publish src/Kapacitor.Daemon/Kapacitor.Daemon.csproj -c Release 2>&1 | tee /tmp/aot-daemon.log +grep -E 'IL[23][01][0-9]{2}' /tmp/aot-cli.log /tmp/aot-daemon.log ``` -Zero matches required. +Zero matches required across both logs. If the daemon project isn't currently configured for AOT publish (verify against its `` setting at implementation time), add that as part of this PR — adding a NativeAOT-sensitive dep to a project that doesn't publish AOT silently defers the warning surface to a future ship. ## 9. Docs @@ -602,26 +635,30 @@ No CLAUDE.md changes — existing "README sync" and AOT verification rules alrea - `src/Kapacitor.Daemon/DaemonConfig.cs` — `CodexPath` - `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` — extract per-vendor blocks; route via `IHostedAgentLauncher`; vendor validation; `AgentInstance.Vendor`; failed-launch cleanup through launcher - `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` — URL split, vendor-shaped response builders, pass vendor to `RequestPermissionAsync` -- `src/Kapacitor.Daemon/Services/ServerConnection.cs` — `RequestPermissionAsync` signature gains `string vendor` +- `src/Kapacitor.Daemon/Services/ServerConnection.cs` — `RequestPermissionAsync` signature gains `string vendor`; invokes the five-arg server overload - `src/kapacitor/Commands/CodexHookCommand.cs` — `PermissionRequest` bridge branch (fail-closed, loopback validation) -- `src/kapacitor/Commands/PermissionRequestCommand.cs` — extract loopback-validation helper for reuse by the Codex bridge bounce +- `src/kapacitor/Commands/PermissionRequestCommand.cs` — **update post target to `daemonUrl + "/claude/permission-request"`** (line 63 today). Also extract loopback-validation helper for reuse by the Codex bridge bounce. +- `src/kapacitor/Commands/PluginCommand.cs` — delegate `EntryReferencesKapacitorCodexHook` + `CodexHookEvents` to the new `Kapacitor.Core/CodexHooksParser.cs` (parser move, see Create list) - `src/kapacitor/Program.cs` — register `ClaudeLauncher` + `CodexLauncher` and the vendor-keyed dictionary - `Directory.Packages.props` — add `Tomlyn` package version -- `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj` — reference `Tomlyn` +- `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj` — reference `Tomlyn`; verify/enable `` so warnings surface during the gate (§8.8) - `README.md` - `src/Kapacitor.Core/Resources/help-codex-hook.txt` - `test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs` -- `test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs` +- `test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs` — add the Claude-CLI-hook regression test (§7.0) - `test/kapacitor.Tests.Unit/CodexHookCommandTests.cs` +- `test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs` — re-target predicate import to `CodexHooksParser` **Create** +- `src/Kapacitor.Core/CodexHooksParser.cs` — shared `~/.codex/hooks.json` parser; hosts the moved `EntryReferencesKapacitorCodexHook` predicate, the `CodexHookEvents` constant array, and a `HasKapacitorHooksFor(JsonObject root, IEnumerable events)` helper used by both `PluginCommand` (install/remove) and `CodexLauncher` (preflight) - `src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs` - `src/Kapacitor.Daemon/Services/ClaudeLauncher.cs` - `src/Kapacitor.Daemon/Services/CodexLauncher.cs` - `src/Kapacitor.Daemon/Services/CodexConfigWriter.cs` - `src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs` (or inline into `CodexLauncher.cs` — implementation choice) - `src/kapacitor/Commands/DaemonBridgeUrl.cs` — shared loopback-validation helper (extracted from `PermissionRequestCommand`) +- `test/kapacitor.Tests.Unit/CodexHooksParserTests.cs` — direct tests on the moved predicate - `test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs` - `test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs` - `test/kapacitor.Tests.Unit/CodexLauncherTests.cs` @@ -638,13 +675,20 @@ No CLAUDE.md changes — existing "README sync" and AOT verification rules alrea Detail covered in §3.5. Server-side files touched (paths relative to `kapacitor-server` repo root): -- `src/Kurrent.Capacitor/Agents/DaemonCommands.cs` — `Vendor` on the server-side mirror of `LaunchAgentCommand`; controller-boundary validation -- `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; `RequestPermission` gains `string vendor` parameter and persists it on the permission-request row +- `src/Kurrent.Capacitor/Agents/DaemonCommands.cs` — `Vendor` on the server-side mirror of `LaunchAgentCommand`, defaulted to `"claude"` for old-daemon JSON-deserialisation compatibility; controller-boundary validation +- `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; `RequestPermission` at line 369 gains an **optional fifth `string vendor = "claude"` parameter** so old daemons calling with four args still work; persists vendor on the permission-request row - `src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor` — vendor selector (default `"claude"`) +- `src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor` (line 26 today) — hide `Always Allow` and `Update Input` buttons when `PendingPermission.Vendor == "codex"`; show only `Allow` / `Deny` +- `PendingPermission` read-model record — add `Vendor` field, populated from the new permission-request row column - `AgentRunStarted` read-model projection — add `Vendor` column/property; existing rows backfill to `"claude"` -- Server-side tests covering vendor wire-shape round-trip and dialog selection - -Tracked in the same Linear issue (AI-68); shipped as a paired PR per the §3.5 rollout ordering. +- Server-side tests: + - `RequestPermission_four_arg_overload_defaults_vendor_to_claude` (old-daemon compatibility) + - `RequestPermission_five_arg_overload_persists_vendor_codex` (new daemon happy path) + - `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent` + - `PermissionRequestCard_hides_always_allow_when_vendor_is_codex` + - `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression) + +Tracked in the same Linear issue (AI-68); shipped as a paired PR per the §3.5 rollout ordering (server first). ## 11. Out of scope From 58a6495b5248ce18f1f56f12348929427330db00 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 17:37:37 +0200 Subject: [PATCH 04/36] [AI-68] docs: address third-round review on hosted-Codex design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four follow-ups from third review: - §3.5 + §6.2 + §10.2 switch from "optional fifth parameter on RequestPermission" to "new RequestPermissionV2 hub method with the same four args plus vendor". This repo's existing SignalR regression notes (SendTranscriptBatchWireTests.cs:15, RemoteAgentService.cs:142) document that HubConnection.InvokeAsync dispatches by exact arity and does NOT backfill optional values. The named-method approach is the only safe pattern. Old daemons keep calling four-arg RequestPermission; new daemons invoke V2. Wire-level tests added. - §3.5 expands permission-UI vendor awareness beyond PermissionRequestCard to cover AgentChatView.razor:62 ("Yes, always" inline option) and ElicitationOption.cs:13 (Claude-only "always" variant). Vendor threads through InterruptIssued, PendingPermission, SignalR PermissionRequested push, AgentChatContext, and AgentChatView. Server-side tests added for each surface. - §6.2 updates the daemon's ServerConnection.RequestPermissionAsync signature to take string vendor and to invoke "RequestPermissionV2". New LocalPermissionBridgeTests cover that POST /{token}/codex/... reaches the server with vendor = "codex" and the claude path with vendor = "claude". - §5.4 reconciles the "best-effort vs fail-fast" semantics that previously contradicted §4.3. Scopes the swallow-and-continue policy to filesystem / TOML errors only; CodexHooksNotInstalledException stays fail-fast and produces LaunchFailed. Documents the wrapper shape with explicit catch ordering so future fail-fast preconditions follow the same pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-14-ai-68-codex-hosted-agents-design.md | 106 ++++++++++++++---- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md index e86e41a122..ceae36048b 100644 --- a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md +++ b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md @@ -128,31 +128,39 @@ public string CodexPath { get; set; } = "codex"; // NEW AI-68 spans both repos. Daemon-side changes are not independently shippable — without server-side propagation, no user can ever request `vendor: codex` from the dashboard. The server PR lands paired with this one; this section documents the wire contract this design assumes the server will honour, so daemon and server can be implemented in parallel. +**SignalR arity constraint (important).** `SendTranscriptBatchWireTests.cs:15` and `RemoteAgentService.cs:142` document explicitly: SignalR's `HubConnection.InvokeAsync` dispatches by exact arity, not by named parameters, and does NOT backfill optional values when an old caller invokes a new method signature. Adding an optional fifth parameter to an existing hub method silently breaks old daemons. The only safe pattern for adding fields to an existing hub method in this repo is **a new method name**. + Server files touched (paths relative to `kapacitor-server` repo root): - **`src/Kurrent.Capacitor/Agents/DaemonCommands.cs`** — the server-side mirror of `LaunchAgentCommand`. Add the `string Vendor` field, defaulted to `"claude"` for SignalR JSON-deserialisation compatibility with older daemons that do not send the field. Reject unknown values at the controller boundary so an invalid value never reaches the daemon. -- **`src/Kurrent.Capacitor/Sessions/CapacitorHub.cs`** — the `LaunchAgent` hub method (and any HTTP entry point that ultimately produces a `DaemonCommands.LaunchAgent`) accepts vendor from the request DTO and forwards it through. The `RequestPermission` hub method at `CapacitorHub.cs:369` (called today by `LocalPermissionBridge` with four args: `sessionId, toolName, toolInput, suggestions`) gains a **fifth optional `string vendor = "claude"` parameter** — SignalR's positional-arg dispatch lets old daemons (which still send four args) call the new method without breaking, and the server defaults vendor to `"claude"`. Server persists vendor on the permission-request row so the dashboard renders vendor-appropriate prompts. +- **`src/Kurrent.Capacitor/Sessions/CapacitorHub.cs`** — the `LaunchAgent` hub method (and any HTTP entry point that ultimately produces a `DaemonCommands.LaunchAgent`) accepts vendor from the request DTO and forwards it through. **Keep the existing four-arg `RequestPermission(sessionId, toolName, toolInput, suggestions)` hub method unchanged** so older daemons continue working. **Add a new five-arg hub method `RequestPermissionV2(sessionId, toolName, toolInput, suggestions, vendor)`** that persists vendor on the permission-request row. The old method, when invoked, persists `vendor = "claude"` so older daemons keep behaving exactly as today. - **`src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor`** — vendor selector in the launch dialog (radio buttons or dropdown — UX choice). Default `"claude"`. The selection populates the new field on the launch DTO. -- **`src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor`** — thread vendor through `PendingPermission` so the card renders vendor-aware actions. Specifically: Claude-only buttons (`Always Allow` / `Update Input`) are hidden when vendor is `"codex"` because the Codex hook response shape (§6.3) cannot honour `applyPermissions` / `updatedInput` — silently dropping the user's selection on the daemon side is worse than not offering it. See the open-question resolution at the end of this section. -- **`PendingPermission` read-model record** — add `Vendor` field, populated from the new column on the permission-request row. +- **Permission UI surfaces** — three places need vendor awareness, all driven by adding `Vendor` to the `PendingPermission` read-model record and to the SignalR `PermissionRequested` payload pushed to the dashboard: + - **`src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor:26`** — hide `Always Allow` and `Update Input` buttons when `PendingPermission.Vendor == "codex"`; show only `Allow` / `Deny`. + - **`src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62`** — the active-chat input renders `Yes, always` for permission elicitations; hide it when `AgentChatContext.CurrentPermission.Vendor == "codex"`. + - **`src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:13`** — the "always" option is Claude-only. The option list construction site (wherever `AgentChatContext` builds the elicitation options from a `PendingPermission`) filters the always-variant when vendor is codex. +- **`InterruptIssued` event / `PendingPermission` record / SignalR `PermissionRequested` payload** — all three need a `Vendor` field. The daemon's `LocalPermissionBridge` already extracts vendor from the URL segment (§6.1); the server reads it from the V2 hub call and stamps it on the persisted row, the read-model record, and the dashboard push. +- **`AgentChatContext`** — exposes vendor on the current permission state so the chat view's conditional rendering can read it. - **AgentRunStarted read model** — server-side projection of `AgentRunStarted` adds a `Vendor` column / property. Used by the dashboard to render a vendor badge on the agent card. Migration path is whatever the server uses for additive read-model fields (existing rows backfill to `"claude"` since they predate Codex). Ordering for paired PR rollout (staging): -1. Land the server PR first. With both new fields defaulted to `"claude"` (DTO and `RequestPermission` overload), existing daemons keep working unchanged — they invoke the four-arg hub method and the server fills the fifth as `"claude"`. The dashboard still defaults to `"claude"` for every launch, so the user-visible behaviour is identical to today. -2. Land the daemon PR. Newer daemons invoke the five-arg overload, sending an explicit vendor. Newer daemons connecting to a not-yet-upgraded server would fail (because old server has no five-arg overload) — but the rollout order avoids that window. +1. Land the server PR first. The new `RequestPermissionV2` exists alongside the old `RequestPermission`. The DTO `Vendor` field defaults to `"claude"`. Existing daemons keep invoking the old four-arg method and keep working unchanged. Dashboard still defaults to `"claude"` for every launch, so user-visible behaviour is identical to today. +2. Land the daemon PR. Newer daemons invoke `RequestPermissionV2`, sending an explicit vendor. Newer daemons connected to an old server would fail (no V2 method) — the rollout order avoids that window. 3. Flip the dashboard's launch dialog to expose the vendor selector. Until this step, hosted Codex remains gated behind the server-side feature even though both ends support it. -The reverse order (daemon first) is **not** safe because newer daemons need the server's new `RequestPermission` overload. Server-first is the only correct order for this PR pair. - -**Permission-request UI vendor awareness — open-question resolution.** Today's `PermissionRequestCard.razor:26` shows `Always Allow` unconditionally; that button maps to `applyPermissions` in the Claude response shape and is dropped by the Codex response builder (§6.3). Shipping hosted Codex without UI vendor awareness would silently no-op user clicks. Pulled into AI-68 scope: `PendingPermission` carries `Vendor`, the card hides `Always Allow` and `Update Input` for `vendor == "codex"`, and shows only the `Allow` / `Deny` pair. Tests on the server side cover the card render under both vendors. +The reverse order (daemon first) is **not** safe because newer daemons depend on `RequestPermissionV2` existing on the server. Server-first is the only correct order for this PR pair. Server-side test coverage paired with the rollout: -- `RequestPermission_four_arg_overload_defaults_vendor_to_claude` — regression for old-daemon compatibility (call the hub method with four args, assert the persisted row carries `vendor = "claude"`). -- `RequestPermission_five_arg_overload_persists_vendor_codex` — happy path for the new daemon. -- `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent` — wire-shape regression for old daemons that do not send the field. -- `PermissionRequestCard_hides_always_allow_when_vendor_is_codex` — UI vendor awareness. +- `RequestPermission_old_method_persists_vendor_claude` — regression for old-daemon compatibility (call the four-arg method via a real `HubConnection.InvokeAsync` wire test, assert the persisted row carries `vendor = "claude"`). +- `RequestPermissionV2_persists_explicit_vendor` — happy path for the new daemon, with parametrised cases for both `"claude"` and `"codex"`. +- `Old_daemon_simulator_calls_four_arg_method_and_receives_decision_unchanged` — pure-wire test simulating the `RemoteAgentService.cs:142` call shape, ensuring no SignalR arity-mismatch error. +- `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent`. +- `PermissionRequestCard_hides_always_allow_when_vendor_is_codex`. +- `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression). +- `AgentChatView_hides_yes_always_option_for_codex_vendor`. +- `ElicitationOption_factory_omits_always_variant_when_vendor_is_codex`. ## 4. `IHostedAgentLauncher` interface and impls @@ -390,7 +398,27 @@ internal static partial class CodexConfigWriter { ### 5.4 Failure semantics -Mirrors the Claude pre-trust path: every error path logs at Warning and continues. The orchestrator wraps `launcher.Prepare(ctx)` in a try/catch so a config-file glitch cannot block launch. If `trust_level` is not set, Codex's first run hits its own trust prompt — visible in the PTY relay, recoverable by re-launch. +**Scoped to this writer only.** `CodexConfigWriter` logs and swallows filesystem and TOML-parse errors — if the config file can't be written, the daemon-launched Codex will hit its own trust prompt on first run (visible in the PTY relay, recoverable by re-launch). This is best-effort and intentionally non-fatal. + +This is **not** a blanket "Prepare-swallows-everything" policy. The orchestrator's wrapper around `launcher.Prepare(ctx)` distinguishes two failure modes (§4.3): + +- **Fail-fast (re-throw to `LaunchFailed`):** `CodexHooksNotInstalledException` from the hook preflight. Without hooks the agent is broken end-to-end — silent continue is worse than a clear stop. +- **Best-effort (catch + warning log, continue):** filesystem errors from `OverlayDirectory`, TOML errors from `CodexConfigWriter`, debug logs about unused `Tools[]`. These do not break the agent; at worst they degrade trust-prompt UX or omit overlaid settings. + +Concretely, the orchestrator wraps `launcher.Prepare` like this (pseudocode): + +```csharp +try { + launcher.Prepare(ctx); +} catch (CodexHooksNotInstalledException ex) { + await _server.LaunchFailedAsync(agentId, ex.Message); + return; // abort launch +} catch (Exception ex) { + LogPrepareSoftFailure(ex, agentId); // warning log, launch continues +} +``` + +Any future fail-fast preconditions added to a launcher (e.g., a missing CLI binary detector) should follow the same pattern — define a typed exception that the orchestrator catches separately from the generic `Exception` swallow. ## 6. `LocalPermissionBridge` per-vendor routing @@ -402,7 +430,27 @@ Path expected: `/{token}/{vendor}/permission-request`. The handler asserts vendo ### 6.2 Server SignalR call -The bridge invokes `server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct)`. With cross-repo scope (§3.5), the hub method gains a `string vendor` parameter and the server persists it on the permission-request row so the dashboard can render vendor-appropriate prompts without joining back to the agent run. The daemon-side bridge always passes vendor (derived from the URL segment, validated in §6.1). +The bridge today invokes `server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct)`. With cross-repo scope (§3.5), the daemon-side wrapper `ServerConnection.RequestPermissionAsync` gains a `string vendor` parameter and **invokes the new `RequestPermissionV2` hub method** (the four-arg `RequestPermission` is kept server-side only for old-daemon compatibility). New signature: + +```csharp +public async Task RequestPermissionAsync( + string sessionId, + string? toolName, + JsonElement? toolInput, + JsonElement? suggestions, + string vendor, // NEW — flows in from LocalPermissionBridge URL segment + CancellationToken ct = default + ) => + await _hub.InvokeAsync( + "RequestPermissionV2", + sessionId, toolName, toolInput, suggestions, vendor, + ct + ); +``` + +The daemon-side bridge always passes vendor (derived from the URL segment, validated in §6.1). Old daemons running unmodified code continue to call the four-arg `RequestPermission` hub method, which the server preserves for compatibility. + +**Bridge test (`LocalPermissionBridgeTests`):** `Codex_path_invokes_server_with_vendor_codex` — drives a POST to `/{token}/codex/permission-request`, captures the `ServerConnection.RequestPermissionAsync` invocation via a test double, asserts the `vendor` parameter is `"codex"`. Paired test: `Claude_path_invokes_server_with_vendor_claude`. ### 6.3 Vendor-shaped responses @@ -675,18 +723,30 @@ No CLAUDE.md changes — existing "README sync" and AOT verification rules alrea Detail covered in §3.5. Server-side files touched (paths relative to `kapacitor-server` repo root): +**Modify** + - `src/Kurrent.Capacitor/Agents/DaemonCommands.cs` — `Vendor` on the server-side mirror of `LaunchAgentCommand`, defaulted to `"claude"` for old-daemon JSON-deserialisation compatibility; controller-boundary validation -- `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; `RequestPermission` at line 369 gains an **optional fifth `string vendor = "claude"` parameter** so old daemons calling with four args still work; persists vendor on the permission-request row +- `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; **keep the four-arg `RequestPermission` unchanged** (persists `vendor = "claude"`); **add new five-arg `RequestPermissionV2`** that persists explicit vendor on the permission-request row - `src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor` — vendor selector (default `"claude"`) -- `src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor` (line 26 today) — hide `Always Allow` and `Update Input` buttons when `PendingPermission.Vendor == "codex"`; show only `Allow` / `Deny` -- `PendingPermission` read-model record — add `Vendor` field, populated from the new permission-request row column +- `src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor:26` — hide `Always Allow` and `Update Input` when `PendingPermission.Vendor == "codex"` +- `src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62` — hide `Yes, always` for codex-vendor permission elicitations +- `src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:13` — option-list factory omits the always-variant when vendor is codex +- `AgentChatContext` — surfaces `CurrentPermission.Vendor` for chat view conditionals +- `PendingPermission` read-model record — add `Vendor` field +- SignalR `PermissionRequested` push payload — add `Vendor` field +- `InterruptIssued` event — add `Vendor` field - `AgentRunStarted` read-model projection — add `Vendor` column/property; existing rows backfill to `"claude"` -- Server-side tests: - - `RequestPermission_four_arg_overload_defaults_vendor_to_claude` (old-daemon compatibility) - - `RequestPermission_five_arg_overload_persists_vendor_codex` (new daemon happy path) - - `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent` - - `PermissionRequestCard_hides_always_allow_when_vendor_is_codex` - - `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression) + +**Server-side tests** + +- `RequestPermission_old_method_persists_vendor_claude` — wire-level test via real `HubConnection.InvokeAsync` calling the four-arg method, asserts persisted row has `vendor = "claude"` (matches the pattern of `SendTranscriptBatchWireTests.cs:15`) +- `RequestPermissionV2_persists_explicit_vendor` — parametrised over `"claude"` / `"codex"` +- `Old_daemon_simulator_invokes_four_arg_method_and_receives_decision` — pure wire test using a SignalR client harness that emits the `RemoteAgentService.cs:142` shape (four positional args, no `vendor`) and asserts no arity-mismatch error +- `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent` +- `PermissionRequestCard_hides_always_allow_when_vendor_is_codex` +- `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression) +- `AgentChatView_hides_yes_always_option_for_codex_vendor` +- `ElicitationOption_factory_omits_always_variant_when_vendor_is_codex` Tracked in the same Linear issue (AI-68); shipped as a paired PR per the §3.5 rollout ordering (server first). From a0fcc505ca205230c51f61ba28b4e1dce78e9968 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 18:49:20 +0200 Subject: [PATCH 05/36] [AI-68] docs: address fourth-round review on hosted-Codex design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four follow-ups from fourth review: - PermissionRequested SignalR broadcast hits the same arity trap as RequestPermission. Spec switches to a dual-broadcast: keep the existing five-arg PermissionRequested unchanged for old clients, add a sibling six-arg PermissionRequestedV2 that includes vendor. RemoteAgentService subscribes to both. New wire-regression tests cover both shapes firing on the same trigger. - InterruptIssued comes from the external Kurrent.Agent.Schema package and is not editable here. Vendor is carried on Capacitor-owned ClaudeCodePermissionExtension instead (ClaudeCodeExtensions.cs:107). §3.5 + §10.2 updated to reflect the extension as the carrier. - IHostedAgentLauncher.Prepare XML doc rewritten to acknowledge both failure modes — filesystem/parse errors swallowed inside the launcher, typed preflight exceptions allowed to propagate to LaunchFailed — pointing to §5.4's wrapper shape for the orchestrator-side catch ordering. - Permission-elicitation UI surfaces corrected to match the actual code shape: PendingPermission lives in ElicitationOption.cs (add Vendor field), AgentChatView's "Yes, always" is hardcoded markup (Razor-render conditional), AgentChatContext exposes scalar fields (add PermissionVendor, not a nested CurrentPermission object). Dropped the misnamed ElicitationOption_factory test. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-14-ai-68-codex-hosted-agents-design.md | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md index ceae36048b..f8bd29e7aa 100644 --- a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md +++ b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md @@ -139,8 +139,10 @@ Server files touched (paths relative to `kapacitor-server` repo root): - **`src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor:26`** — hide `Always Allow` and `Update Input` buttons when `PendingPermission.Vendor == "codex"`; show only `Allow` / `Deny`. - **`src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62`** — the active-chat input renders `Yes, always` for permission elicitations; hide it when `AgentChatContext.CurrentPermission.Vendor == "codex"`. - **`src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:13`** — the "always" option is Claude-only. The option list construction site (wherever `AgentChatContext` builds the elicitation options from a `PendingPermission`) filters the always-variant when vendor is codex. -- **`InterruptIssued` event / `PendingPermission` record / SignalR `PermissionRequested` payload** — all three need a `Vendor` field. The daemon's `LocalPermissionBridge` already extracts vendor from the URL segment (§6.1); the server reads it from the V2 hub call and stamps it on the persisted row, the read-model record, and the dashboard push. -- **`AgentChatContext`** — exposes vendor on the current permission state so the chat view's conditional rendering can read it. +- **SignalR `PermissionRequested` broadcast** — same SignalR-arity trap as `RequestPermission` (clients today subscribe with five args at `SessionHookHandlers.cs:1083` / `RemoteAgentService.cs:27`). Adding a sixth `vendor` argument silently breaks old UI / mobile clients that haven't been upgraded. **Keep the existing `PermissionRequested(...)` broadcast unchanged** and **add a new `PermissionRequestedV2(...)` broadcast that includes `vendor`**, fired on the same hub on the same events. The server dual-broadcasts (one event each) for the duration of the rollout — old clients consume V1 and assume `vendor = "claude"` as default; new clients prefer V2 and ignore V1. +- **`InterruptIssued`** lives in the external `Kurrent.Agent.Schema` package (referenced from `Kurrent.Capacitor.Core.csproj:24`) and is not editable from this repo. Vendor is carried as a **Capacitor-owned extension field** on `ClaudeCodePermissionExtension` (today at `ClaudeCodeExtensions.cs:107`), which is the existing pattern for Capacitor-specific data riding on schema-typed events. Daemon hook handlers populate the extension; server projection persists it; the dashboard reads it through the same `PendingPermission` field. +- **`PendingPermission` record** (today in `ElicitationOption.cs`) — add `Vendor` field, populated server-side from the `ClaudeCodePermissionExtension` payload above. +- **`AgentChatContext`** (today at `AgentChatContext.cs:28`) — exposes scalar permission fields. Add `PermissionVendor` (matching the existing flat shape — no nested `CurrentPermission` object) so the chat view's conditional rendering has a single property to bind against. - **AgentRunStarted read model** — server-side projection of `AgentRunStarted` adds a `Vendor` column / property. Used by the dashboard to render a vendor badge on the agent card. Migration path is whatever the server uses for additive read-model fields (existing rows backfill to `"claude"` since they predate Codex). Ordering for paired PR rollout (staging): @@ -156,11 +158,14 @@ Server-side test coverage paired with the rollout: - `RequestPermission_old_method_persists_vendor_claude` — regression for old-daemon compatibility (call the four-arg method via a real `HubConnection.InvokeAsync` wire test, assert the persisted row carries `vendor = "claude"`). - `RequestPermissionV2_persists_explicit_vendor` — happy path for the new daemon, with parametrised cases for both `"claude"` and `"codex"`. - `Old_daemon_simulator_calls_four_arg_method_and_receives_decision_unchanged` — pure-wire test simulating the `RemoteAgentService.cs:142` call shape, ensuring no SignalR arity-mismatch error. +- `PermissionRequested_old_event_still_fires_with_five_args` — regression for old clients (subscribe via SignalR with five-arg signature, assert the event still fires on the same hub events as today). +- `PermissionRequestedV2_fires_with_vendor_arg` — new clients subscribe with six args and receive vendor. +- `Server_dual_broadcasts_old_and_new_event_on_same_trigger` — single permission request fires both `PermissionRequested` (V1, no vendor) and `PermissionRequestedV2` (with vendor); old + new clients both receive their respective shape. +- `ClaudeCodePermissionExtension_carries_vendor_field` — the extension model serialises and deserialises the new `Vendor` field round-trip (mirrors any existing `ClaudeCodeExtensions` test pattern). - `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent`. - `PermissionRequestCard_hides_always_allow_when_vendor_is_codex`. - `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression). -- `AgentChatView_hides_yes_always_option_for_codex_vendor`. -- `ElicitationOption_factory_omits_always_variant_when_vendor_is_codex`. +- `AgentChatView_hides_yes_always_option_when_pending_permission_vendor_is_codex` — the "Yes, always" control is hardcoded in `AgentChatView`; this is a Razor-render test, not an `ElicitationOption` unit test. ## 4. `IHostedAgentLauncher` interface and impls @@ -184,8 +189,16 @@ internal interface IHostedAgentLauncher { /// • Pre-trust the worktree path in the vendor's config file /// • Write any vendor-specific config (MCP, etc.) /// • Merge dialog-selected tools into vendor-specific permission shape - /// Best-effort: implementations swallow filesystem errors and log; they do - /// NOT throw, so launch never blocks on a settings-overlay glitch. + /// • Run fail-fast preflight checks (e.g. required CLI hooks installed) + /// Two failure modes are supported and the orchestrator distinguishes them: + /// • Filesystem / parse errors are swallowed inside the launcher with a + /// warning log so a settings-overlay glitch never blocks launch. + /// • Typed preflight exceptions (e.g. CodexHooksNotInstalledException) + /// propagate out and the orchestrator converts them into LaunchFailed + /// with the exception's user-facing message. Use these sparingly — only + /// for conditions where letting the launch continue would yield a + /// broken agent the user couldn't diagnose. + /// See §5.4 for the wrapper shape and catch ordering. /// void Prepare(LauncherContext ctx); @@ -727,14 +740,14 @@ Detail covered in §3.5. Server-side files touched (paths relative to `kapacitor - `src/Kurrent.Capacitor/Agents/DaemonCommands.cs` — `Vendor` on the server-side mirror of `LaunchAgentCommand`, defaulted to `"claude"` for old-daemon JSON-deserialisation compatibility; controller-boundary validation - `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; **keep the four-arg `RequestPermission` unchanged** (persists `vendor = "claude"`); **add new five-arg `RequestPermissionV2`** that persists explicit vendor on the permission-request row +- `src/Kurrent.Capacitor/Sessions/SessionHookHandlers.cs:1083` — **keep the five-arg `PermissionRequested` broadcast unchanged** (back-compat for old UI / mobile clients); **add a sibling six-arg `PermissionRequestedV2` broadcast** that includes vendor. Both events fire on the same trigger for the duration of the rollout. Populate vendor from the new `ClaudeCodePermissionExtension.Vendor` extension field. +- `src/Kurrent.Capacitor.Core/Services/RemoteAgentService.cs:27` — subscribe to `PermissionRequestedV2` alongside the existing `PermissionRequested` subscription. New code path consumes V2; the old subscription stays for now so an external client running an older `RemoteAgentService` build doesn't break. +- `src/Kurrent.Capacitor.Core/ClaudeCodeExtensions.cs:107` — add a `Vendor` field to `ClaudeCodePermissionExtension`. This is the Capacitor-owned carrier for vendor (the underlying `InterruptIssued` schema event lives in the external `Kurrent.Agent.Schema` package and is not editable here) +- `src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:3` — add `Vendor` to `PendingPermission` +- `src/Kurrent.Capacitor.Core/Models/AgentChatContext.cs:28` — add `PermissionVendor` scalar field (matches the existing flat shape; not a nested object) - `src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor` — vendor selector (default `"claude"`) - `src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor:26` — hide `Always Allow` and `Update Input` when `PendingPermission.Vendor == "codex"` -- `src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62` — hide `Yes, always` for codex-vendor permission elicitations -- `src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:13` — option-list factory omits the always-variant when vendor is codex -- `AgentChatContext` — surfaces `CurrentPermission.Vendor` for chat view conditionals -- `PendingPermission` read-model record — add `Vendor` field -- SignalR `PermissionRequested` push payload — add `Vendor` field -- `InterruptIssued` event — add `Vendor` field +- `src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62` — read `AgentChatContext.PermissionVendor`; hide the hardcoded `Yes, always` control for codex vendor - `AgentRunStarted` read-model projection — add `Vendor` column/property; existing rows backfill to `"claude"` **Server-side tests** @@ -742,11 +755,14 @@ Detail covered in §3.5. Server-side files touched (paths relative to `kapacitor - `RequestPermission_old_method_persists_vendor_claude` — wire-level test via real `HubConnection.InvokeAsync` calling the four-arg method, asserts persisted row has `vendor = "claude"` (matches the pattern of `SendTranscriptBatchWireTests.cs:15`) - `RequestPermissionV2_persists_explicit_vendor` — parametrised over `"claude"` / `"codex"` - `Old_daemon_simulator_invokes_four_arg_method_and_receives_decision` — pure wire test using a SignalR client harness that emits the `RemoteAgentService.cs:142` shape (four positional args, no `vendor`) and asserts no arity-mismatch error +- `PermissionRequested_old_event_still_fires_with_five_args` — old client subscribes via SignalR with five-arg handler, asserts the event still fires on the same hub events as today +- `PermissionRequestedV2_fires_with_vendor_arg` — new client subscribes with six args, receives vendor +- `Server_dual_broadcasts_old_and_new_event_on_same_trigger` +- `ClaudeCodePermissionExtension_carries_vendor_field` - `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent` - `PermissionRequestCard_hides_always_allow_when_vendor_is_codex` - `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression) -- `AgentChatView_hides_yes_always_option_for_codex_vendor` -- `ElicitationOption_factory_omits_always_variant_when_vendor_is_codex` +- `AgentChatView_hides_yes_always_option_when_pending_permission_vendor_is_codex` Tracked in the same Linear issue (AI-68); shipped as a paired PR per the §3.5 rollout ordering (server first). From b549d302ea3e13b3abc99e81118efdc55451d23d Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 18:54:44 +0200 Subject: [PATCH 06/36] [AI-68] docs: drop back-compat layers; vendor is a required field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the V2 hub method, dual SignalR broadcast, and nullable-default workarounds added in prior amendments. Staging is the only environment and the deployment is single-controlled — server, daemon, and clients upgrade together as part of this Linear issue. The compatibility layers were carrying rollout-window complexity for a window that does not exist in practice. Changes: - §3.1 LaunchAgentCommand.Vendor is a required positional parameter (no C# default). Required positionals moved before optional ones to satisfy C# language rules. - §3.5 RequestPermission bumped to five args (adds vendor); no V2 alias. - §3.5 PermissionRequested broadcast bumped to six args (adds vendor); no V2 alias, no dual-broadcast. - §3.5 DaemonCommands.LaunchAgentCommand.Vendor required (no "claude" default). Old-daemon JSON deserialisation compat language removed. - §6.2 daemon-side ServerConnection.RequestPermissionAsync invokes plain RequestPermission (not RequestPermissionV2). - §10.2 server file list and tests pruned: dropped Old_daemon_simulator, PermissionRequested_old_event_still_fires, Server_dual_broadcasts, LaunchAgentCommand_deserialises_with_default_vendor. Added LaunchAgentCommand_round_trips_required_vendor_field and LaunchAgentCommand_rejects_unknown_vendor_at_controller_boundary. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-14-ai-68-codex-hosted-agents-design.md | 98 ++++++++----------- 1 file changed, 43 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md index f8bd29e7aa..be540bcad9 100644 --- a/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md +++ b/docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md @@ -75,15 +75,17 @@ public readonly record struct LaunchAgentCommand( string RepoPath, string[]? Tools, string[]? AttachmentIds, + string Vendor, // NEW — required, no default. Forces every + // call site to be explicit; no default that + // pretends the field is optional. LaunchKind Kind = LaunchKind.Default, ReviewLaunchInfo? Review = null, - string? BaseRef = null, - string Vendor = "claude" // NEW — required on wire; the default exists for - // C# call-site ergonomics. Server always sends - // explicit vendor. + string? BaseRef = null ); ``` +(Required positional moves before optional positionals for C# language requirements.) + Daemon validates at the top of `HandleLaunchAgent`: ```csharp @@ -126,46 +128,35 @@ public string CodexPath { get; set; } = "codex"; // NEW ### 3.5 Server-side wire-contract changes (kapacitor-server repo) -AI-68 spans both repos. Daemon-side changes are not independently shippable — without server-side propagation, no user can ever request `vendor: codex` from the dashboard. The server PR lands paired with this one; this section documents the wire contract this design assumes the server will honour, so daemon and server can be implemented in parallel. +AI-68 spans both repos. Daemon-side changes are not independently shippable — without server-side propagation, no user can ever request `vendor: codex` from the dashboard. The server PR lands paired with this one. -**SignalR arity constraint (important).** `SendTranscriptBatchWireTests.cs:15` and `RemoteAgentService.cs:142` document explicitly: SignalR's `HubConnection.InvokeAsync` dispatches by exact arity, not by named parameters, and does NOT backfill optional values when an old caller invokes a new method signature. Adding an optional fifth parameter to an existing hub method silently breaks old daemons. The only safe pattern for adding fields to an existing hub method in this repo is **a new method name**. +**No back-compat layers.** Staging is the only environment, and the deployment is fully controlled — server, daemon, and clients are upgraded together as part of this Linear issue. Old-client / old-daemon compatibility shims (V2 hub methods, dual broadcasts, nullable defaults that pretend a required field is optional) are intentionally not in scope: they would carry rollout-window complexity for a window that does not exist in practice. SignalR arity changes are coordinated by shipping all three components in lockstep. Server files touched (paths relative to `kapacitor-server` repo root): -- **`src/Kurrent.Capacitor/Agents/DaemonCommands.cs`** — the server-side mirror of `LaunchAgentCommand`. Add the `string Vendor` field, defaulted to `"claude"` for SignalR JSON-deserialisation compatibility with older daemons that do not send the field. Reject unknown values at the controller boundary so an invalid value never reaches the daemon. -- **`src/Kurrent.Capacitor/Sessions/CapacitorHub.cs`** — the `LaunchAgent` hub method (and any HTTP entry point that ultimately produces a `DaemonCommands.LaunchAgent`) accepts vendor from the request DTO and forwards it through. **Keep the existing four-arg `RequestPermission(sessionId, toolName, toolInput, suggestions)` hub method unchanged** so older daemons continue working. **Add a new five-arg hub method `RequestPermissionV2(sessionId, toolName, toolInput, suggestions, vendor)`** that persists vendor on the permission-request row. The old method, when invoked, persists `vendor = "claude"` so older daemons keep behaving exactly as today. -- **`src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor`** — vendor selector in the launch dialog (radio buttons or dropdown — UX choice). Default `"claude"`. The selection populates the new field on the launch DTO. -- **Permission UI surfaces** — three places need vendor awareness, all driven by adding `Vendor` to the `PendingPermission` read-model record and to the SignalR `PermissionRequested` payload pushed to the dashboard: +- **`src/Kurrent.Capacitor/Agents/DaemonCommands.cs`** — the server-side mirror of `LaunchAgentCommand`. Add the required `string Vendor` field (no default). Reject unknown values at the controller boundary so an invalid value never reaches the daemon. +- **`src/Kurrent.Capacitor/Sessions/CapacitorHub.cs`** — the `LaunchAgent` hub method accepts vendor from the request DTO and forwards it through. **`RequestPermission` becomes five-arg** (`sessionId, toolName, toolInput, suggestions, vendor`). No V2 alias. Server persists vendor on the permission-request row. +- **`src/Kurrent.Capacitor/Sessions/SessionHookHandlers.cs:1083`** — **`PermissionRequested` broadcast becomes six-arg** (existing fields plus `vendor`). No V2 alias. +- **`src/Kurrent.Capacitor.Core/Services/RemoteAgentService.cs:27`** — subscription updates to six-arg `PermissionRequested` handler shape. +- **`src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor`** — vendor selector in the launch dialog (radio buttons or dropdown — UX choice). Default `"claude"`. The selection populates the required `Vendor` field on the launch DTO. +- **Permission UI surfaces** — three places need vendor awareness, all driven by `Vendor` on `PendingPermission` and on the SignalR `PermissionRequested` payload: - **`src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor:26`** — hide `Always Allow` and `Update Input` buttons when `PendingPermission.Vendor == "codex"`; show only `Allow` / `Deny`. - - **`src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62`** — the active-chat input renders `Yes, always` for permission elicitations; hide it when `AgentChatContext.CurrentPermission.Vendor == "codex"`. - - **`src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:13`** — the "always" option is Claude-only. The option list construction site (wherever `AgentChatContext` builds the elicitation options from a `PendingPermission`) filters the always-variant when vendor is codex. -- **SignalR `PermissionRequested` broadcast** — same SignalR-arity trap as `RequestPermission` (clients today subscribe with five args at `SessionHookHandlers.cs:1083` / `RemoteAgentService.cs:27`). Adding a sixth `vendor` argument silently breaks old UI / mobile clients that haven't been upgraded. **Keep the existing `PermissionRequested(...)` broadcast unchanged** and **add a new `PermissionRequestedV2(...)` broadcast that includes `vendor`**, fired on the same hub on the same events. The server dual-broadcasts (one event each) for the duration of the rollout — old clients consume V1 and assume `vendor = "claude"` as default; new clients prefer V2 and ignore V1. -- **`InterruptIssued`** lives in the external `Kurrent.Agent.Schema` package (referenced from `Kurrent.Capacitor.Core.csproj:24`) and is not editable from this repo. Vendor is carried as a **Capacitor-owned extension field** on `ClaudeCodePermissionExtension` (today at `ClaudeCodeExtensions.cs:107`), which is the existing pattern for Capacitor-specific data riding on schema-typed events. Daemon hook handlers populate the extension; server projection persists it; the dashboard reads it through the same `PendingPermission` field. -- **`PendingPermission` record** (today in `ElicitationOption.cs`) — add `Vendor` field, populated server-side from the `ClaudeCodePermissionExtension` payload above. -- **`AgentChatContext`** (today at `AgentChatContext.cs:28`) — exposes scalar permission fields. Add `PermissionVendor` (matching the existing flat shape — no nested `CurrentPermission` object) so the chat view's conditional rendering has a single property to bind against. -- **AgentRunStarted read model** — server-side projection of `AgentRunStarted` adds a `Vendor` column / property. Used by the dashboard to render a vendor badge on the agent card. Migration path is whatever the server uses for additive read-model fields (existing rows backfill to `"claude"` since they predate Codex). - -Ordering for paired PR rollout (staging): - -1. Land the server PR first. The new `RequestPermissionV2` exists alongside the old `RequestPermission`. The DTO `Vendor` field defaults to `"claude"`. Existing daemons keep invoking the old four-arg method and keep working unchanged. Dashboard still defaults to `"claude"` for every launch, so user-visible behaviour is identical to today. -2. Land the daemon PR. Newer daemons invoke `RequestPermissionV2`, sending an explicit vendor. Newer daemons connected to an old server would fail (no V2 method) — the rollout order avoids that window. -3. Flip the dashboard's launch dialog to expose the vendor selector. Until this step, hosted Codex remains gated behind the server-side feature even though both ends support it. - -The reverse order (daemon first) is **not** safe because newer daemons depend on `RequestPermissionV2` existing on the server. Server-first is the only correct order for this PR pair. - -Server-side test coverage paired with the rollout: - -- `RequestPermission_old_method_persists_vendor_claude` — regression for old-daemon compatibility (call the four-arg method via a real `HubConnection.InvokeAsync` wire test, assert the persisted row carries `vendor = "claude"`). -- `RequestPermissionV2_persists_explicit_vendor` — happy path for the new daemon, with parametrised cases for both `"claude"` and `"codex"`. -- `Old_daemon_simulator_calls_four_arg_method_and_receives_decision_unchanged` — pure-wire test simulating the `RemoteAgentService.cs:142` call shape, ensuring no SignalR arity-mismatch error. -- `PermissionRequested_old_event_still_fires_with_five_args` — regression for old clients (subscribe via SignalR with five-arg signature, assert the event still fires on the same hub events as today). -- `PermissionRequestedV2_fires_with_vendor_arg` — new clients subscribe with six args and receive vendor. -- `Server_dual_broadcasts_old_and_new_event_on_same_trigger` — single permission request fires both `PermissionRequested` (V1, no vendor) and `PermissionRequestedV2` (with vendor); old + new clients both receive their respective shape. -- `ClaudeCodePermissionExtension_carries_vendor_field` — the extension model serialises and deserialises the new `Vendor` field round-trip (mirrors any existing `ClaudeCodeExtensions` test pattern). -- `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent`. + - **`src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62`** — the active-chat input has a hardcoded `Yes, always` control for permission elicitations; hide it when `AgentChatContext.PermissionVendor == "codex"` (Razor-render conditional, not an option-factory change). + - **`src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:3`** — add `Vendor` field to `PendingPermission` (this file owns the record). +- **`InterruptIssued`** lives in the external `Kurrent.Agent.Schema` package (referenced from `Kurrent.Capacitor.Core.csproj:24`) and is not editable from this repo. Vendor is carried as a **Capacitor-owned extension field** on `ClaudeCodePermissionExtension` (today at `ClaudeCodeExtensions.cs:107`). Daemon hook handlers populate the extension; server projection persists it; the dashboard reads it through `PendingPermission.Vendor`. +- **`AgentChatContext`** (today at `AgentChatContext.cs:28`) — exposes scalar permission fields. Add `PermissionVendor` (matching the existing flat shape — no nested `CurrentPermission` object). +- **AgentRunStarted read model** — projection adds `Vendor`. Existing rows backfill to `"claude"` since they predate Codex. + +Server-side test coverage: + +- `RequestPermission_five_arg_call_persists_explicit_vendor` — parametrised over `"claude"` / `"codex"`. +- `PermissionRequested_six_arg_broadcast_includes_vendor` — subscribe with the new six-arg shape; assert vendor present. +- `LaunchAgentCommand_round_trips_required_vendor_field` — wire-shape regression for the DTO. +- `LaunchAgentCommand_rejects_unknown_vendor_at_controller_boundary`. +- `ClaudeCodePermissionExtension_carries_vendor_field` — round-trip on the extension model. - `PermissionRequestCard_hides_always_allow_when_vendor_is_codex`. - `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression). -- `AgentChatView_hides_yes_always_option_when_pending_permission_vendor_is_codex` — the "Yes, always" control is hardcoded in `AgentChatView`; this is a Razor-render test, not an `ElicitationOption` unit test. +- `AgentChatView_hides_yes_always_option_when_pending_permission_vendor_is_codex` — Razor-render test on the hardcoded markup. ## 4. `IHostedAgentLauncher` interface and impls @@ -443,7 +434,7 @@ Path expected: `/{token}/{vendor}/permission-request`. The handler asserts vendo ### 6.2 Server SignalR call -The bridge today invokes `server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct)`. With cross-repo scope (§3.5), the daemon-side wrapper `ServerConnection.RequestPermissionAsync` gains a `string vendor` parameter and **invokes the new `RequestPermissionV2` hub method** (the four-arg `RequestPermission` is kept server-side only for old-daemon compatibility). New signature: +The bridge today invokes `server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct)`. With cross-repo scope (§3.5), the daemon-side wrapper `ServerConnection.RequestPermissionAsync` gains a required `string vendor` parameter and the hub method `RequestPermission` is bumped to five args server-side (no V2 alias). New signature: ```csharp public async Task RequestPermissionAsync( @@ -455,13 +446,13 @@ public async Task RequestPermissionAsync( CancellationToken ct = default ) => await _hub.InvokeAsync( - "RequestPermissionV2", + "RequestPermission", sessionId, toolName, toolInput, suggestions, vendor, ct ); ``` -The daemon-side bridge always passes vendor (derived from the URL segment, validated in §6.1). Old daemons running unmodified code continue to call the four-arg `RequestPermission` hub method, which the server preserves for compatibility. +The daemon-side bridge always passes vendor (derived from the URL segment, validated in §6.1). **Bridge test (`LocalPermissionBridgeTests`):** `Codex_path_invokes_server_with_vendor_codex` — drives a POST to `/{token}/codex/permission-request`, captures the `ServerConnection.RequestPermissionAsync` invocation via a test double, asserts the `vendor` parameter is `"codex"`. Paired test: `Claude_path_invokes_server_with_vendor_claude`. @@ -696,7 +687,7 @@ No CLAUDE.md changes — existing "README sync" and AOT verification rules alrea - `src/Kapacitor.Daemon/DaemonConfig.cs` — `CodexPath` - `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` — extract per-vendor blocks; route via `IHostedAgentLauncher`; vendor validation; `AgentInstance.Vendor`; failed-launch cleanup through launcher - `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` — URL split, vendor-shaped response builders, pass vendor to `RequestPermissionAsync` -- `src/Kapacitor.Daemon/Services/ServerConnection.cs` — `RequestPermissionAsync` signature gains `string vendor`; invokes the five-arg server overload +- `src/Kapacitor.Daemon/Services/ServerConnection.cs` — `RequestPermissionAsync` signature gains required `string vendor`; invokes the five-arg server hub method - `src/kapacitor/Commands/CodexHookCommand.cs` — `PermissionRequest` bridge branch (fail-closed, loopback validation) - `src/kapacitor/Commands/PermissionRequestCommand.cs` — **update post target to `daemonUrl + "/claude/permission-request"`** (line 63 today). Also extract loopback-validation helper for reuse by the Codex bridge bounce. - `src/kapacitor/Commands/PluginCommand.cs` — delegate `EntryReferencesKapacitorCodexHook` + `CodexHookEvents` to the new `Kapacitor.Core/CodexHooksParser.cs` (parser move, see Create list) @@ -738,13 +729,13 @@ Detail covered in §3.5. Server-side files touched (paths relative to `kapacitor **Modify** -- `src/Kurrent.Capacitor/Agents/DaemonCommands.cs` — `Vendor` on the server-side mirror of `LaunchAgentCommand`, defaulted to `"claude"` for old-daemon JSON-deserialisation compatibility; controller-boundary validation -- `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; **keep the four-arg `RequestPermission` unchanged** (persists `vendor = "claude"`); **add new five-arg `RequestPermissionV2`** that persists explicit vendor on the permission-request row -- `src/Kurrent.Capacitor/Sessions/SessionHookHandlers.cs:1083` — **keep the five-arg `PermissionRequested` broadcast unchanged** (back-compat for old UI / mobile clients); **add a sibling six-arg `PermissionRequestedV2` broadcast** that includes vendor. Both events fire on the same trigger for the duration of the rollout. Populate vendor from the new `ClaudeCodePermissionExtension.Vendor` extension field. -- `src/Kurrent.Capacitor.Core/Services/RemoteAgentService.cs:27` — subscribe to `PermissionRequestedV2` alongside the existing `PermissionRequested` subscription. New code path consumes V2; the old subscription stays for now so an external client running an older `RemoteAgentService` build doesn't break. -- `src/Kurrent.Capacitor.Core/ClaudeCodeExtensions.cs:107` — add a `Vendor` field to `ClaudeCodePermissionExtension`. This is the Capacitor-owned carrier for vendor (the underlying `InterruptIssued` schema event lives in the external `Kurrent.Agent.Schema` package and is not editable here) +- `src/Kurrent.Capacitor/Agents/DaemonCommands.cs` — required `Vendor` field on the server-side mirror of `LaunchAgentCommand` (no default); controller-boundary validation +- `src/Kurrent.Capacitor/Sessions/CapacitorHub.cs` — accept vendor on `LaunchAgent`; bump `RequestPermission` to five args including `vendor`; persist vendor on the permission-request row +- `src/Kurrent.Capacitor/Sessions/SessionHookHandlers.cs:1083` — bump `PermissionRequested` broadcast to six args including vendor; populate from `ClaudeCodePermissionExtension.Vendor` +- `src/Kurrent.Capacitor.Core/Services/RemoteAgentService.cs:27` — subscription handler updated to the new six-arg `PermissionRequested` shape +- `src/Kurrent.Capacitor.Core/ClaudeCodeExtensions.cs:107` — add `Vendor` to `ClaudeCodePermissionExtension` (Capacitor-owned carrier; the underlying `InterruptIssued` schema event lives in the external `Kurrent.Agent.Schema` package and is not editable here) - `src/Kurrent.Capacitor.Core/Chat/ElicitationOption.cs:3` — add `Vendor` to `PendingPermission` -- `src/Kurrent.Capacitor.Core/Models/AgentChatContext.cs:28` — add `PermissionVendor` scalar field (matches the existing flat shape; not a nested object) +- `src/Kurrent.Capacitor.Core/Models/AgentChatContext.cs:28` — add `PermissionVendor` scalar field (flat shape, no nested object) - `src/Kurrent.Capacitor.Shared/Components/Agents/LaunchAgentDialog.razor` — vendor selector (default `"claude"`) - `src/Kurrent.Capacitor.Shared/Components/PermissionRequestCard.razor:26` — hide `Always Allow` and `Update Input` when `PendingPermission.Vendor == "codex"` - `src/Kurrent.Capacitor.Shared/Components/Chat/AgentChatView.razor:62` — read `AgentChatContext.PermissionVendor`; hide the hardcoded `Yes, always` control for codex vendor @@ -752,19 +743,16 @@ Detail covered in §3.5. Server-side files touched (paths relative to `kapacitor **Server-side tests** -- `RequestPermission_old_method_persists_vendor_claude` — wire-level test via real `HubConnection.InvokeAsync` calling the four-arg method, asserts persisted row has `vendor = "claude"` (matches the pattern of `SendTranscriptBatchWireTests.cs:15`) -- `RequestPermissionV2_persists_explicit_vendor` — parametrised over `"claude"` / `"codex"` -- `Old_daemon_simulator_invokes_four_arg_method_and_receives_decision` — pure wire test using a SignalR client harness that emits the `RemoteAgentService.cs:142` shape (four positional args, no `vendor`) and asserts no arity-mismatch error -- `PermissionRequested_old_event_still_fires_with_five_args` — old client subscribes via SignalR with five-arg handler, asserts the event still fires on the same hub events as today -- `PermissionRequestedV2_fires_with_vendor_arg` — new client subscribes with six args, receives vendor -- `Server_dual_broadcasts_old_and_new_event_on_same_trigger` +- `RequestPermission_five_arg_call_persists_explicit_vendor` — parametrised over `"claude"` / `"codex"` +- `PermissionRequested_six_arg_broadcast_includes_vendor` +- `LaunchAgentCommand_round_trips_required_vendor_field` +- `LaunchAgentCommand_rejects_unknown_vendor_at_controller_boundary` - `ClaudeCodePermissionExtension_carries_vendor_field` -- `LaunchAgentCommand_deserialises_with_default_vendor_claude_when_field_absent` - `PermissionRequestCard_hides_always_allow_when_vendor_is_codex` - `PermissionRequestCard_renders_all_actions_when_vendor_is_claude` (regression) - `AgentChatView_hides_yes_always_option_when_pending_permission_vendor_is_codex` -Tracked in the same Linear issue (AI-68); shipped as a paired PR per the §3.5 rollout ordering (server first). +Tracked in the same Linear issue (AI-68); shipped as a paired PR. Server, daemon, and clients upgrade together — there is no rollout window because staging is single-controlled. ## 11. Out of scope From 34669e7349426d3773b3cecfb2fe77b8a95e68e9 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 19:07:56 +0200 Subject: [PATCH 07/36] [AI-68] docs: implementation plan for hosted Codex agents (CLI/daemon repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21-task TDD plan covering the §10.1 surface — Tomlyn dep + AOT gate, CodexHooksParser move to Core, CodexPaths computed properties, DaemonBridgeUrl loopback helper, CodexHooksNotInstalledException, CodexConfigWriter, LaunchAgentCommand/AgentRunStarted Vendor fields, ServerConnection vendor plumbing, Claude CLI hook URL migration, IHostedAgentLauncher abstraction, ClaudeLauncher extraction, AgentInstance.Vendor, orchestrator vendor routing, DaemonConfig.CodexPath, CodexLauncher (overlay + preflight + pre-trust + BuildArgs), LocalPermissionBridge per-vendor routing, Codex CLI hook bridge bounce (fail-closed), DI registration, README updates, final AOT gate. Cross-repo §10.2 server-side work is explicitly scoped out — paired PR plan lives in the kapacitor-server worktree. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-14-ai-68-codex-hosted-agents.md | 3034 +++++++++++++++++ 1 file changed, 3034 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-ai-68-codex-hosted-agents.md diff --git a/docs/superpowers/plans/2026-05-14-ai-68-codex-hosted-agents.md b/docs/superpowers/plans/2026-05-14-ai-68-codex-hosted-agents.md new file mode 100644 index 0000000000..c4337c0dc1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-ai-68-codex-hosted-agents.md @@ -0,0 +1,3034 @@ +# AI-68 — Hosted Codex agents on macOS and Linux (CLI/daemon repo) — 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:** Add `vendor: codex` support to the daemon's hosted-agent path so the dashboard can launch a Codex CLI under PTY supervision with the same overlay/pre-trust/permission-bridge guarantees Claude already has on macOS and Linux. + +**Architecture:** Extract a vendor-neutral `IHostedAgentLauncher` strategy out of `AgentOrchestrator` (`ClaudeLauncher` first, behavior-preserving), then add `CodexLauncher` alongside. `LocalPermissionBridge` splits its URL space by `{vendor}` segment. Codex pre-trust mutates `~/.codex/config.toml` via Tomlyn. The CLI Codex hook bounces `PermissionRequest` through the daemon when `KAPACITOR_DAEMON_URL` is set, fail-closed on any error. + +**Tech Stack:** .NET 10 NativeAOT, SignalR client, TUnit, WireMock.Net, Tomlyn (new dep for TOML round-trip), `System.Text.Json.Nodes`. + +**Spec:** `docs/superpowers/specs/2026-05-14-ai-68-codex-hosted-agents-design.md` (commit `b549d30`) + +**Scope:** This plan covers §10.1 of the spec — the CLI/daemon repo (this worktree). §10.2 (kapacitor-server repo: new required `Vendor` on `DaemonCommands.LaunchAgentCommand`, five-arg `RequestPermission` hub method, six-arg `PermissionRequested` broadcast, `ClaudeCodePermissionExtension.Vendor`, dashboard vendor selector, vendor-aware permission UI) ships as a paired PR with its own plan in that worktree. The two PRs land together; staging is single-controlled per the spec. + +**Out of scope (per spec §11):** Windows hosted Codex (AI-72), Codex PR-review launches (AI-632 — orchestrator fails fast), sandbox/approval selector in launch dialog (AI-633 — v1 hardcodes `workspace-write` + `on-request`). + +--- + +## File structure + +**Modify (CLI / daemon repo)** + +- `Directory.Packages.props` — add `Tomlyn` version +- `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj` — add `` +- `src/Kapacitor.Core/Models.cs` — add `Vendor` to `LaunchAgentCommand` + `AgentRunStarted`; update `JsonSerializerContext` +- `src/Kapacitor.Core/CodexPaths.cs` — convert `Home` / `Sessions` / `UserHooksJson` from init-once to computed expression-bodied properties +- `src/Kapacitor.Daemon/DaemonConfig.cs` — add `CodexPath` field +- `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` — route through `IHostedAgentLauncher`; add `AgentInstance.Vendor`; vendor-aware cleanup (both success and failed-launch paths) +- `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` — per-vendor URL routing; vendor-shaped response builders +- `src/Kapacitor.Daemon/Services/ServerConnection.cs` — `RequestPermissionAsync` gains required `string vendor` parameter; invokes the five-arg server hub method +- `src/kapacitor/Commands/CodexHookCommand.cs` — `HandlePermissionRequest` gains the bridge-bounce branch (fail-closed) +- `src/kapacitor/Commands/PermissionRequestCommand.cs` — post target updated to `/claude/permission-request`; loopback validation helper extracted +- `src/kapacitor/Commands/PluginCommand.cs` — delegate `EntryReferencesKapacitorCodexHook` + `CodexHookEvents` to `Kapacitor.Core/CodexHooksParser.cs` +- `src/kapacitor/Program.cs` — DI registration: `ClaudeLauncher`, `CodexLauncher`, vendor-keyed dictionary +- `README.md` — hosted Codex section, daemon config, codex-hook daemon-bridge note +- `src/Kapacitor.Core/Resources/help-codex-hook.txt` — daemon-bridge behaviour +- `test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs` — extend for `Vendor` +- `test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs` — extend for per-vendor URL + Claude regression +- `test/kapacitor.Tests.Unit/CodexHookCommandTests.cs` — extend for bridge-bounce branch +- `test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs` — re-target predicate to `CodexHooksParser` + +**Create (CLI / daemon repo)** + +- `src/Kapacitor.Core/CodexHooksParser.cs` — shared `~/.codex/hooks.json` parser (predicate + event list + `HasKapacitorHooksFor` helper) +- `src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs` — interface + `LauncherContext` + `LaunchArgs` +- `src/Kapacitor.Daemon/Services/ClaudeLauncher.cs` — extracted Claude impl (behavior-preserving) +- `src/Kapacitor.Daemon/Services/CodexLauncher.cs` — new Codex impl +- `src/Kapacitor.Daemon/Services/CodexConfigWriter.cs` — Tomlyn-based pre-trust writer +- `src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs` — typed preflight exception +- `src/kapacitor/Commands/DaemonBridgeUrl.cs` — shared loopback-validation helper +- `test/kapacitor.Tests.Unit/CodexHooksParserTests.cs` +- `test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs` +- `test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs` +- `test/kapacitor.Tests.Unit/CodexLauncherTests.cs` +- `test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs` + +--- + +## Tasks + +### Task 1: Add Tomlyn dependency and verify AOT baseline + +**Files:** +- Modify: `Directory.Packages.props` +- Modify: `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj` + +- [ ] **Step 1: Check current Tomlyn latest stable version** + +```bash +dotnet package search Tomlyn --exact-match +``` + +Note the latest stable version (likely `0.20.x` at time of writing). Use it in the next step. + +- [ ] **Step 2: Add Tomlyn to central package management** + +Add a `` line alphabetically in the `` of `Directory.Packages.props`: + +```xml + +``` + +(Substitute the version returned by step 1.) + +- [ ] **Step 3: Reference Tomlyn from the daemon project** + +In `src/Kapacitor.Daemon/Kapacitor.Daemon.csproj`, find the `` containing `` and add a new `` for package references (or extend an existing one): + +```xml + + + +``` + +- [ ] **Step 4: Restore packages** + +```bash +dotnet restore src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds; the new package shows in the lock file. + +- [ ] **Step 5: AOT publish baseline (both projects)** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | tee /tmp/aot-cli.log +dotnet publish src/Kapacitor.Daemon/Kapacitor.Daemon.csproj -c Release 2>&1 | tee /tmp/aot-daemon.log +grep -E 'IL[23][01][0-9]{2}' /tmp/aot-cli.log /tmp/aot-daemon.log +``` + +Expected: `grep` returns no matches. + +If matches appear: the design's §5.3 fallback (typed model via `[TomlSerializable]` source-gen context) is the resolution path. Do not proceed until clean. + +- [ ] **Step 6: Commit** + +```bash +git add Directory.Packages.props src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +git commit -m "[AI-68] deps: add Tomlyn for Codex config.toml pre-trust" +``` + +--- + +### Task 2: Extract `CodexHooksParser` to Kapacitor.Core + +The predicate `EntryReferencesKapacitorCodexHook` lives today on `PluginCommand` in the CLI project. The daemon's `CodexLauncher.Prepare` preflight (Task 18) needs it, but `Kapacitor.Daemon` only references `Kapacitor.Core`. Move the parsing/predicate logic into Core; keep file-write logic in `PluginCommand`. + +**Files:** +- Create: `src/Kapacitor.Core/CodexHooksParser.cs` +- Create: `test/kapacitor.Tests.Unit/CodexHooksParserTests.cs` +- Modify: `src/kapacitor/Commands/PluginCommand.cs` +- Modify: `test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs` + +- [ ] **Step 1: Write failing tests for the parser** + +Create `test/kapacitor.Tests.Unit/CodexHooksParserTests.cs`: + +```csharp +using System.Text.Json.Nodes; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class CodexHooksParserTests { + [Test] + public async Task EntryReferencesKapacitorCodexHook_returns_true_when_command_contains_marker() { + var entry = JsonNode.Parse("""{"hooks":[{"type":"command","command":"kapacitor codex-hook","timeout":30}]}"""); + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)).IsTrue(); + } + + [Test] + public async Task EntryReferencesKapacitorCodexHook_returns_false_when_command_does_not_match() { + var entry = JsonNode.Parse("""{"hooks":[{"type":"command","command":"echo hi","timeout":30}]}"""); + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)).IsFalse(); + } + + [Test] + public async Task EntryReferencesKapacitorCodexHook_returns_false_for_null() { + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(null)).IsFalse(); + } + + [Test] + public async Task HasKapacitorHooksFor_returns_true_when_all_events_have_kapacitor_entry() { + var root = JsonNode.Parse(""" + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """) as JsonObject; + var events = new[] { "SessionStart", "Stop", "PermissionRequest" }; + await Assert.That(CodexHooksParser.HasKapacitorHooksFor(root!, events)).IsTrue(); + } + + [Test] + public async Task HasKapacitorHooksFor_returns_false_when_one_event_missing() { + var root = JsonNode.Parse(""" + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"echo something else"}]}] + }} + """) as JsonObject; + var events = new[] { "SessionStart", "Stop", "PermissionRequest" }; + await Assert.That(CodexHooksParser.HasKapacitorHooksFor(root!, events)).IsFalse(); + } + + [Test] + public async Task CodexHookEvents_lists_all_six_events_in_canonical_order() { + await Assert.That(CodexHooksParser.CodexHookEvents).IsEquivalentTo( + new[] { "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop" } + ); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexHooksParserTests/*" +``` + +Expected: all five tests fail with "CodexHooksParser does not exist". + +- [ ] **Step 3: Implement `CodexHooksParser`** + +Create `src/Kapacitor.Core/CodexHooksParser.cs`: + +```csharp +using System.Text.Json.Nodes; + +namespace kapacitor; + +/// +/// Parsing helpers for ~/.codex/hooks.json (and <repo>/.codex/hooks.json +/// in project-scope installs). Shared by the CLI's plugin install --codex +/// command (which writes the file) and the daemon's CodexLauncher preflight +/// (which reads it before spawning a hosted Codex agent). +/// +public static class CodexHooksParser { + /// Hook event names Codex CLI emits. + public static readonly string[] CodexHookEvents = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "Stop" + ]; + + /// + /// Returns true if is a hooks.json group whose + /// hooks[].command contains kapacitor codex-hook. + /// + public static bool EntryReferencesKapacitorCodexHook(JsonNode? entry) { + if (entry?["hooks"] is not JsonArray hooks) return false; + + foreach (var hook in hooks) { + if (hook?["command"] is JsonValue jv && + jv.TryGetValue(out var cmd) && + cmd.Contains("kapacitor codex-hook")) { + return true; + } + } + + return false; + } + + /// + /// Returns true if every event in has at least one + /// hooks.json entry that invokes kapacitor codex-hook. + /// + public static bool HasKapacitorHooksFor(JsonObject root, IEnumerable events) { + if (root["hooks"] is not JsonObject hooks) return false; + + foreach (var evt in events) { + if (hooks[evt] is not JsonArray entries) return false; + + var any = false; + foreach (var entry in entries) { + if (EntryReferencesKapacitorCodexHook(entry)) { + any = true; + break; + } + } + + if (!any) return false; + } + + return true; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexHooksParserTests/*" +``` + +Expected: all five pass. + +- [ ] **Step 5: Refactor `PluginCommand` to delegate to the parser** + +In `src/kapacitor/Commands/PluginCommand.cs`: + +Replace the local `CodexHookEvents` static array and the `EntryReferencesKapacitorCodexHook` method with delegations: + +```csharp +// At top of file, replace the local CodexHookEvents declaration +static readonly string[] CodexHookEvents = CodexHooksParser.CodexHookEvents; +``` + +Delete the local `EntryReferencesKapacitorCodexHook` method body and replace all call sites with `CodexHooksParser.EntryReferencesKapacitorCodexHook(...)`. The `PluginCommand.cs:286` definition is removed. + +- [ ] **Step 6: Update `PluginCommandCodexTests`** + +In `test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs`, replace any test that directly invokes the moved predicate (`PluginCommand.EntryReferencesKapacitorCodexHook`) with `CodexHooksParser.EntryReferencesKapacitorCodexHook`. Keep tests covering install/remove file-write behavior in this file unchanged. + +- [ ] **Step 7: Run the full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/Kapacitor.Core/CodexHooksParser.cs \ + test/kapacitor.Tests.Unit/CodexHooksParserTests.cs \ + src/kapacitor/Commands/PluginCommand.cs \ + test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs +git commit -m "[AI-68] core: extract CodexHooksParser for daemon-side preflight reuse" +``` + +--- + +### Task 3: Convert `CodexPaths` properties from init-once to computed + +`CodexPaths.Home` / `Sessions` / `UserHooksJson` today are initialised once via field-initializer, which caches the resolved path forever. Tests that scope `HOME` after any caller initialises `CodexPaths` silently hit the real `~/.codex`. Switch to expression-bodied properties so `PathHelpers.HomeDirectory` is re-read each access. + +**Files:** +- Modify: `src/Kapacitor.Core/CodexPaths.cs` +- Create: `test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs` + +- [ ] **Step 1: Write failing isolation test** + +Create `test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs`: + +```csharp +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class CodexPathsHomeIsolationTests { + [Test] + public async Task Home_reflects_current_HOME_env_var() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexpaths-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + // CodexPaths.Home must re-read HOME on every access, not cache the + // value from first initialisation. + await Assert.That(CodexPaths.Home).IsEqualTo(Path.Combine(tmp.FullName, ".codex")); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Sessions_reflects_current_HOME_env_var() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexpaths-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + await Assert.That(CodexPaths.Sessions).IsEqualTo(Path.Combine(tmp.FullName, ".codex", "sessions")); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task UserHooksJson_reflects_current_HOME_env_var() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexpaths-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + await Assert.That(CodexPaths.UserHooksJson).IsEqualTo(Path.Combine(tmp.FullName, ".codex", "hooks.json")); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexPathsHomeIsolationTests/*" +``` + +Expected: tests may pass or fail depending on whether `PathHelpers.HomeDirectory` itself caches. If they pass on the init-once form, that's because the static initialisers run on first touch and `HOME` happens to match. Force the failure by touching `CodexPaths.Home` once at startup before changing `HOME`: + +If the tests pass already, add to each `try` block, before `SetEnvironmentVariable`: +```csharp +_ = CodexPaths.Home; // force static init under the original HOME +``` + +Re-run. Expected: now they fail because `CodexPaths.Home` retains the original HOME's resolution. + +- [ ] **Step 3: Convert properties to computed** + +In `src/Kapacitor.Core/CodexPaths.cs`, replace the init-once declarations: + +```csharp +public static string Home { get; } = Path.Combine(PathHelpers.HomeDirectory, ".codex"); +public static string Sessions { get; } = Path.Combine(Path.Combine(PathHelpers.HomeDirectory, ".codex"), "sessions"); +public static string UserHooksJson { get; } = Path.Combine(Path.Combine(PathHelpers.HomeDirectory, ".codex"), "hooks.json"); +``` + +With expression-bodied properties: + +```csharp +public static string Home => Path.Combine(PathHelpers.HomeDirectory, ".codex"); +public static string Sessions => Path.Combine(Home, "sessions"); +public static string UserHooksJson => Path.Combine(Home, "hooks.json"); +``` + +(Also simplifies the nested `Path.Combine` calls by reusing `Home`.) + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexPathsHomeIsolationTests/*" +``` + +Expected: all three pass. + +- [ ] **Step 5: Run the full unit test suite to confirm no regressions** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/Kapacitor.Core/CodexPaths.cs test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs +git commit -m "[AI-68] core: make CodexPaths computed so tests can scope HOME" +``` + +--- + +### Task 4: Extract `DaemonBridgeUrl` loopback-validation helper + +`PermissionRequestCommand.cs:70` today contains an inline check that `KAPACITOR_DAEMON_URL` is `http://127.0.0.1:...`. The Codex bridge bounce (Task 22) needs the same validation. Extract into a shared static helper in the CLI project. + +**Files:** +- Create: `src/kapacitor/Commands/DaemonBridgeUrl.cs` +- Create: `test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs` +- Modify: `src/kapacitor/Commands/PermissionRequestCommand.cs` + +- [ ] **Step 1: Write failing tests for the helper** + +Create `test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs`: + +```csharp +using kapacitor.Commands; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class DaemonBridgeUrlTests { + [Test] + public async Task TryParseLoopback_accepts_http_127_0_0_1() { + var ok = DaemonBridgeUrl.TryParseLoopback("http://127.0.0.1:54321/abc123", out var baseUrl); + await Assert.That(ok).IsTrue(); + await Assert.That(baseUrl).IsEqualTo("http://127.0.0.1:54321/abc123"); + } + + [Test] + public async Task TryParseLoopback_rejects_https() { + var ok = DaemonBridgeUrl.TryParseLoopback("https://127.0.0.1:54321/abc123", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_non_loopback_host() { + var ok = DaemonBridgeUrl.TryParseLoopback("http://example.com:54321/abc123", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_null() { + var ok = DaemonBridgeUrl.TryParseLoopback(null, out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_empty() { + var ok = DaemonBridgeUrl.TryParseLoopback("", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_malformed_uri() { + var ok = DaemonBridgeUrl.TryParseLoopback("not-a-url", out _); + await Assert.That(ok).IsFalse(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/DaemonBridgeUrlTests/*" +``` + +Expected: all six tests fail with "DaemonBridgeUrl does not exist". + +- [ ] **Step 3: Implement the helper** + +Create `src/kapacitor/Commands/DaemonBridgeUrl.cs`: + +```csharp +namespace kapacitor.Commands; + +/// +/// Loopback validation for KAPACITOR_DAEMON_URL. Both the Claude and +/// Codex permission-request hook CLI commands must refuse to POST permission +/// payloads to anything other than an HTTP loopback URL — non-loopback or +/// HTTPS values usually indicate a misconfigured environment variable, and +/// we don't want hook payloads leaving the loopback interface. +/// +public static class DaemonBridgeUrl { + /// + /// True when is a valid http://127.0.0.1:.../... + /// URL. Returns the parsed-and-normalised form via + /// (callers append /{vendor}/permission-request themselves). + /// + public static bool TryParseLoopback(string? daemonUrl, out string baseUrl) { + baseUrl = ""; + + if (string.IsNullOrWhiteSpace(daemonUrl)) return false; + if (!Uri.TryCreate(daemonUrl, UriKind.Absolute, out var uri)) return false; + if (uri.Scheme != "http") return false; + if (uri.Host != "127.0.0.1") return false; + + baseUrl = daemonUrl.TrimEnd('/'); + return true; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/DaemonBridgeUrlTests/*" +``` + +Expected: all six pass. + +- [ ] **Step 5: Replace the inline check in `PermissionRequestCommand`** + +In `src/kapacitor/Commands/PermissionRequestCommand.cs:70` (or wherever the inline validation lives), replace the loopback check with a call to `DaemonBridgeUrl.TryParseLoopback`. Behavior must be identical — same accept set, same reject set, same exit code on failure. + +Run existing Claude permission-request tests: + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/PermissionRequestCommand*" +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/kapacitor/Commands/DaemonBridgeUrl.cs \ + test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs \ + src/kapacitor/Commands/PermissionRequestCommand.cs +git commit -m "[AI-68] cli: extract DaemonBridgeUrl loopback helper for Codex reuse" +``` + +--- + +### Task 5: Define `CodexHooksNotInstalledException` + +Typed exception for `CodexLauncher.Prepare`'s preflight (Task 18). The orchestrator catches this type specifically to emit an actionable `LaunchFailed`; other exceptions from `Prepare` are swallowed best-effort. + +**Files:** +- Create: `src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs` + +- [ ] **Step 1: Create the exception** + +```csharp +namespace kapacitor.Daemon.Services; + +/// +/// Thrown by 's Prepare preflight when neither the +/// user-scope (~/.codex/hooks.json) nor project-scope +/// (<worktree>/.codex/hooks.json) hooks file has a +/// kapacitor codex-hook entry for SessionStart / Stop / PermissionRequest. +/// The orchestrator catches this type and emits with +/// the exception's message; the user sees an actionable instruction to run +/// kapacitor plugin install --codex. +/// +internal sealed class CodexHooksNotInstalledException : Exception { + public CodexHooksNotInstalledException(string message) : base(message) { } +} +``` + +- [ ] **Step 2: Build to verify no syntax issues** + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs +git commit -m "[AI-68] daemon: add CodexHooksNotInstalledException for preflight fail-fast" +``` + +--- + +### Task 6: Implement `CodexConfigWriter` (Tomlyn-based pre-trust) + +**Files:** +- Create: `src/Kapacitor.Daemon/Services/CodexConfigWriter.cs` +- Create: `test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs` + +- [ ] **Step 1: Write failing tests** + +Create `test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs`: + +```csharp +using kapacitor.Daemon.Services; +using Microsoft.Extensions.Logging.Abstractions; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using Tomlyn; +using Tomlyn.Model; + +namespace kapacitor.Tests.Unit; + +public class CodexConfigWriterTests { + static DirectoryInfo NewScopedHome() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexconfig-test-"); + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + return tmp; + } + + [Test] + public async Task Writes_initial_projects_table_when_config_toml_missing() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + try { + CodexConfigWriter.TrustWorktree("/tmp/some-worktree", NullLogger.Instance); + + var configPath = Path.Combine(CodexPaths.Home, "config.toml"); + await Assert.That(File.Exists(configPath)).IsTrue(); + + var root = Toml.ToModel(File.ReadAllText(configPath)); + var projects = (TomlTable)root["projects"]; + var entry = (TomlTable)projects["/tmp/some-worktree"]; + await Assert.That((string)entry["trust_level"]).IsEqualTo("trusted"); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Writes_to_fresh_home_creates_codex_directory() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + // Explicitly NOT pre-creating .codex + try { + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + var codexDir = Path.Combine(tmp.FullName, ".codex"); + await Assert.That(Directory.Exists(codexDir)).IsTrue(); + await Assert.That(File.Exists(Path.Combine(codexDir, "config.toml"))).IsTrue(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Adds_entry_to_existing_config_preserving_other_tables() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + File.WriteAllText(Path.Combine(codexDir, "config.toml"), """ + model = "gpt-5.5" + + [mcp_servers.linear] + url = "https://mcp.linear.app/mcp" + + [projects."/existing/path"] + trust_level = "trusted" + """); + + CodexConfigWriter.TrustWorktree("/tmp/new-wt", NullLogger.Instance); + + var root = Toml.ToModel(File.ReadAllText(Path.Combine(codexDir, "config.toml"))); + await Assert.That((string)root["model"]).IsEqualTo("gpt-5.5"); + var mcp = (TomlTable)((TomlTable)root["mcp_servers"])["linear"]; + await Assert.That((string)mcp["url"]).IsEqualTo("https://mcp.linear.app/mcp"); + + var projects = (TomlTable)root["projects"]; + await Assert.That((string)((TomlTable)projects["/existing/path"])["trust_level"]).IsEqualTo("trusted"); + await Assert.That((string)((TomlTable)projects["/tmp/new-wt"])["trust_level"]).IsEqualTo("trusted"); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Updates_trust_level_if_present_but_not_trusted() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + File.WriteAllText(Path.Combine(codexDir, "config.toml"), """ + [projects."/tmp/wt"] + trust_level = "ask" + """); + + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + var root = Toml.ToModel(File.ReadAllText(Path.Combine(codexDir, "config.toml"))); + var entry = (TomlTable)((TomlTable)root["projects"])["/tmp/wt"]; + await Assert.That((string)entry["trust_level"]).IsEqualTo("trusted"); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task No_op_when_trust_level_already_trusted() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + var configPath = Path.Combine(codexDir, "config.toml"); + File.WriteAllText(configPath, """ + [projects."/tmp/wt"] + trust_level = "trusted" + """); + var originalMtime = File.GetLastWriteTimeUtc(configPath); + + await Task.Delay(20); // ensure mtime resolution gap + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + await Assert.That(File.GetLastWriteTimeUtc(configPath)).IsEqualTo(originalMtime); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Atomic_rename_leaves_no_tmp_files() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + try { + CodexConfigWriter.TrustWorktree("/tmp/wt-1", NullLogger.Instance); + CodexConfigWriter.TrustWorktree("/tmp/wt-2", NullLogger.Instance); + + var codexDir = Path.Combine(tmp.FullName, ".codex"); + var leftover = Directory.GetFiles(codexDir).Where(f => Path.GetFileName(f).Contains(".tmp-")).ToList(); + await Assert.That(leftover).IsEmpty(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Concurrent_writers_serialise_safely() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + try { + var tasks = Enumerable.Range(0, 20) + .Select(i => Task.Run(() => CodexConfigWriter.TrustWorktree($"/tmp/wt-{i}", NullLogger.Instance))) + .ToArray(); + await Task.WhenAll(tasks); + + var configPath = Path.Combine(CodexPaths.Home, "config.toml"); + var root = Toml.ToModel(File.ReadAllText(configPath)); + var projects = (TomlTable)root["projects"]; + for (var i = 0; i < 20; i++) { + var entry = (TomlTable)projects[$"/tmp/wt-{i}"]; + await Assert.That((string)entry["trust_level"]).IsEqualTo("trusted"); + } + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Malformed_existing_config_is_skipped_not_overwritten() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var tmp = NewScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + var configPath = Path.Combine(codexDir, "config.toml"); + const string garbage = "{{{ not valid TOML"; + File.WriteAllText(configPath, garbage); + + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + // File untouched, no throw + await Assert.That(File.ReadAllText(configPath)).IsEqualTo(garbage); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexConfigWriterTests/*" +``` + +Expected: all eight tests fail with "CodexConfigWriter does not exist". + +- [ ] **Step 3: Implement `CodexConfigWriter`** + +Create `src/Kapacitor.Daemon/Services/CodexConfigWriter.cs`: + +```csharp +using Microsoft.Extensions.Logging; +using Tomlyn; +using Tomlyn.Model; + +namespace kapacitor.Daemon.Services; + +/// +/// Writes the per-worktree pre-trust entry into ~/.codex/config.toml: +/// +/// [projects."/abs/worktree/path"] +/// trust_level = "trusted" +/// +/// Tomlyn's TomlTable mode round-trips preserve all other top-level tables +/// (model, mcp_servers, plugins, marketplaces, ad-hoc user keys) but DO NOT +/// preserve user comments or original formatting. This is acceptable because +/// the file is daemon-managed and human edits are expected to be sparse. +/// +internal static class CodexConfigWriter { + static readonly Lock _writeLock = new(); + + public static void TrustWorktree(string worktreePath, ILogger logger) { + lock (_writeLock) { + var configPath = Path.Combine(CodexPaths.Home, "config.toml"); + + TomlTable root; + if (File.Exists(configPath)) { + try { + root = Toml.ToModel(File.ReadAllText(configPath)); + } catch (Exception ex) { + logger.LogWarning(ex, "Failed to parse {Path}; aborting pre-trust", configPath); + return; + } + } else { + root = new TomlTable(); + } + + if (root["projects"] is not TomlTable projects) { + projects = new TomlTable(); + root["projects"] = projects; + } + + if (projects[worktreePath] is not TomlTable entry) { + entry = new TomlTable(); + projects[worktreePath] = entry; + } + + var alreadyTrusted = entry["trust_level"] is string s && + string.Equals(s, "trusted", StringComparison.Ordinal); + + if (alreadyTrusted) return; + + entry["trust_level"] = "trusted"; + + try { + // First-time users have no ~/.codex; create it before the atomic rename. + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + WriteTomlAtomic(configPath, root); + } catch (Exception ex) { + logger.LogWarning(ex, "Failed to write {Path}; pre-trust not persisted", configPath); + } + } + } + + static void WriteTomlAtomic(string path, TomlTable root) { + var tmp = path + ".tmp-" + Environment.ProcessId + "-" + Guid.NewGuid().ToString("N"); + File.WriteAllText(tmp, Toml.FromModel(root)); + + try { + File.Move(tmp, path, overwrite: true); + } catch { + try { File.Delete(tmp); } catch { /* best-effort */ } + throw; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexConfigWriterTests/*" +``` + +Expected: all eight pass. + +- [ ] **Step 5: AOT verification gate** + +```bash +dotnet publish src/Kapacitor.Daemon/Kapacitor.Daemon.csproj -c Release 2>&1 | tee /tmp/aot-daemon.log +grep -E 'IL[23][01][0-9]{2}' /tmp/aot-daemon.log +``` + +Expected: no matches. If matches appear from Tomlyn usage, escalate to the spec §5.3 fallback (typed source-gen context) before continuing. + +- [ ] **Step 6: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/CodexConfigWriter.cs \ + test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs +git commit -m "[AI-68] daemon: CodexConfigWriter pre-trusts worktrees in ~/.codex/config.toml" +``` + +--- + +### Task 7: Add `Vendor` to `LaunchAgentCommand` and validate + +**Files:** +- Modify: `src/Kapacitor.Core/Models.cs` +- Modify: `test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs` + +- [ ] **Step 1: Write failing wire-format test** + +In `test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs` add: + +```csharp +[Test] +public async Task Vendor_field_round_trips_through_json_serializer() { + var cmd = new LaunchAgentCommand( + AgentId: "agent-1", + Prompt: null, + Model: "claude-sonnet-4-6", + Effort: null, + RepoPath: "/tmp/repo", + Tools: null, + AttachmentIds: null, + Vendor: "codex" + ); + + var json = JsonSerializer.Serialize(cmd, kapacitor.ModelsJsonContext.Default.LaunchAgentCommand); + var back = JsonSerializer.Deserialize(json, kapacitor.ModelsJsonContext.Default.LaunchAgentCommand); + + await Assert.That(back.Vendor).IsEqualTo("codex"); +} +``` + +(Use whatever `JsonSerializerContext` name matches the existing pattern in the file; substitute accordingly.) + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/LaunchAgentCommandWireFormatTests/Vendor_field_round_trips_through_json_serializer" +``` + +Expected: fails with "Vendor argument not found" or similar compile error. + +- [ ] **Step 3: Add `Vendor` to `LaunchAgentCommand`** + +In `src/Kapacitor.Core/Models.cs`, update the record: + +```csharp +public readonly record struct LaunchAgentCommand( + string AgentId, + string? Prompt, + string Model, + string? Effort, + string RepoPath, + string[]? Tools, + string[]? AttachmentIds, + string Vendor, + LaunchKind Kind = LaunchKind.Default, + ReviewLaunchInfo? Review = null, + string? BaseRef = null + ); +``` + +(Required positional `Vendor` moves before the optional positionals to satisfy C# syntax.) + +- [ ] **Step 4: Fix any compile errors at call sites** + +Build: + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Any call site that constructs `LaunchAgentCommand` positionally is now broken — fix by passing `vendor: "claude"` explicitly. (Server-side construction lives in the kapacitor-server repo and is not touched here.) + +- [ ] **Step 5: Run wire-format test to verify it passes** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/LaunchAgentCommandWireFormatTests/*" +``` + +Expected: all pass. + +- [ ] **Step 6: Add vendor validation in `AgentOrchestrator.HandleLaunchAgent`** + +In `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs`, near the top of `HandleLaunchAgent` (right after destructuring `cmd`), add: + +```csharp +if (cmd.Vendor is not ("claude" or "codex")) { + await _server.LaunchFailedAsync(cmd.AgentId, $"Unknown vendor: {cmd.Vendor}"); + return; +} +``` + +This is a placeholder validation — Task 14 wires it to the launcher dictionary. Don't reference `_launchers` yet; just the validate-and-fail behaviour. + +- [ ] **Step 7: Run the full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/Kapacitor.Core/Models.cs \ + src/Kapacitor.Daemon/Services/AgentOrchestrator.cs \ + test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs +git commit -m "[AI-68] models: required Vendor field on LaunchAgentCommand" +``` + +--- + +### Task 8: Add `Vendor` to `AgentRunStarted` + +**Files:** +- Modify: `src/Kapacitor.Core/Models.cs` +- Modify: `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` +- Modify: `test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs` + +- [ ] **Step 1: Write failing test** + +Append to `LaunchAgentCommandWireFormatTests.cs`: + +```csharp +[Test] +public async Task AgentRunStarted_vendor_serialises_into_json_body() { + var evt = new AgentRunStarted( + Prompt: "do a thing", + Model: "claude-sonnet-4-6", + Effort: null, + RepoPath: "/tmp/repo", + WorktreePath: "/tmp/wt", + Vendor: "codex" + ); + + var json = JsonSerializer.Serialize(evt, kapacitor.ModelsJsonContext.Default.AgentRunStarted); + await Assert.That(json).Contains("\"Vendor\":\"codex\""); +} +``` + +(Use whatever JSON-property casing matches the existing convention; if snake_case is in use, expect `"vendor":"codex"`.) + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/LaunchAgentCommandWireFormatTests/AgentRunStarted_vendor_serialises_into_json_body" +``` + +Expected: compile error (no Vendor parameter on `AgentRunStarted`). + +- [ ] **Step 3: Update `AgentRunStarted`** + +In `src/Kapacitor.Core/Models.cs`: + +```csharp +record AgentRunStarted( + string? Prompt, + string? Model, + string? Effort, + string? RepoPath, + string? WorktreePath, + string Vendor + ); +``` + +- [ ] **Step 4: Fix the orchestrator's `AppendAgentRunEventAsync` call site** + +In `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs`, the `_server.AppendAgentRunEventAsync(agentId, new AgentRunStarted(...))` site near line 341 now needs `vendor: cmd.Vendor` as the seventh positional: + +```csharp +_ = _server.AppendAgentRunEventAsync( + agentId, + new AgentRunStarted(prompt, model, effort, repoPath, worktree.Path, cmd.Vendor) +); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/Kapacitor.Core/Models.cs \ + src/Kapacitor.Daemon/Services/AgentOrchestrator.cs \ + test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs +git commit -m "[AI-68] models: required Vendor field on AgentRunStarted" +``` + +--- + +### Task 9: Update `ServerConnection.RequestPermissionAsync` to take `vendor` + +**Files:** +- Modify: `src/Kapacitor.Daemon/Services/ServerConnection.cs` +- Modify: `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` + +- [ ] **Step 1: Update the `ServerConnection.RequestPermissionAsync` signature** + +In `src/Kapacitor.Daemon/Services/ServerConnection.cs`, find the existing `RequestPermissionAsync` method (around line 283). Add a required `string vendor` parameter just before `CancellationToken ct`, and pass it as the fifth positional to the hub `InvokeAsync` call: + +```csharp +public async Task RequestPermissionAsync( + string sessionId, + string? toolName, + JsonElement? toolInput, + JsonElement? suggestions, + string vendor, + CancellationToken ct = default + ) => + await _hub.InvokeAsync( + "RequestPermission", + sessionId, toolName, toolInput, suggestions, vendor, + ct + ); +``` + +- [ ] **Step 2: Update the bridge's call site** + +In `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` around line 188, the existing call: + +```csharp +decision = await server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct); +``` + +Becomes: + +```csharp +decision = await server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, "claude", ct); +``` + +This is a stub value — Task 21 replaces it with the vendor extracted from the URL segment. The interim "claude" keeps behaviour identical to today while we land the parameter change incrementally. + +- [ ] **Step 3: Build to verify no compile errors** + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds. + +- [ ] **Step 4: Run the full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass — the bridge's behaviour is unchanged (still passes `"claude"` to the server) so the existing `LocalPermissionBridgeTests` continue to work against a test double that ignores the new parameter. + +If any existing test double or fake stubs `RequestPermissionAsync` and doesn't accept the new parameter, update it to match the new signature. + +- [ ] **Step 5: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/ServerConnection.cs \ + src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs +git commit -m "[AI-68] daemon: ServerConnection.RequestPermissionAsync gains vendor param" +``` + +--- + +### Task 10: Update `PermissionRequestCommand` to post to `/claude/permission-request` + +The bridge URL split in Task 21 makes the legacy `/{token}/permission-request` path 404. The Claude permission-request CLI hook must migrate to `/claude/permission-request` in lockstep. + +**Files:** +- Modify: `src/kapacitor/Commands/PermissionRequestCommand.cs` + +- [ ] **Step 1: Locate the post target** + +`src/kapacitor/Commands/PermissionRequestCommand.cs:63` constructs the POST URL today as `daemonUrl + "/permission-request"`. Update to `daemonUrl + "/claude/permission-request"`. + +- [ ] **Step 2: Build to verify no compile errors** + +```bash +dotnet build src/kapacitor/kapacitor.csproj +``` + +Expected: succeeds. + +- [ ] **Step 3: Run existing Claude permission-request tests** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/PermissionRequestCommand*" +``` + +Some tests may fail because they assert the old URL. Update the assertions to match `/claude/permission-request`. + +Re-run: + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/PermissionRequestCommand*" +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/kapacitor/Commands/PermissionRequestCommand.cs \ + test/kapacitor.Tests.Unit/ # if any test files were updated +git commit -m "[AI-68] cli: Claude permission-request posts to /claude/permission-request" +``` + +--- + +### Task 11: Define `IHostedAgentLauncher`, `LauncherContext`, `LaunchArgs` + +**Files:** +- Create: `src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs` + +- [ ] **Step 1: Create the interface and supporting records** + +Create `src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs`: + +```csharp +using kapacitor.Daemon.Pty; + +namespace kapacitor.Daemon.Services; + +/// +/// Vendor-specific launch strategy. handles +/// lifecycle concerns (PTY spawn, status, heartbeat, cleanup); each impl owns +/// the vendor-specific bits: CLI binary path, args, settings overlay, pre-trust, +/// MCP config, and per-vendor cleanup. +/// +internal interface IHostedAgentLauncher { + /// Vendor token this launcher handles ("claude" or "codex"). + string Vendor { get; } + + /// Absolute path or bare command for the CLI. Pulled from DaemonConfig. + string CliPath { get; } + + /// + /// Per-vendor preparation BEFORE the PTY is spawned. Implementations: + /// • Overlay vendor-specific settings dir from source repo into worktree + /// • Pre-trust the worktree path in the vendor's config file + /// • Write any vendor-specific config (MCP, etc.) + /// • Merge dialog-selected tools into vendor-specific permission shape + /// • Run fail-fast preflight checks (e.g. required CLI hooks installed) + /// Two failure modes are supported and the orchestrator distinguishes them: + /// • Filesystem / parse errors are swallowed inside the launcher with a + /// warning log so a settings-overlay glitch never blocks launch. + /// • Typed preflight exceptions (e.g. CodexHooksNotInstalledException) + /// propagate out and the orchestrator converts them into LaunchFailed + /// with the exception's user-facing message. Use these sparingly. + /// + void Prepare(LauncherContext ctx); + + /// Build the argv array passed to the CLI. + LaunchArgs BuildArgs(LauncherContext ctx); + + /// Per-vendor cleanup AFTER the agent exits / is stopped. + void Cleanup(AgentInstance agent); +} + +internal sealed record LauncherContext( + string AgentId, + string SourceRepoPath, + WorktreeInfo Worktree, + string? Prompt, + string Model, + string? Effort, + string[]? Tools, + bool IsReview, + ReviewLaunchInfo? Review, + ReviewLaunchBuilder.Result? ReviewLaunch + ); + +internal readonly record struct LaunchArgs(string[] Args, string? McpConfigPath); +``` + +- [ ] **Step 2: Build to verify** + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs +git commit -m "[AI-68] daemon: define IHostedAgentLauncher strategy interface" +``` + +--- + +### Task 12: Extract `ClaudeLauncher` (behavior-preserving refactor) + +Move all Claude-specific code from `AgentOrchestrator.cs` into `ClaudeLauncher.cs`. No behavior change. Existing tests are the regression net. + +**Files:** +- Create: `src/Kapacitor.Daemon/Services/ClaudeLauncher.cs` +- Modify: `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` + +- [ ] **Step 1: Create `ClaudeLauncher` with the extracted methods** + +Create `src/Kapacitor.Daemon/Services/ClaudeLauncher.cs`. Move these existing private static methods from `AgentOrchestrator.cs` verbatim: + +- `OverlayDirectory` (line ~951) +- `SymlinkClaudeProjectDir` (line ~973) +- `RemoveClaudeProjectSymlink` (line ~998) +- `WriteMcpConfig` (line ~1137) +- `ReadMcpJsonServerNames` (line ~1122) +- `TrustWorktreeInClaudeConfig` (line ~1006) +- `LoadJsonObject` (line ~1091) +- `WriteJsonAtomic` (line ~1107) +- `MergeToolPermissions` (line ~902) +- `TrustWriteLock` (line ~817), `ValidEffortLevels` (line ~818), `IndentedJsonOpts` (line ~800) — needed by methods above + +```csharp +using System.Text.Json; +using System.Text.Json.Nodes; +using kapacitor.Commands; +using Microsoft.Extensions.Logging; + +namespace kapacitor.Daemon.Services; + +internal sealed partial class ClaudeLauncher( + DaemonConfig config, + ILogger logger + ) : IHostedAgentLauncher { + + public string Vendor => "claude"; + public string CliPath => config.ClaudePath; + + static readonly Lock TrustWriteLock = new(); + static readonly HashSet ValidEffortLevels = ["low", "medium", "high", "max"]; + static readonly JsonSerializerOptions IndentedJsonOpts = new() { WriteIndented = true }; + + public void Prepare(LauncherContext ctx) { + // Overlay .claude/ local settings from source repo into worktree. + try { + var sourceClaudeDir = Path.Combine(ctx.SourceRepoPath, ".claude"); + var destClaudeDir = Path.Combine(ctx.Worktree.Path, ".claude"); + + if (Directory.Exists(sourceClaudeDir)) { + OverlayDirectory(sourceClaudeDir, destClaudeDir); + } + + SymlinkClaudeProjectDir(ctx.SourceRepoPath, ctx.Worktree.Path); + } catch (Exception ex) { + LogOverlayFailed(ex, ctx.AgentId); + } + + try { + WriteMcpConfig(ctx.SourceRepoPath, ctx.Worktree.Path); + } catch (Exception ex) { + LogMcpConfigFailed(ex, ctx.AgentId); + } + + try { + TrustWorktreeInClaudeConfig(ctx.Worktree.Path); + } catch (Exception ex) { + LogTrustWorktreeFailed(ex, ctx.AgentId); + } + + try { + if (ctx.Tools is { Length: > 0 }) { + MergeToolPermissions(ctx.Worktree.Path, ctx.Tools); + } + } catch (Exception ex) { + LogToolPermissionsFailed(ex, ctx.AgentId); + } + } + + public LaunchArgs BuildArgs(LauncherContext ctx) { + var args = new List(); + string? mcpConfigPath = null; + + if (ctx.IsReview && ctx.ReviewLaunch is { } launch) { + mcpConfigPath = launch.McpConfigPath; + + args.Add("--mcp-config"); + args.Add(launch.McpConfigPath); + args.Add("--system-prompt"); + args.Add(launch.SystemPrompt); + + if (!string.IsNullOrEmpty(ctx.Model)) { + args.Add("--model"); + args.Add(ctx.Model); + } + } else { + if (!string.IsNullOrEmpty(ctx.Effort)) { + args.Add("--effort"); + args.Add(ctx.Effort); + } + if (!string.IsNullOrEmpty(ctx.Model)) { + args.Add("--model"); + args.Add(ctx.Model); + } + if (!string.IsNullOrEmpty(ctx.Prompt)) { + args.Add("--"); + args.Add(ctx.Prompt); + } + } + + return new LaunchArgs(args.ToArray(), mcpConfigPath); + } + + public void Cleanup(AgentInstance agent) { + try { RemoveClaudeProjectSymlink(agent.Worktree.Path); } catch (Exception ex) { + LogCleanupSymlinkFailed(ex, agent.Id); + } + + if (agent.McpConfigPath is not null) { + try { File.Delete(agent.McpConfigPath); } catch (Exception ex) { + LogCleanupMcpConfigFailed(ex, agent.Id); + } + } + } + + // === All the verbatim moved static helpers below === + // Copy OverlayDirectory, SymlinkClaudeProjectDir, RemoveClaudeProjectSymlink, + // WriteMcpConfig, ReadMcpJsonServerNames, TrustWorktreeInClaudeConfig, + // LoadJsonObject, WriteJsonAtomic, MergeToolPermissions HERE — paste them + // verbatim from AgentOrchestrator.cs (with `static` keyword preserved). + + // === LoggerMessage source-generated methods (move corresponding ones from orchestrator) === + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to overlay .claude settings for agent {AgentId} (continuing)")] + partial void LogOverlayFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to write .mcp.json for agent {AgentId} (continuing)")] + partial void LogMcpConfigFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to pre-trust worktree for agent {AgentId} (continuing)")] + partial void LogTrustWorktreeFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to merge tool permissions for agent {AgentId} (continuing)")] + partial void LogToolPermissionsFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to remove claude project symlink for agent {AgentId}")] + partial void LogCleanupSymlinkFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to remove mcp config for agent {AgentId}")] + partial void LogCleanupMcpConfigFailed(Exception ex, string agentId); +} +``` + +- [ ] **Step 2: Remove the moved code from `AgentOrchestrator.cs`** + +In `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs`: + +- Delete the static methods listed in Step 1 (`OverlayDirectory`, `SymlinkClaudeProjectDir`, `RemoveClaudeProjectSymlink`, `WriteMcpConfig`, `ReadMcpJsonServerNames`, `TrustWorktreeInClaudeConfig`, `LoadJsonObject`, `WriteJsonAtomic`, `MergeToolPermissions`). +- Delete the corresponding `LoggerMessage` partial methods (`LogOverlayFailed`, `LogMcpConfigFailed`, `LogTrustWorktreeFailed`, `LogToolPermissionsFailed`). +- Keep `TrustWriteLock`, `ValidEffortLevels`, `IndentedJsonOpts` for now (Task 14 removes any stragglers). + +Leave `HandleLaunchAgent` calling the (now-removed) methods — the build will break. That's expected; Task 14 wires it through the launcher dictionary. + +- [ ] **Step 3: Build to confirm the expected breakage** + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: build errors in `AgentOrchestrator.cs` because the deleted methods are still called from `HandleLaunchAgent`. **This is the intentional checkpoint** — fixing it is Task 14. + +- [ ] **Step 4: Temporarily stub the calls so the build compiles** + +This task is purely the extraction. To keep the working tree green between tasks, comment out the call sites in `HandleLaunchAgent` and replace each with `// TODO Task 14: dispatch through launcher`. Restore in Task 14. + +After commenting: + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds. Note: behaviour is broken (hosted Claude won't pre-trust or overlay) until Task 14 lands. **Do not push this commit alone.** + +- [ ] **Step 5: Commit (mark as WIP)** + +```bash +git add src/Kapacitor.Daemon/Services/ClaudeLauncher.cs \ + src/Kapacitor.Daemon/Services/AgentOrchestrator.cs +git commit -m "[AI-68] daemon: WIP extract ClaudeLauncher (next: wire through orchestrator)" +``` + +--- + +### Task 13: Add `AgentInstance.Vendor` field + +**Files:** +- Modify: `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` + +- [ ] **Step 1: Add `Vendor` to the `AgentInstance` record** + +In `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` around line 15, update the record: + +```csharp +public record AgentInstance( + string Id, + string? Prompt, + string Model, + string? Effort, + string RepoPath, + string Vendor, + IPtyProcess Process, + WorktreeInfo Worktree, + CancellationTokenSource ReadCts + ) { + // ... existing properties unchanged +} +``` + +- [ ] **Step 2: Build to confirm call-site breakages are visible** + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: at least one error at the `new AgentInstance(...)` site in `HandleLaunchAgent` (line ~332). Task 14 fixes it; pass through `vendor: cmd.Vendor` for now to keep building. + +- [ ] **Step 3: Patch the construction site** + +In `HandleLaunchAgent`, find the `new AgentInstance(...)` call and insert `vendor: cmd.Vendor` as the sixth positional: + +```csharp +var agent = new AgentInstance(agentId, prompt, model, effort, repoPath, cmd.Vendor, process, worktree, cts) { + McpConfigPath = mcpConfigPath +}; +``` + +Build: + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds. + +- [ ] **Step 4: Run the full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass (Vendor is a passive field at this point). + +- [ ] **Step 5: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/AgentOrchestrator.cs +git commit -m "[AI-68] daemon: AgentInstance carries Vendor for cleanup dispatch" +``` + +--- + +### Task 14: Wire `AgentOrchestrator` to dispatch through launcher dictionary + +**Files:** +- Modify: `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs` +- Create: `test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs` + +- [ ] **Step 1: Write failing test for vendor routing** + +Create `test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs`: + +```csharp +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class AgentOrchestratorVendorTests { + [Test] + public async Task Launch_with_unknown_vendor_emits_launch_failed_and_does_not_spawn_pty() { + // Test harness: substitute IPtyProcessFactory + ServerConnection with + // spies that capture calls. Construct an orchestrator with an empty + // launcher dictionary so any vendor lookup fails before spawn. + var ptySpy = new SpyPtyProcessFactory(); + var serverSpy = new SpyServerConnection(); + var launchers = new Dictionary(); + var orch = NewOrchestratorUnderTest(launchers, ptySpy, serverSpy); + + await orch.HandleLaunchAgentForTest(new LaunchAgentCommand( + AgentId: "agent-x", Prompt: null, Model: "m", Effort: null, + RepoPath: "/tmp/repo", Tools: null, AttachmentIds: null, + Vendor: "bogus" + )); + + await Assert.That(ptySpy.SpawnCount).IsEqualTo(0); + await Assert.That(serverSpy.LaunchFailedReasons).Contains(r => r.Contains("Unknown vendor")); + } + + // (Additional vendor-routing tests in Step 4.) +} +``` + +You will need a test harness factory that constructs `AgentOrchestrator` with mocked dependencies. If one exists, reuse it; otherwise add `internal static AgentOrchestrator NewOrchestratorUnderTest(...)` next to the orchestrator (kept `internal` so `InternalsVisibleTo` exposes it to tests). + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/AgentOrchestratorVendorTests/*" +``` + +Expected: fails (the existing orchestrator does not yet do vendor lookup; spawn happens regardless). + +- [ ] **Step 3: Update orchestrator constructor + `HandleLaunchAgent` dispatch** + +In `src/Kapacitor.Daemon/Services/AgentOrchestrator.cs`: + +1. Add `_launchers` field and constructor parameter: + +```csharp +readonly IReadOnlyDictionary _launchers; + +public AgentOrchestrator( + DaemonConfig config, + ServerConnection server, + WorktreeManager worktreeManager, + RepoMatcher repoMatcher, + IPtyProcessFactory ptyFactory, + IHttpClientFactory httpClientFactory, + LocalPermissionBridge permissionBridge, + IReadOnlyDictionary launchers, + ILogger logger + ) { + // ... existing assignments + _launchers = launchers; + // ... event wiring unchanged +} +``` + +2. Update `HandleLaunchAgent` to look up the launcher and dispatch through it. Replace the commented-out Claude-specific code (from Task 12 Step 4) with: + +```csharp +// At the top, after destructuring + vendor validation: +if (!_launchers.TryGetValue(cmd.Vendor, out var launcher)) { + await _server.LaunchFailedAsync(agentId, $"No launcher registered for vendor: {cmd.Vendor}"); + return; +} + +// Review-kind for Codex is unsupported in v1: +if (isReview && cmd.Vendor == "codex") { + await _server.LaunchFailedAsync(agentId, "PR review for Codex is not yet supported"); + return; +} + +// (existing review-launch validation against origin remote stays, only for Claude) +// ... existing worktree create, attachment download ... + +// Replace the commented-out OverlayDirectory / TrustWorktreeInClaudeConfig / +// WriteMcpConfig / MergeToolPermissions block with: +var launcherCtx = new LauncherContext( + AgentId: agentId, + SourceRepoPath: repoPath, + Worktree: worktree, + Prompt: prompt, + Model: model, + Effort: effort, + Tools: tools, + IsReview: isReview, + Review: cmd.Review, + ReviewLaunch: isReview && cmd.Review is { } reviewArgs + ? await ReviewLaunchBuilder.BuildAsync(_config.ServerUrl ?? "", reviewArgs.Owner, reviewArgs.Repo, reviewArgs.PrNumber) + : null +); + +try { + launcher.Prepare(launcherCtx); +} catch (CodexHooksNotInstalledException ex) { + await _server.LaunchFailedAsync(agentId, ex.Message); + return; +} catch (Exception ex) { + _logger.LogWarning(ex, "Launcher Prepare soft-failure for agent {AgentId} (continuing)", agentId); +} + +var launchArgs = launcher.BuildArgs(launcherCtx); +mcpConfigPath = launchArgs.McpConfigPath; + +// (existing env-var dictionary construction stays unchanged) + +var process = _ptyFactory.Spawn(launcher.CliPath, launchArgs.Args, worktree.Path, env); +``` + +3. Update `CleanupAgentAsync` (around line 858) to dispatch through launcher: + +```csharp +async Task CleanupAgentAsync(string agentId) { + if (!_agents.TryRemove(agentId, out var agent)) return; + + try { await agent.Process.DisposeAsync(); } catch (Exception ex) { LogCleanupStepFailed(ex, "disposing process", agentId); } + + if (_launchers.TryGetValue(agent.Vendor, out var launcher)) { + try { launcher.Cleanup(agent); } catch (Exception ex) { LogCleanupStepFailed(ex, "launcher.Cleanup", agentId); } + } + + try { await WorktreeManager.RemoveAsync(agent.Worktree); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing worktree", agentId); } + + try { await _server.AgentUnregisteredAsync(agentId); } catch (Exception ex) { LogCleanupStepFailed(ex, "unregistering", agentId); } +} +``` + +The old `RemoveClaudeProjectSymlink(agent.Worktree.Path)` and `File.Delete(agent.McpConfigPath)` calls move into `ClaudeLauncher.Cleanup` (already done in Task 12). + +4. Update the failed-launch catch block (around line 361). The existing block calls `RemoveClaudeProjectSymlink` + worktree removal + mcpConfig delete directly. Replace: + +```csharp +} catch (Exception ex) { + LogLaunchFailed(ex, agentId); + + if (worktree != null) { + if (_launchers.TryGetValue(cmd.Vendor, out var launcherForCleanup)) { + try { + // Build a transient AgentInstance just for cleanup dispatch. + // The launcher's Cleanup signature takes AgentInstance, so we + // construct one with the failed-launch context. PTY is not yet + // alive at this point so Process is set to a no-op stub. + var transient = new AgentInstance( + agentId, prompt, model, effort, repoPath, cmd.Vendor, + NoopPtyProcess.Instance, worktree, new CancellationTokenSource() + ) { + McpConfigPath = mcpConfigPath + }; + launcherForCleanup.Cleanup(transient); + } catch (Exception cleanupEx) { + LogCleanupStepFailed(cleanupEx, "launcher.Cleanup (failed-launch)", agentId); + } + } + + try { await WorktreeManager.RemoveAsync(worktree); } catch { /* best-effort */ } + } + + await _server.LaunchFailedAsync(agentId, ex.Message); +} +``` + +Add a small `NoopPtyProcess` internal class (or use a real null impl) — the launcher's `Cleanup` doesn't touch the process, but the `AgentInstance` constructor requires non-null. Define in the same file: + +```csharp +internal sealed class NoopPtyProcess : IPtyProcess { + public static readonly NoopPtyProcess Instance = new(); + public int Pid => 0; + public bool HasExited => true; + public int? ExitCode => 0; + public ValueTask DisposeAsync() => default; + public Task WaitForExitAsync(TimeSpan _) => Task.CompletedTask; + public Task TerminateAsync(TimeSpan _) => Task.CompletedTask; + public IAsyncEnumerable ReadOutputAsync(CancellationToken _) => AsyncEnumerable.Empty(); + public ValueTask WriteAsync(string _) => default; + public ValueTask WriteAsync(byte[] _) => default; + public void Resize(ushort _, ushort __) { } +} +``` + +(Match the actual `IPtyProcess` interface — adjust the stub if the interface differs.) + +- [ ] **Step 4: Add the remaining vendor-routing tests** + +Extend `AgentOrchestratorVendorTests.cs`: + +```csharp +[Test] +public async Task Launch_with_vendor_claude_calls_claude_launcher() { + var claudeSpy = new SpyHostedAgentLauncher("claude"); + var codexSpy = new SpyHostedAgentLauncher("codex"); + var launchers = new Dictionary { + ["claude"] = claudeSpy, + ["codex"] = codexSpy + }; + var orch = NewOrchestratorUnderTest(launchers, new SpyPtyProcessFactory(), new SpyServerConnection()); + + await orch.HandleLaunchAgentForTest(NewClaudeCommand()); + + await Assert.That(claudeSpy.BuildArgsCalls).IsEqualTo(1); + await Assert.That(codexSpy.BuildArgsCalls).IsEqualTo(0); +} + +[Test] +public async Task Launch_with_vendor_codex_calls_codex_launcher() { + var claudeSpy = new SpyHostedAgentLauncher("claude"); + var codexSpy = new SpyHostedAgentLauncher("codex"); + var launchers = new Dictionary { + ["claude"] = claudeSpy, + ["codex"] = codexSpy + }; + var orch = NewOrchestratorUnderTest(launchers, new SpyPtyProcessFactory(), new SpyServerConnection()); + + await orch.HandleLaunchAgentForTest(NewCodexCommand()); + + await Assert.That(codexSpy.BuildArgsCalls).IsEqualTo(1); + await Assert.That(claudeSpy.BuildArgsCalls).IsEqualTo(0); +} + +[Test] +public async Task Launch_review_kind_with_vendor_codex_emits_launch_failed() { + var serverSpy = new SpyServerConnection(); + var orch = NewOrchestratorUnderTest( + new Dictionary { + ["codex"] = new SpyHostedAgentLauncher("codex") + }, new SpyPtyProcessFactory(), serverSpy); + + await orch.HandleLaunchAgentForTest(NewCodexCommand() with { + Kind = LaunchKind.Review, + Review = new ReviewLaunchInfo("owner", "repo", 42) + }); + + await Assert.That(serverSpy.LaunchFailedReasons).Contains(r => r.Contains("PR review for Codex")); +} + +[Test] +public async Task Cleanup_calls_vendor_specific_cleanup_method() { + var claudeSpy = new SpyHostedAgentLauncher("claude"); + var codexSpy = new SpyHostedAgentLauncher("codex"); + var orch = NewOrchestratorUnderTest( + new Dictionary { ["claude"] = claudeSpy, ["codex"] = codexSpy }, + new SpyPtyProcessFactory(), new SpyServerConnection()); + + await orch.HandleLaunchAgentForTest(NewCodexCommand()); + await orch.CleanupAllForTest(); + + await Assert.That(codexSpy.CleanupCalls).IsEqualTo(1); + await Assert.That(claudeSpy.CleanupCalls).IsEqualTo(0); +} + +[Test] +public async Task Codex_hooks_not_installed_exception_during_prepare_yields_actionable_launch_failed() { + var codexSpy = new SpyHostedAgentLauncher("codex") { + PrepareThrows = new CodexHooksNotInstalledException("Codex hooks not installed. Run `kapacitor plugin install --codex` and try again.") + }; + var serverSpy = new SpyServerConnection(); + var orch = NewOrchestratorUnderTest( + new Dictionary { ["codex"] = codexSpy }, + new SpyPtyProcessFactory(), serverSpy); + + await orch.HandleLaunchAgentForTest(NewCodexCommand()); + + await Assert.That(serverSpy.LaunchFailedReasons).Contains(r => r.Contains("Codex hooks not installed")); + await Assert.That(codexSpy.BuildArgsCalls).IsEqualTo(0); +} +``` + +Implement the `SpyHostedAgentLauncher`, `SpyPtyProcessFactory`, `SpyServerConnection`, `NewClaudeCommand`, `NewCodexCommand` helpers in the same test file (or a shared `TestSpies.cs`). + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/AgentOrchestratorVendorTests/*" +``` + +Expected: all five pass. + +- [ ] **Step 6: Run the full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/AgentOrchestrator.cs \ + test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs +git commit -m "[AI-68] daemon: orchestrator dispatches launches through IHostedAgentLauncher" +``` + +--- + +### Task 15: Add `CodexPath` to `DaemonConfig` + +**Files:** +- Modify: `src/Kapacitor.Daemon/DaemonConfig.cs` + +- [ ] **Step 1: Add the field** + +In `src/Kapacitor.Daemon/DaemonConfig.cs` after `ClaudePath`: + +```csharp +public string ClaudePath { get; set; } = "claude"; +public string CodexPath { get; set; } = "codex"; +``` + +- [ ] **Step 2: Build to verify** + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/Kapacitor.Daemon/DaemonConfig.cs +git commit -m "[AI-68] daemon: add CodexPath config field" +``` + +--- + +### Task 16: Implement `CodexLauncher` + +Largest single task — encompasses overlay, hook preflight, pre-trust, args building, no cleanup needed. + +**Files:** +- Create: `src/Kapacitor.Daemon/Services/CodexLauncher.cs` +- Create: `test/kapacitor.Tests.Unit/CodexLauncherTests.cs` + +- [ ] **Step 1: Write failing tests for `BuildArgs`** + +Create `test/kapacitor.Tests.Unit/CodexLauncherTests.cs`: + +```csharp +using kapacitor.Daemon.Services; +using kapacitor.Daemon.Pty; +using Microsoft.Extensions.Logging.Abstractions; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class CodexLauncherTests { + static CodexLauncher NewLauncher() => + new(new DaemonConfig { CodexPath = "codex" }, NullLogger.Instance); + + static LauncherContext NewCtx( + string? prompt = null, + string model = "gpt-5.3-codex", + string? effort = null + ) => new( + AgentId: "a-1", + SourceRepoPath: "/tmp/repo", + Worktree: new WorktreeInfo(Path: "/tmp/wt", BranchName: "wt-branch"), + Prompt: prompt, + Model: model, + Effort: effort, + Tools: null, + IsReview: false, + Review: null, + ReviewLaunch: null + ); + + [Test] + public async Task BuildArgs_includes_workspace_write_sandbox_and_on_request_approval() { + var args = NewLauncher().BuildArgs(NewCtx()).Args; + await Assert.That(args).Contains("--sandbox").And.Contains("workspace-write"); + await Assert.That(args).Contains("--ask-for-approval").And.Contains("on-request"); + } + + [Test] + public async Task BuildArgs_maps_effort_max_to_xhigh() { + var args = NewLauncher().BuildArgs(NewCtx(effort: "max")).Args; + await Assert.That(string.Join(' ', args)).Contains("model_reasoning_effort=\"xhigh\""); + } + + [Test] + [Arguments("low")] + [Arguments("medium")] + [Arguments("high")] + public async Task BuildArgs_passes_effort_through_unchanged(string effort) { + var args = NewLauncher().BuildArgs(NewCtx(effort: effort)).Args; + await Assert.That(string.Join(' ', args)).Contains($"model_reasoning_effort=\"{effort}\""); + } + + [Test] + [Arguments(null)] + [Arguments("auto")] + public async Task BuildArgs_omits_effort_when_null_or_auto(string? effort) { + var args = NewLauncher().BuildArgs(NewCtx(effort: effort)).Args; + await Assert.That(string.Join(' ', args)).DoesNotContain("model_reasoning_effort"); + } + + [Test] + public async Task BuildArgs_appends_prompt_after_double_dash_when_present() { + var args = NewLauncher().BuildArgs(NewCtx(prompt: "do a thing")).Args; + var dashIdx = Array.IndexOf(args, "--"); + await Assert.That(dashIdx).IsGreaterThan(-1); + await Assert.That(args[dashIdx + 1]).IsEqualTo("do a thing"); + } + + [Test] + public async Task BuildArgs_emits_no_alt_screen_flag() { + var args = NewLauncher().BuildArgs(NewCtx()).Args; + await Assert.That(args).Contains("--no-alt-screen"); + } + + [Test] + public async Task BuildArgs_includes_cd_with_worktree_path() { + var args = NewLauncher().BuildArgs(NewCtx()).Args; + var cdIdx = Array.IndexOf(args, "--cd"); + await Assert.That(cdIdx).IsGreaterThan(-1); + await Assert.That(args[cdIdx + 1]).IsEqualTo("/tmp/wt"); + } + + [Test] + public async Task BuildArgs_includes_model_when_set() { + var args = NewLauncher().BuildArgs(NewCtx(model: "gpt-5.4")).Args; + var mIdx = Array.IndexOf(args, "-m"); + await Assert.That(mIdx).IsGreaterThan(-1); + await Assert.That(args[mIdx + 1]).IsEqualTo("gpt-5.4"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexLauncherTests/*" +``` + +Expected: all fail with "CodexLauncher does not exist". + +- [ ] **Step 3: Implement `CodexLauncher.BuildArgs`** + +Create `src/Kapacitor.Daemon/Services/CodexLauncher.cs` with the BuildArgs path: + +```csharp +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace kapacitor.Daemon.Services; + +internal sealed partial class CodexLauncher( + DaemonConfig config, + ILogger logger + ) : IHostedAgentLauncher { + + public string Vendor => "codex"; + public string CliPath => config.CodexPath; + + static readonly string[] CriticalHookEvents = ["SessionStart", "Stop", "PermissionRequest"]; + + public void Prepare(LauncherContext ctx) { + // Implementation in Step 5 (overlay + preflight + config writer) + throw new NotImplementedException(); + } + + public LaunchArgs BuildArgs(LauncherContext ctx) { + var args = new List(); + + args.Add("--cd"); + args.Add(ctx.Worktree.Path); + + args.Add("--sandbox"); + args.Add("workspace-write"); + + args.Add("--ask-for-approval"); + args.Add("on-request"); + + if (!string.IsNullOrEmpty(ctx.Model)) { + args.Add("-m"); + args.Add(ctx.Model); + } + + var effort = ctx.Effort; + if (!string.IsNullOrEmpty(effort) && !string.Equals(effort, "auto", StringComparison.OrdinalIgnoreCase)) { + var mapped = string.Equals(effort, "max", StringComparison.OrdinalIgnoreCase) ? "xhigh" : effort; + args.Add("-c"); + args.Add($"model_reasoning_effort=\"{mapped}\""); + } + + args.Add("--no-alt-screen"); + + if (!string.IsNullOrEmpty(ctx.Prompt)) { + args.Add("--"); + args.Add(ctx.Prompt); + } + + return new LaunchArgs(args.ToArray(), McpConfigPath: null); + } + + public void Cleanup(AgentInstance agent) { + // No-op: ~/.codex/config.toml trust entries are intentionally persistent. + // Worktree paths are unique per run; cumulative entries are harmless. + } +} +``` + +- [ ] **Step 4: Run `BuildArgs` tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexLauncherTests/*" +``` + +Expected: all eight BuildArgs tests pass. + +- [ ] **Step 5: Write failing tests for `Prepare` (overlay + preflight + config writer)** + +Append to `CodexLauncherTests.cs`: + +```csharp +[Test] +public async Task Prepare_overlays_codex_settings_dir_from_source_repo() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + var srcCodex = Directory.CreateDirectory(Path.Combine(sourceRepo, ".codex")).FullName; + File.WriteAllText(Path.Combine(srcCodex, "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + + await Assert.That(File.Exists(Path.Combine(worktree, ".codex", "hooks.json"))).IsTrue(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } +} + +[Test] +public async Task Prepare_throws_when_no_hooks_json_anywhere() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + + await Assert.That(() => NewLauncher().Prepare(ctx)) + .Throws() + .WithMessage(msg => msg.Contains("kapacitor plugin install --codex")); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } +} + +[Test] +public async Task Prepare_succeeds_when_user_scope_hooks_json_has_all_three_critical_events() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + Directory.CreateDirectory(Path.Combine(home, ".codex")); + File.WriteAllText(Path.Combine(home, ".codex", "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + + // config.toml gets pre-trusted + var configPath = Path.Combine(home, ".codex", "config.toml"); + await Assert.That(File.Exists(configPath)).IsTrue(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } +} + +[Test] +public async Task Prepare_succeeds_when_project_scope_hooks_json_present_after_overlay() { + // hooks live in /.codex/hooks.json — the overlay copies them into worktree before preflight runs. + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + Directory.CreateDirectory(Path.Combine(sourceRepo, ".codex")); + File.WriteAllText(Path.Combine(sourceRepo, ".codex", "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + // succeeds — no throw, config.toml gets written + await Assert.That(File.Exists(Path.Combine(home, ".codex", "config.toml"))).IsTrue(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } +} + +[Test] +public async Task Prepare_invokes_codex_config_writer_with_worktree_path() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + Directory.CreateDirectory(Path.Combine(home, ".codex")); + File.WriteAllText(Path.Combine(home, ".codex", "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + + var configToml = File.ReadAllText(Path.Combine(home, ".codex", "config.toml")); + await Assert.That(configToml).Contains($"[projects.\"{worktree}\"]"); + await Assert.That(configToml).Contains("trust_level = \"trusted\""); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } +} + +static LauncherContext NewCtxWith(string source, string worktree) => new( + AgentId: "a-1", + SourceRepoPath: source, + Worktree: new WorktreeInfo(Path: worktree, BranchName: "br"), + Prompt: null, + Model: "gpt-5.3-codex", + Effort: null, + Tools: null, + IsReview: false, + Review: null, + ReviewLaunch: null +); +``` + +- [ ] **Step 6: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexLauncherTests/Prepare_*" +``` + +Expected: all `Prepare_*` tests throw `NotImplementedException`. + +- [ ] **Step 7: Implement `CodexLauncher.Prepare`** + +Replace the `Prepare` body in `CodexLauncher.cs`: + +```csharp +public void Prepare(LauncherContext ctx) { + // 1. Overlay source/.codex into worktree FIRST so project-scope hooks + // (kapacitor plugin install --codex --project) become visible to the + // preflight in step 2. + try { + var sourceCodexDir = Path.Combine(ctx.SourceRepoPath, ".codex"); + var destCodexDir = Path.Combine(ctx.Worktree.Path, ".codex"); + + if (Directory.Exists(sourceCodexDir)) { + OverlayDirectory(sourceCodexDir, destCodexDir); + } + } catch (Exception ex) { + LogOverlayFailed(ex, ctx.AgentId); + } + + // 2. Hook preflight (fail-fast). Either worktree-scope (after overlay) + // OR user-scope is sufficient. + if (!HooksInstalledIn(Path.Combine(ctx.Worktree.Path, ".codex", "hooks.json")) && + !HooksInstalledIn(CodexPaths.UserHooksJson)) { + throw new CodexHooksNotInstalledException( + "Codex hooks not installed. Run `kapacitor plugin install --codex` " + + "(user scope) or `kapacitor plugin install --codex --project` " + + "(project scope) and try again." + ); + } + + // 3. Pre-trust the worktree in ~/.codex/config.toml. + try { + CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, logger); + } catch (Exception ex) { + LogTrustFailed(ex, ctx.AgentId); + } + + if (ctx.Tools is { Length: > 0 }) { + LogToolsIgnoredForCodex(ctx.AgentId, ctx.Tools.Length); + } +} + +static bool HooksInstalledIn(string hooksPath) { + if (!File.Exists(hooksPath)) return false; + try { + var root = JsonNode.Parse(File.ReadAllText(hooksPath)) as JsonObject; + return root is not null && CodexHooksParser.HasKapacitorHooksFor(root, CriticalHookEvents); + } catch { + return false; + } +} + +static void OverlayDirectory(string source, string dest) { + Directory.CreateDirectory(dest); + foreach (var file in Directory.GetFiles(source)) { + var destFile = Path.Combine(dest, Path.GetFileName(file)); + if (!File.Exists(destFile)) File.Copy(file, destFile); + } + foreach (var dir in Directory.GetDirectories(source)) { + OverlayDirectory(dir, Path.Combine(dest, Path.GetFileName(dir))); + } +} + +[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to overlay .codex settings for agent {AgentId} (continuing)")] +partial void LogOverlayFailed(Exception ex, string agentId); + +[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to pre-trust worktree for agent {AgentId} (continuing)")] +partial void LogTrustFailed(Exception ex, string agentId); + +[LoggerMessage(Level = LogLevel.Debug, Message = "Tools array of length {Count} ignored for vendor=codex (no allowlist concept) — agent {AgentId}")] +partial void LogToolsIgnoredForCodex(string agentId, int count); +``` + +`OverlayDirectory` is duplicated between `ClaudeLauncher` and `CodexLauncher`. Lift to a shared helper if both are in the same assembly. Either: +- Add `internal static class FileSystemOverlay` in `kapacitor.Daemon.Services` with the single `OverlayDirectory` method, OR +- Keep two copies for now and lift in a follow-up cleanup. + +For this task, **lift to `FileSystemOverlay`** to avoid the duplication landing in main. Update both launchers to call `FileSystemOverlay.OverlayDirectory(...)`. + +- [ ] **Step 8: Run `Prepare_*` tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexLauncherTests/Prepare_*" +``` + +Expected: all five `Prepare_*` tests pass. + +- [ ] **Step 9: Run the full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/CodexLauncher.cs \ + src/Kapacitor.Daemon/Services/ClaudeLauncher.cs \ + src/Kapacitor.Daemon/Services/FileSystemOverlay.cs \ + test/kapacitor.Tests.Unit/CodexLauncherTests.cs +git commit -m "[AI-68] daemon: CodexLauncher with overlay + hook preflight + pre-trust" +``` + +--- + +### Task 17: `LocalPermissionBridge` per-vendor URL routing + response shapes + +**Files:** +- Modify: `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs` +- Modify: `test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs` + +- [ ] **Step 1: Write failing tests for new URL shape** + +In `test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs` add: + +```csharp +[Test] +public async Task Claude_path_returns_claude_response_shape() { + // Reuse the existing test fixture pattern — substitute ServerConnection so + // RequestPermissionAsync returns a known PermissionDecision. POST to + // /{token}/claude/permission-request. Assert body has hookSpecificOutput + // with applyPermissions/updatedInput fields when the decision carries them. + // (Concrete body: mirror the existing "Claude shape" assertion from the + // pre-split test.) + Assert.Fail("regression: bridge URL must include /claude/ segment"); +} + +[Test] +public async Task Codex_path_returns_codex_response_shape() { + // POST to /{token}/codex/permission-request. Assert body is + // {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}} + // WITHOUT applyPermissions/updatedInput. + Assert.Fail("not implemented"); +} + +[Test] +public async Task Legacy_path_without_vendor_returns_404() { + // POST to /{token}/permission-request — must return 404. + Assert.Fail("not implemented"); +} + +[Test] +public async Task Unknown_vendor_returns_404() { + // POST to /{token}/bogus/permission-request — must return 404. + Assert.Fail("not implemented"); +} + +[Test] +public async Task Codex_path_invokes_server_with_vendor_codex() { + // Capture ServerConnection.RequestPermissionAsync's vendor parameter. + // POST to /{token}/codex/permission-request; assert captured vendor == "codex". + Assert.Fail("not implemented"); +} + +[Test] +public async Task Claude_path_invokes_server_with_vendor_claude() { + // POST to /{token}/claude/permission-request; assert captured vendor == "claude". + Assert.Fail("not implemented"); +} + +[Test] +public async Task Codex_path_strips_apply_permissions_from_server_decision() { + // Server decision includes applyPermissions; Codex response body must NOT contain it. + Assert.Fail("not implemented"); +} +``` + +Flesh out each test with concrete HttpClient POSTs against the bridge fixture, asserting status codes / body contents as listed. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/LocalPermissionBridgeTests/*" +``` + +Expected: new tests fail; existing tests may still pass (legacy URL still works) until Step 3. + +- [ ] **Step 3: Update the bridge URL routing** + +In `src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs`: + +1. Widen the HTTP prefix to allow any sub-path: + +```csharp +// In StartAsync: +listener.Prefixes.Add($"http://127.0.0.1:{port}/{token}/"); +``` + +2. Replace the path-match block (around line 138): + +```csharp +const string Suffix = "/permission-request"; +var path = context.Request.Url?.AbsolutePath; + +if (path is null || !path.StartsWith($"/{_token}/", StringComparison.Ordinal) || + !path.EndsWith(Suffix, StringComparison.Ordinal) || + context.Request.HttpMethod != "POST") { + context.Response.StatusCode = 404; + context.Response.Close(); + return; +} + +var vendorStart = _token.Length + 2; +var vendorEnd = path.Length - Suffix.Length; +var vendor = vendorEnd > vendorStart ? path[vendorStart..vendorEnd] : ""; + +if (vendor is not ("claude" or "codex")) { + context.Response.StatusCode = 404; + context.Response.Close(); + return; +} +``` + +3. Pass `vendor` to the server call: + +```csharp +decision = await server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, vendor, ct); +``` + +4. Add vendor-shaped response builder: + +```csharp +static string BuildHookResponseJson(PermissionDecision decision, string vendor) => + vendor switch { + "claude" => BuildClaudeResponse(decision), + "codex" => BuildCodexResponse(decision), + _ => throw new InvalidOperationException($"Unsupported vendor: {vendor}") + }; + +static string BuildClaudeResponse(PermissionDecision decision) { + // Current BuildHookResponseJson body verbatim — including applyPermissions / updatedInput + var decisionNode = new JsonObject { ["behavior"] = decision.Behavior }; + if (decision.ApplyPermissions is { } ap) decisionNode["applyPermissions"] = JsonNode.Parse(ap.GetRawText()); + if (decision.UpdatedInput is { } ui) decisionNode["updatedInput"] = JsonNode.Parse(ui.GetRawText()); + + var payload = new JsonObject { + ["hookSpecificOutput"] = new JsonObject { + ["hookEventName"] = "PermissionRequest", + ["decision"] = decisionNode + } + }; + return payload.ToJsonString(); +} + +static string BuildCodexResponse(PermissionDecision decision) { + var payload = new JsonObject { + ["hookSpecificOutput"] = new JsonObject { + ["hookEventName"] = "PermissionRequest", + ["decision"] = new JsonObject { ["behavior"] = decision.Behavior } + } + }; + return payload.ToJsonString(); +} +``` + +Replace the existing `BuildHookResponseJson(decision)` call (around line 195) with `BuildHookResponseJson(decision, vendor)`. + +- [ ] **Step 4: Run bridge tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/LocalPermissionBridgeTests/*" +``` + +Expected: all pass. + +- [ ] **Step 5: Add Claude-CLI-hook regression test** + +In `LocalPermissionBridgeTests.cs` add: + +```csharp +[Test] +public async Task Claude_cli_hook_post_target_lands_at_new_url() { + // Spin up the bridge with a captured ServerConnection. Drive + // PermissionRequestCommand against it. Assert the captured request URL + // ends with "/claude/permission-request" (not the legacy unprefixed path). + Assert.Fail("regression for Task 10 URL migration"); +} +``` + +Implement against the existing test scaffolding. Run: + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/LocalPermissionBridgeTests/Claude_cli_hook_post_target_lands_at_new_url" +``` + +Expected: passes. + +- [ ] **Step 6: Run full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs \ + test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs +git commit -m "[AI-68] daemon: LocalPermissionBridge per-vendor URL routing + response shapes" +``` + +--- + +### Task 18: `CodexHookCommand.HandlePermissionRequest` bridge bounce (fail-closed) + +**Files:** +- Modify: `src/kapacitor/Commands/CodexHookCommand.cs` +- Modify: `test/kapacitor.Tests.Unit/CodexHookCommandTests.cs` + +- [ ] **Step 1: Write failing tests** + +In `test/kapacitor.Tests.Unit/CodexHookCommandTests.cs` add: + +```csharp +[Test] +public async Task PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forwards_response_to_stdout() { + using var bridge = WireMockServer.Start(); + bridge.Given(Request.Create().WithPath("/abc/codex/permission-request").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("""{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}""")); + + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://127.0.0.1:{bridge.Ports[0]}/abc"); + try { + var stdin = new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}"""); + var stdout = new StringWriter(); + Console.SetOut(stdout); + + var exit = await CodexHookCommand.Handle("http://server", stdin); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(stdout.ToString()).Contains("\"behavior\":\"allow\""); + } finally { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + } +} + +[Test] +public async Task PermissionRequest_with_daemon_url_emits_deny_and_exits_nonzero_on_500() { + using var bridge = WireMockServer.Start(); + bridge.Given(Request.Create().WithPath("/abc/codex/permission-request").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(500)); + + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://127.0.0.1:{bridge.Ports[0]}/abc"); + try { + var stdin = new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}"""); + var stdout = new StringWriter(); + Console.SetOut(stdout); + + var exit = await CodexHookCommand.Handle("http://server", stdin); + + await Assert.That(exit).IsNotEqualTo(0); + await Assert.That(stdout.ToString()).Contains("\"behavior\":\"deny\""); + } finally { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + } +} + +[Test] +public async Task PermissionRequest_with_daemon_url_emits_deny_on_connection_refused() { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", "http://127.0.0.1:1/abc"); // port 1 = guaranteed refused + try { + var stdin = new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}"""); + var stdout = new StringWriter(); + Console.SetOut(stdout); + + var exit = await CodexHookCommand.Handle("http://server", stdin); + + await Assert.That(exit).IsNotEqualTo(0); + await Assert.That(stdout.ToString()).Contains("\"behavior\":\"deny\""); + } finally { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + } +} + +[Test] +public async Task PermissionRequest_with_non_loopback_daemon_url_emits_deny_without_posting() { + using var bridge = WireMockServer.Start(); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://example.com:{bridge.Ports[0]}/abc"); + try { + var stdin = new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}"""); + var stdout = new StringWriter(); + Console.SetOut(stdout); + + var exit = await CodexHookCommand.Handle("http://server", stdin); + + await Assert.That(exit).IsNotEqualTo(0); + await Assert.That(stdout.ToString()).Contains("\"behavior\":\"deny\""); + await Assert.That(bridge.LogEntries).IsEmpty(); + } finally { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + } +} + +[Test] +public async Task PermissionRequest_with_https_daemon_url_emits_deny_without_posting() { + using var bridge = WireMockServer.Start(); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"https://127.0.0.1:{bridge.Ports[0]}/abc"); + try { + var stdin = new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}"""); + var stdout = new StringWriter(); + Console.SetOut(stdout); + + var exit = await CodexHookCommand.Handle("http://server", stdin); + + await Assert.That(exit).IsNotEqualTo(0); + await Assert.That(stdout.ToString()).Contains("\"behavior\":\"deny\""); + await Assert.That(bridge.LogEntries).IsEmpty(); + } finally { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + } +} + +[Test] +public async Task PermissionRequest_without_daemon_url_still_uses_legacy_stub() { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + using var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/hooks/permission-request/codex").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + var stdin = new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}"""); + var stdout = new StringWriter(); + Console.SetOut(stdout); + + var exit = await CodexHookCommand.Handle($"http://127.0.0.1:{server.Ports[0]}", stdin); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(stdout.ToString()).Contains("\"behavior\":\"allow\""); + await Assert.That(server.LogEntries).HasCount().EqualTo(1); // server saw the informational POST +} + +[Test] +public async Task PermissionRequest_with_daemon_url_does_not_double_post_to_server_hooks_endpoint() { + using var bridge = WireMockServer.Start(); + using var server = WireMockServer.Start(); + bridge.Given(Request.Create().WithPath("/abc/codex/permission-request").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("""{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}""")); + server.Given(Request.Create().WithPath("/hooks/permission-request/codex").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://127.0.0.1:{bridge.Ports[0]}/abc"); + try { + var stdin = new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}"""); + await CodexHookCommand.Handle($"http://127.0.0.1:{server.Ports[0]}", stdin); + + await Assert.That(server.LogEntries).IsEmpty(); // server saw NO POST when daemon is set + await Assert.That(bridge.LogEntries).HasCount().EqualTo(1); + } finally { + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexHookCommandTests/PermissionRequest_*" +``` + +Expected: the four bridge-branch tests fail; the no-daemon test may pass (stub is current behaviour); the no-double-post test fails because today the stub always posts. + +- [ ] **Step 3: Implement the bridge bounce branch** + +In `src/kapacitor/Commands/CodexHookCommand.cs`, replace `HandlePermissionRequest`: + +```csharp +static async Task HandlePermissionRequest(string baseUrl, JsonNode node) { + var daemonUrl = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + + if (daemonUrl is null) { + return await HandlePermissionRequestStub(baseUrl, node); + } + + return await HandlePermissionRequestViaBridge(daemonUrl, node); +} + +static async Task HandlePermissionRequestStub(string baseUrl, JsonNode node) { + await PostHookAsync(baseUrl, "permission-request/codex", node.ToJsonString()); + + var response = new JsonObject { + ["hookSpecificOutput"] = new JsonObject { + ["hookEventName"] = "PermissionRequest", + ["decision"] = new JsonObject { ["behavior"] = "allow" } + } + }; + Console.Write(response.ToJsonString()); + return 0; +} + +static async Task HandlePermissionRequestViaBridge(string daemonUrl, JsonNode node) { + if (!DaemonBridgeUrl.TryParseLoopback(daemonUrl, out var baseUrl)) { + Console.Error.WriteLine($"[kapacitor] codex-hook permission-request: KAPACITOR_DAEMON_URL must be http loopback, got: {daemonUrl}"); + return EmitDenyAndExitNonzero(); + } + + using var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; + + try { + using var content = new StringContent(node.ToJsonString(), Encoding.UTF8, "application/json"); + using var resp = await client.PostAsync($"{baseUrl}/codex/permission-request", content); + + if (!resp.IsSuccessStatusCode) { + Console.Error.WriteLine($"[kapacitor] codex-hook permission-request bridge: HTTP {(int)resp.StatusCode}"); + return EmitDenyAndExitNonzero(); + } + + var body = await resp.Content.ReadAsStringAsync(); + Console.Write(body); + return 0; + } catch (Exception ex) { + Console.Error.WriteLine($"[kapacitor] codex-hook permission-request bridge error: {ex.Message}"); + return EmitDenyAndExitNonzero(); + } +} + +static int EmitDenyAndExitNonzero() { + var response = new JsonObject { + ["hookSpecificOutput"] = new JsonObject { + ["hookEventName"] = "PermissionRequest", + ["decision"] = new JsonObject { ["behavior"] = "deny" } + } + }; + Console.Write(response.ToJsonString()); + return 1; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj -- --treenode-filter "/*/*/CodexHookCommandTests/*" +``` + +Expected: all pass. + +- [ ] **Step 5: Run the full unit test suite** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/kapacitor/Commands/CodexHookCommand.cs \ + test/kapacitor.Tests.Unit/CodexHookCommandTests.cs +git commit -m "[AI-68] cli: codex-hook PermissionRequest bridges through daemon (fail-closed)" +``` + +--- + +### Task 19: DI registration in `Program.cs` + +**Files:** +- Modify: `src/kapacitor/Program.cs` (daemon entry point — verify path) + +- [ ] **Step 1: Locate the daemon's DI setup** + +Find the daemon's `Program.cs` or composition root where `AgentOrchestrator` is registered. (May live in `src/Kapacitor.Daemon/Program.cs` rather than the CLI's `src/kapacitor/Program.cs`. Use:) + +```bash +grep -rn "AgentOrchestrator" src/ --include="*.cs" | grep -v "Tests" +``` + +Identify the registration site. + +- [ ] **Step 2: Register both launchers and the dictionary** + +Add to the DI registration block: + +```csharp +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton>(sp => + sp.GetServices().ToDictionary(l => l.Vendor)); +``` + +- [ ] **Step 3: Build to verify** + +```bash +dotnet build src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +``` + +Expected: succeeds. + +- [ ] **Step 4: Run integration tests** + +```bash +dotnet run --project test/kapacitor.Tests.Integration/kapacitor.Tests.Integration.csproj +``` + +Expected: all pass. (Integration tests exercise the real DI graph and would catch missing registrations.) + +- [ ] **Step 5: Commit** + +```bash +git add src/kapacitor/Program.cs # or wherever Program.cs lives +git commit -m "[AI-68] daemon: DI registers ClaudeLauncher + CodexLauncher" +``` + +--- + +### Task 20: README + help-text updates + +**Files:** +- Modify: `README.md` +- Modify: `src/Kapacitor.Core/Resources/help-codex-hook.txt` + +- [ ] **Step 1: Read current README for context** + +```bash +cat README.md | head -100 +``` + +Find the `## Getting started` quick-start, the `kapacitor agent start` section, the `kapacitor codex-hook` section, and any daemon-config docs. + +- [ ] **Step 2: Update README sections** + +In `README.md`: + +- Quick-start (under `## Getting started`): add a one-liner noting that the daemon now supports `vendor: codex` hosted agents (macOS + Linux) in addition to Claude. +- `kapacitor agent start` section: add a "Hosted Codex" subsection mentioning the prerequisites (`kapacitor plugin install --codex` first; defaults to `--sandbox workspace-write` + `--ask-for-approval on-request`). +- `kapacitor codex-hook` section: note that `PermissionRequest` bounces through the local daemon when `KAPACITOR_DAEMON_URL` is set; outside daemon context, the stub allow-response is unchanged. +- Daemon-config section (or add one): document both `ClaudePath` (default `"claude"`) and `CodexPath` (default `"codex"`). + +- [ ] **Step 3: Update help text** + +In `src/Kapacitor.Core/Resources/help-codex-hook.txt`, add a one-paragraph note explaining the bridge-bounce behaviour for hosted (daemon-launched) Codex agents. + +- [ ] **Step 4: Build to bundle the new resource** + +```bash +dotnet build src/Kapacitor.Core/Kapacitor.Core.csproj +``` + +Expected: succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add README.md src/Kapacitor.Core/Resources/help-codex-hook.txt +git commit -m "[AI-68] docs: README + help text for hosted Codex agents" +``` + +--- + +### Task 21: Final AOT verification gate + +**Files:** (none modified — verification only) + +- [ ] **Step 1: AOT publish both projects** + +```bash +dotnet publish src/kapacitor/kapacitor.csproj -c Release 2>&1 | tee /tmp/aot-cli.log +dotnet publish src/Kapacitor.Daemon/Kapacitor.Daemon.csproj -c Release 2>&1 | tee /tmp/aot-daemon.log +``` + +- [ ] **Step 2: Grep for warnings** + +```bash +grep -E 'IL[23][01][0-9]{2}' /tmp/aot-cli.log /tmp/aot-daemon.log +``` + +Expected: zero matches. + +If matches: identify the responsible call site (likely a Tomlyn pathway), apply the spec §5.3 fallback (typed source-gen context), re-run. + +- [ ] **Step 3: Run all tests** + +```bash +dotnet run --project test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +dotnet run --project test/kapacitor.Tests.Integration/kapacitor.Tests.Integration.csproj +``` + +Expected: all green. + +- [ ] **Step 4: Manual smoke checklist (PR-time)** + +The following are PR-review manual checks per AI-68 acceptance — document in the PR description but do NOT run as part of this task: + +- Launch a Codex hosted agent against a real worktree, run a multi-turn session with a tool call and a permission prompt, verify the terminal renders in the dashboard and the permission round-trips. +- Repeat the launch on a fresh machine with NO `~/.codex/hooks.json` — verify `LaunchFailed` with the actionable "Run `kapacitor plugin install --codex`" message. +- Repeat the launch on a machine with project-scope hooks (`/.codex/hooks.json`) but no user-scope hooks — verify launch succeeds after overlay. + +- [ ] **Step 5: Commit (no changes — just marker)** + +```bash +git commit --allow-empty -m "[AI-68] verify: AOT clean + all tests green" +``` + +--- + +## Self-review + +After writing all 21 tasks, the plan checks against the spec: + +- ✅ §2 Spike — captured in plan preamble; no implementation step needed +- ✅ §3.1 LaunchAgentCommand.Vendor — Task 7 +- ✅ §3.2 AgentRunStarted.Vendor — Task 8 +- ✅ §3.3 LocalPermissionBridge URLs — Task 17 +- ✅ §3.4 DaemonConfig.CodexPath — Task 15 +- ⚠️ §3.5 Server-side — explicitly scoped out; cross-references kapacitor-server's paired plan +- ✅ §4.1 IHostedAgentLauncher — Task 11 +- ✅ §4.2 ClaudeLauncher — Task 12 +- ✅ §4.3 CodexLauncher — Task 16 +- ✅ §4.4 Orchestrator changes — Tasks 13, 14 +- ✅ §4.5 Composition root — Task 19 +- ✅ §5 CodexConfigWriter — Tasks 1, 6 +- ✅ §6 LocalPermissionBridge per-vendor — Task 17 +- ✅ §7.0 Claude CLI hook URL — Task 10 +- ✅ §7.1-7.3 Codex CLI hook bridge — Task 18 +- ✅ §8 Tests — distributed across the relevant tasks +- ✅ §9 Docs — Task 20 +- ✅ §10.1 File touch list — covered task-by-task +- ⚠️ §10.2 Server repo — scoped out; paired plan +- ✅ §12 Acceptance mapping — Task 21 manual checklist + +No placeholders found. Type names and method signatures referenced in later tasks (`LauncherContext`, `LaunchArgs`, `CodexHooksParser.HasKapacitorHooksFor`, `DaemonBridgeUrl.TryParseLoopback`, `CodexHooksNotInstalledException`) match their defining tasks (11, 2, 4, 5 respectively). + +--- + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-14-ai-68-codex-hosted-agents.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? From 88f801858b6de1cfe29813e0a796ae99641034b5 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 19:19:38 +0200 Subject: [PATCH 08/36] [AI-68] deps: add Tomlyn for Codex config.toml pre-trust --- Directory.Build.props | 2 +- Directory.Packages.props | 14 ++++++++++++++ src/Kapacitor.Daemon/Kapacitor.Daemon.csproj | 7 ++++--- src/kapacitor/kapacitor.csproj | 8 ++++---- 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Build.props b/Directory.Build.props index 9a9ad4a1c0..11eeb45e5f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,6 +5,6 @@ v - + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000000..d0443e5579 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + true + + + + + + + + + + + diff --git a/src/Kapacitor.Daemon/Kapacitor.Daemon.csproj b/src/Kapacitor.Daemon/Kapacitor.Daemon.csproj index 08a11fb366..6147067ea1 100644 --- a/src/Kapacitor.Daemon/Kapacitor.Daemon.csproj +++ b/src/Kapacitor.Daemon/Kapacitor.Daemon.csproj @@ -33,9 +33,10 @@ - - - + + + + diff --git a/src/kapacitor/kapacitor.csproj b/src/kapacitor/kapacitor.csproj index ca293c34c6..8947033a3a 100644 --- a/src/kapacitor/kapacitor.csproj +++ b/src/kapacitor/kapacitor.csproj @@ -31,10 +31,10 @@ - - - - + + + + From 87ba40fb4d9c51c8ff0a02fc327283bbba8f700f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 19:37:16 +0200 Subject: [PATCH 09/36] [AI-68] deps: pin Spectre.Console + Microsoft.* to versions matching prior float ranges --- Directory.Packages.props | 11 +++++++---- .../kapacitor.Tests.Integration.csproj | 4 ++-- test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d0443e5579..9b30dd2092 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,11 +4,14 @@ - - - + + + - + + + + diff --git a/test/kapacitor.Tests.Integration/kapacitor.Tests.Integration.csproj b/test/kapacitor.Tests.Integration/kapacitor.Tests.Integration.csproj index 004019deff..9f344e96a6 100644 --- a/test/kapacitor.Tests.Integration/kapacitor.Tests.Integration.csproj +++ b/test/kapacitor.Tests.Integration/kapacitor.Tests.Integration.csproj @@ -11,7 +11,7 @@ - - + + diff --git a/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj b/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj index a0653b1a0b..ab72f78f9c 100644 --- a/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +++ b/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj @@ -11,8 +11,8 @@ - - - + + + From 16ea6ae1f410bb3c90999f92524246fc648cfcca Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 22:48:16 +0200 Subject: [PATCH 10/36] [AI-68] core: extract CodexHooksParser for daemon-side preflight reuse Move EntryReferencesKapacitorCodexHook and CodexHookEvents from PluginCommand into a new CodexHooksParser in Kapacitor.Core so Kapacitor.Daemon can reference the predicate without taking a dependency on the CLI project. Update all call sites (PluginCommand, StatusCommand) to delegate to CodexHooksParser. Co-Authored-By: Claude Sonnet 4.6 --- src/Kapacitor.Core/CodexHooksParser.cs | 64 +++++++++++++++++++ src/kapacitor/Commands/PluginCommand.cs | 34 ++-------- src/kapacitor/Commands/StatusCommand.cs | 2 +- .../CodexHooksParserTests.cs | 56 ++++++++++++++++ .../PluginCommandCodexTests.cs | 6 +- 5 files changed, 128 insertions(+), 34 deletions(-) create mode 100644 src/Kapacitor.Core/CodexHooksParser.cs create mode 100644 test/kapacitor.Tests.Unit/CodexHooksParserTests.cs diff --git a/src/Kapacitor.Core/CodexHooksParser.cs b/src/Kapacitor.Core/CodexHooksParser.cs new file mode 100644 index 0000000000..f28716505d --- /dev/null +++ b/src/Kapacitor.Core/CodexHooksParser.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Nodes; + +namespace kapacitor; + +/// +/// Parsing helpers for ~/.codex/hooks.json (and <repo>/.codex/hooks.json +/// in project-scope installs). Shared by the CLI's plugin install --codex +/// command (which writes the file) and the daemon's CodexLauncher preflight +/// (which reads it before spawning a hosted Codex agent). +/// +public static class CodexHooksParser { + /// Hook event names Codex CLI emits. + public static readonly string[] CodexHookEvents = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "Stop" + ]; + + /// + /// Returns true if is a hooks.json group whose + /// hooks[].command contains kapacitor codex-hook. + /// + public static bool EntryReferencesKapacitorCodexHook(JsonNode? entry) { + if (entry?["hooks"] is not JsonArray hooks) return false; + + foreach (var hook in hooks) { + if (hook?["command"] is JsonValue jv && + jv.TryGetValue(out var cmd) && + cmd.Contains("kapacitor codex-hook")) { + return true; + } + } + + return false; + } + + /// + /// Returns true if every event in has at least one + /// hooks.json entry that invokes kapacitor codex-hook. + /// + public static bool HasKapacitorHooksFor(JsonObject root, IEnumerable events) { + if (root["hooks"] is not JsonObject hooks) return false; + + foreach (var evt in events) { + if (hooks[evt] is not JsonArray entries) return false; + + var any = false; + + foreach (var entry in entries) { + if (EntryReferencesKapacitorCodexHook(entry)) { + any = true; + break; + } + } + + if (!any) return false; + } + + return true; + } +} diff --git a/src/kapacitor/Commands/PluginCommand.cs b/src/kapacitor/Commands/PluginCommand.cs index 007a90fb6f..92b2dd460b 100644 --- a/src/kapacitor/Commands/PluginCommand.cs +++ b/src/kapacitor/Commands/PluginCommand.cs @@ -6,15 +6,6 @@ namespace kapacitor.Commands; public static class PluginCommand { static readonly JsonSerializerOptions WriteOpts = new() { WriteIndented = true }; - static readonly string[] CodexHookEvents = [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PermissionRequest", - "Stop" - ]; - const string CodexHookCommand = "kapacitor codex-hook"; public static async Task HandleAsync(string[] args) { @@ -195,7 +186,7 @@ public static bool InstallCodexHooks(string hooksPath) { root["hooks"] = hooks; } - foreach (var evt in CodexHookEvents) { + foreach (var evt in CodexHooksParser.CodexHookEvents) { var kapacitorEntry = new JsonObject { ["hooks"] = new JsonArray( new JsonObject { @@ -216,7 +207,7 @@ public static bool InstallCodexHooks(string hooksPath) { foreach (var entry in entries) { if (entry is null) continue; - if (!EntryReferencesKapacitorCodexHook(entry)) { + if (!CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)) { preserved.Add(entry.DeepClone()); } } @@ -248,7 +239,7 @@ public static bool RemoveCodexHooks(string hooksPath) { var changed = false; - foreach (var evt in CodexHookEvents) { + foreach (var evt in CodexHooksParser.CodexHookEvents) { if (hooks[evt] is not JsonArray entries) continue; var preserved = new JsonArray(); @@ -256,7 +247,7 @@ public static bool RemoveCodexHooks(string hooksPath) { foreach (var entry in entries) { if (entry is null) continue; - if (EntryReferencesKapacitorCodexHook(entry)) { + if (CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)) { changed = true; } else { preserved.Add(entry.DeepClone()); @@ -276,23 +267,6 @@ public static bool RemoveCodexHooks(string hooksPath) { } } - /// - /// Returns true iff is a JsonObject that has a - /// hooks JsonArray containing at least one JsonObject whose - /// command string contains kapacitor codex-hook. - /// Any non-conformant node shape is treated as a non-match (returns false) - /// instead of throwing. - /// - internal static bool EntryReferencesKapacitorCodexHook(JsonNode? entry) { - if (entry is not JsonObject entryObj) return false; - if (entryObj["hooks"] is not JsonArray inner) return false; - - return inner.OfType().Any(h => - h["command"] is JsonValue v - && v.TryGetValue(out var cmd) - && cmd.Contains("kapacitor codex-hook")); - } - static int PrintUsage() { Console.Error.WriteLine("Usage: kapacitor plugin [--project] [--codex]"); diff --git a/src/kapacitor/Commands/StatusCommand.cs b/src/kapacitor/Commands/StatusCommand.cs index 4801bc4c56..5327ddd02e 100644 --- a/src/kapacitor/Commands/StatusCommand.cs +++ b/src/kapacitor/Commands/StatusCommand.cs @@ -116,7 +116,7 @@ public static bool IsCodexHooksInstalled(string hooksPath) { foreach (var (_, value) in hooks) { if (value is not JsonArray entries) continue; - if (entries.Any(entry => PluginCommand.EntryReferencesKapacitorCodexHook(entry))) { + if (entries.Any(entry => CodexHooksParser.EntryReferencesKapacitorCodexHook(entry))) { return true; } } diff --git a/test/kapacitor.Tests.Unit/CodexHooksParserTests.cs b/test/kapacitor.Tests.Unit/CodexHooksParserTests.cs new file mode 100644 index 0000000000..05737a9767 --- /dev/null +++ b/test/kapacitor.Tests.Unit/CodexHooksParserTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Nodes; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class CodexHooksParserTests { + [Test] + public async Task EntryReferencesKapacitorCodexHook_returns_true_when_command_contains_marker() { + var entry = JsonNode.Parse("""{"hooks":[{"type":"command","command":"kapacitor codex-hook","timeout":30}]}"""); + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)).IsTrue(); + } + + [Test] + public async Task EntryReferencesKapacitorCodexHook_returns_false_when_command_does_not_match() { + var entry = JsonNode.Parse("""{"hooks":[{"type":"command","command":"echo hi","timeout":30}]}"""); + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)).IsFalse(); + } + + [Test] + public async Task EntryReferencesKapacitorCodexHook_returns_false_for_null() { + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(null)).IsFalse(); + } + + [Test] + public async Task HasKapacitorHooksFor_returns_true_when_all_events_have_kapacitor_entry() { + var root = JsonNode.Parse(""" + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """) as JsonObject; + var events = new[] { "SessionStart", "Stop", "PermissionRequest" }; + await Assert.That(CodexHooksParser.HasKapacitorHooksFor(root!, events)).IsTrue(); + } + + [Test] + public async Task HasKapacitorHooksFor_returns_false_when_one_event_missing() { + var root = JsonNode.Parse(""" + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"echo something else"}]}] + }} + """) as JsonObject; + var events = new[] { "SessionStart", "Stop", "PermissionRequest" }; + await Assert.That(CodexHooksParser.HasKapacitorHooksFor(root!, events)).IsFalse(); + } + + [Test] + public async Task CodexHookEvents_lists_all_six_events_in_canonical_order() { + await Assert.That(CodexHooksParser.CodexHookEvents).IsEquivalentTo( + new[] { "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop" } + ); + } +} diff --git a/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs b/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs index 3ae831e141..7cc570724b 100644 --- a/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs +++ b/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs @@ -155,18 +155,18 @@ await File.WriteAllTextAsync(path, """ [Test] public async Task EntryReferencesKapacitorCodexHook_returns_false_for_numeric_command() { var entry = JsonNode.Parse("""{"hooks":[{"type":"command","command":42}]}"""); - await Assert.That(PluginCommand.EntryReferencesKapacitorCodexHook(entry)).IsFalse(); + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)).IsFalse(); } [Test] public async Task EntryReferencesKapacitorCodexHook_returns_true_for_matching_string_command() { var entry = JsonNode.Parse("""{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}"""); - await Assert.That(PluginCommand.EntryReferencesKapacitorCodexHook(entry)).IsTrue(); + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(entry)).IsTrue(); } [Test] public async Task EntryReferencesKapacitorCodexHook_returns_false_for_null() { - await Assert.That(PluginCommand.EntryReferencesKapacitorCodexHook(null)).IsFalse(); + await Assert.That(CodexHooksParser.EntryReferencesKapacitorCodexHook(null)).IsFalse(); } sealed class TempDir : IDisposable { From 24e1fbdd64a8d16ca0a4a49c44d1cb105e9aac3e Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 22:50:55 +0200 Subject: [PATCH 11/36] [AI-68] test: enforce CodexHookEvents order positionally --- test/kapacitor.Tests.Unit/CodexHooksParserTests.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/kapacitor.Tests.Unit/CodexHooksParserTests.cs b/test/kapacitor.Tests.Unit/CodexHooksParserTests.cs index 05737a9767..bfae29872a 100644 --- a/test/kapacitor.Tests.Unit/CodexHooksParserTests.cs +++ b/test/kapacitor.Tests.Unit/CodexHooksParserTests.cs @@ -49,8 +49,14 @@ public async Task HasKapacitorHooksFor_returns_false_when_one_event_missing() { [Test] public async Task CodexHookEvents_lists_all_six_events_in_canonical_order() { - await Assert.That(CodexHooksParser.CodexHookEvents).IsEquivalentTo( - new[] { "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop" } - ); + var expected = new[] { + "SessionStart", "UserPromptSubmit", "PreToolUse", + "PostToolUse", "PermissionRequest", "Stop" + }; + + await Assert.That(CodexHooksParser.CodexHookEvents.Length).IsEqualTo(expected.Length); + for (var i = 0; i < expected.Length; i++) { + await Assert.That(CodexHooksParser.CodexHookEvents[i]).IsEqualTo(expected[i]); + } } } From bbfdfbe93fadcf358869d3117417adbf9e5522a4 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 23:00:16 +0200 Subject: [PATCH 12/36] [AI-68] core: make CodexPaths computed so tests can scope HOME --- src/Kapacitor.Core/CodexPaths.cs | 6 +-- src/Kapacitor.Core/PathHelpers.cs | 2 +- .../CodexPathsHomeIsolationTests.cs | 53 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs diff --git a/src/Kapacitor.Core/CodexPaths.cs b/src/Kapacitor.Core/CodexPaths.cs index 1c020f9816..f91b6bc7cb 100644 --- a/src/Kapacitor.Core/CodexPaths.cs +++ b/src/Kapacitor.Core/CodexPaths.cs @@ -1,9 +1,9 @@ namespace kapacitor; static class CodexPaths { - public static string Home { get; } = Path.Combine(PathHelpers.HomeDirectory, ".codex"); - public static string Sessions { get; } = Path.Combine(Path.Combine(PathHelpers.HomeDirectory, ".codex"), "sessions"); - public static string UserHooksJson { get; } = Path.Combine(Path.Combine(PathHelpers.HomeDirectory, ".codex"), "hooks.json"); + public static string Home => Path.Combine(PathHelpers.HomeDirectory, ".codex"); + public static string Sessions => Path.Combine(Home, "sessions"); + public static string UserHooksJson => Path.Combine(Home, "hooks.json"); /// /// Walk ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, optionally pruning diff --git a/src/Kapacitor.Core/PathHelpers.cs b/src/Kapacitor.Core/PathHelpers.cs index 2dcda566e9..84a3ff862b 100644 --- a/src/Kapacitor.Core/PathHelpers.cs +++ b/src/Kapacitor.Core/PathHelpers.cs @@ -1,7 +1,7 @@ namespace kapacitor; static class PathHelpers { - public static string HomeDirectory => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + public static string HomeDirectory => Environment.GetEnvironmentVariable("HOME") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); static readonly string ConfigDir = Environment.GetEnvironmentVariable("KAPACITOR_CONFIG_DIR") ?? Path.Combine(HomeDirectory, ".config", "kapacitor"); diff --git a/test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs b/test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs new file mode 100644 index 0000000000..d348491e38 --- /dev/null +++ b/test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs @@ -0,0 +1,53 @@ +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class CodexPathsHomeIsolationTests { + [Test] + public async Task Home_reflects_current_HOME_env_var() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexpaths-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + // Force any prior static init that might cache HOME's value + _ = CodexPaths.Home; + + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + await Assert.That(CodexPaths.Home).IsEqualTo(Path.Combine(tmp.FullName, ".codex")); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task Sessions_reflects_current_HOME_env_var() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexpaths-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + _ = CodexPaths.Sessions; + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + await Assert.That(CodexPaths.Sessions).IsEqualTo(Path.Combine(tmp.FullName, ".codex", "sessions")); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task UserHooksJson_reflects_current_HOME_env_var() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexpaths-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + _ = CodexPaths.UserHooksJson; + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + await Assert.That(CodexPaths.UserHooksJson).IsEqualTo(Path.Combine(tmp.FullName, ".codex", "hooks.json")); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } +} From ea290bd2a9e2a8c08c3a258f479bdd5f68c2385f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 23:11:22 +0200 Subject: [PATCH 13/36] [AI-68] cli: extract DaemonBridgeUrl loopback helper for Codex reuse --- src/kapacitor/Commands/DaemonBridgeUrl.cs | 28 ++++++++ .../Commands/PermissionRequestCommand.cs | 14 ++-- .../DaemonBridgeUrlTests.cs | 66 +++++++++++++++++++ .../PermissionRequestCommandTests.cs | 7 +- 4 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/kapacitor/Commands/DaemonBridgeUrl.cs create mode 100644 test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs diff --git a/src/kapacitor/Commands/DaemonBridgeUrl.cs b/src/kapacitor/Commands/DaemonBridgeUrl.cs new file mode 100644 index 0000000000..192afe54af --- /dev/null +++ b/src/kapacitor/Commands/DaemonBridgeUrl.cs @@ -0,0 +1,28 @@ +namespace kapacitor.Commands; + +/// +/// Loopback validation for KAPACITOR_DAEMON_URL. Both the Claude and +/// Codex permission-request hook CLI commands must refuse to POST permission +/// payloads to anything other than an HTTP loopback URL — non-loopback or +/// HTTPS values usually indicate a misconfigured environment variable, and +/// we don't want hook payloads leaving the loopback interface. +/// +public static class DaemonBridgeUrl { + /// + /// True when is a valid http://127.0.0.1:.../... + /// URL. Returns the parsed-and-trailing-slash-stripped form via + /// (callers append /{vendor}/permission-request + /// themselves). + /// + public static bool TryParseLoopback(string? daemonUrl, out string baseUrl) { + baseUrl = ""; + + if (string.IsNullOrWhiteSpace(daemonUrl)) return false; + if (!Uri.TryCreate(daemonUrl, UriKind.Absolute, out var uri)) return false; + if (uri.Scheme != "http") return false; + if (uri.Host != "127.0.0.1") return false; + + baseUrl = daemonUrl.TrimEnd('/'); + return true; + } +} diff --git a/src/kapacitor/Commands/PermissionRequestCommand.cs b/src/kapacitor/Commands/PermissionRequestCommand.cs index 3223feb2f5..98ad88ca46 100644 --- a/src/kapacitor/Commands/PermissionRequestCommand.cs +++ b/src/kapacitor/Commands/PermissionRequestCommand.cs @@ -72,7 +72,13 @@ internal static bool TryGetLoopbackDaemonUrl(out string daemonUrl) { var raw = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); if (string.IsNullOrEmpty(raw)) return false; - if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) || !uri.IsLoopback) { + if (DaemonBridgeUrl.TryParseLoopback(raw, out daemonUrl)) { + return true; + } + + // Provide helpful error message if validation failed + if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) { + // Malformed URI Console.Error.WriteLine($"[kapacitor] Ignoring non-loopback KAPACITOR_DAEMON_URL: {raw}"); return false; } @@ -82,9 +88,9 @@ internal static bool TryGetLoopbackDaemonUrl(out string daemonUrl) { return false; } - // Trim trailing slash so the appended "/permission-request" produces a clean URL. - daemonUrl = raw.TrimEnd('/'); - return true; + // URI is valid but host is not 127.0.0.1 + Console.Error.WriteLine($"[kapacitor] Ignoring non-loopback KAPACITOR_DAEMON_URL: {raw}"); + return false; } static async Task PostAsync(string url, JsonObject payload, bool authenticated) { diff --git a/test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs b/test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs new file mode 100644 index 0000000000..a93c58bd96 --- /dev/null +++ b/test/kapacitor.Tests.Unit/DaemonBridgeUrlTests.cs @@ -0,0 +1,66 @@ +using kapacitor.Commands; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +public class DaemonBridgeUrlTests { + [Test] + public async Task TryParseLoopback_accepts_http_127_0_0_1() { + var ok = DaemonBridgeUrl.TryParseLoopback("http://127.0.0.1:54321/abc123", out var baseUrl); + await Assert.That(ok).IsTrue(); + await Assert.That(baseUrl).IsEqualTo("http://127.0.0.1:54321/abc123"); + } + + [Test] + public async Task TryParseLoopback_strips_trailing_slash() { + var ok = DaemonBridgeUrl.TryParseLoopback("http://127.0.0.1:54321/abc123/", out var baseUrl); + await Assert.That(ok).IsTrue(); + await Assert.That(baseUrl).IsEqualTo("http://127.0.0.1:54321/abc123"); + } + + [Test] + public async Task TryParseLoopback_rejects_https() { + var ok = DaemonBridgeUrl.TryParseLoopback("https://127.0.0.1:54321/abc123", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_non_loopback_host() { + var ok = DaemonBridgeUrl.TryParseLoopback("http://example.com:54321/abc123", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_null() { + var ok = DaemonBridgeUrl.TryParseLoopback(null, out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_empty() { + var ok = DaemonBridgeUrl.TryParseLoopback("", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_malformed_uri() { + var ok = DaemonBridgeUrl.TryParseLoopback("not-a-url", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_ipv6_localhost() { + // Codex hook payloads should not leave loopback via IPv6 either. + // We pin to literal "127.0.0.1" — IPv6 :: localhost (::1) is not accepted. + var ok = DaemonBridgeUrl.TryParseLoopback("http://[::1]:54321/abc123", out _); + await Assert.That(ok).IsFalse(); + } + + [Test] + public async Task TryParseLoopback_rejects_localhost_dns_name() { + // We require literal IP — "localhost" could resolve to non-loopback in a misconfigured env. + var ok = DaemonBridgeUrl.TryParseLoopback("http://localhost:54321/abc123", out _); + await Assert.That(ok).IsFalse(); + } +} diff --git a/test/kapacitor.Tests.Unit/PermissionRequestCommandTests.cs b/test/kapacitor.Tests.Unit/PermissionRequestCommandTests.cs index 955eb16827..0540c5b7c0 100644 --- a/test/kapacitor.Tests.Unit/PermissionRequestCommandTests.cs +++ b/test/kapacitor.Tests.Unit/PermissionRequestCommandTests.cs @@ -36,13 +36,14 @@ public async Task AcceptsLoopbackHttpAndTrimsTrailingSlash() { } [Test, NotInParallel(nameof(PermissionRequestCommandTests))] - public async Task AcceptsLocalhostHttp() { + public async Task RejectsLocalhostDnsName() { + // We require literal 127.0.0.1 — "localhost" could resolve to non-loopback in a misconfigured env. using var _ = new EnvVarScope(EnvVar, "http://localhost:51234/tok"); var ok = PermissionRequestCommand.TryGetLoopbackDaemonUrl(out var url); - await Assert.That(ok).IsTrue(); - await Assert.That(url).IsEqualTo("http://localhost:51234/tok"); + await Assert.That(ok).IsFalse(); + await Assert.That(url).IsEqualTo(""); } [Test, NotInParallel(nameof(PermissionRequestCommandTests))] From 1ef2dc8063f446f8930151652acbb191699e8c5f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 23:25:31 +0200 Subject: [PATCH 14/36] [AI-68] cli: collapse TryGetLoopbackDaemonUrl to single delegation --- src/kapacitor/Commands/PermissionRequestCommand.cs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/kapacitor/Commands/PermissionRequestCommand.cs b/src/kapacitor/Commands/PermissionRequestCommand.cs index 98ad88ca46..0b579fcbc9 100644 --- a/src/kapacitor/Commands/PermissionRequestCommand.cs +++ b/src/kapacitor/Commands/PermissionRequestCommand.cs @@ -76,19 +76,6 @@ internal static bool TryGetLoopbackDaemonUrl(out string daemonUrl) { return true; } - // Provide helpful error message if validation failed - if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) { - // Malformed URI - Console.Error.WriteLine($"[kapacitor] Ignoring non-loopback KAPACITOR_DAEMON_URL: {raw}"); - return false; - } - - if (uri.Scheme != Uri.UriSchemeHttp) { - Console.Error.WriteLine($"[kapacitor] Ignoring non-http KAPACITOR_DAEMON_URL scheme: {uri.Scheme}"); - return false; - } - - // URI is valid but host is not 127.0.0.1 Console.Error.WriteLine($"[kapacitor] Ignoring non-loopback KAPACITOR_DAEMON_URL: {raw}"); return false; } From 96f2384a08818d2f46e141184daa3aa60f6bc0bc Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 23:26:36 +0200 Subject: [PATCH 15/36] [AI-68] daemon: add CodexHooksNotInstalledException for preflight fail-fast --- .../Services/CodexHooksNotInstalledException.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs diff --git a/src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs b/src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs new file mode 100644 index 0000000000..3a133de306 --- /dev/null +++ b/src/Kapacitor.Daemon/Services/CodexHooksNotInstalledException.cs @@ -0,0 +1,14 @@ +namespace kapacitor.Daemon.Services; + +/// +/// Thrown by 's Prepare preflight when neither the +/// user-scope (~/.codex/hooks.json) nor project-scope +/// (<worktree>/.codex/hooks.json) hooks file has a +/// kapacitor codex-hook entry for SessionStart / Stop / PermissionRequest. +/// The orchestrator catches this type and emits with +/// the exception's message; the user sees an actionable instruction to run +/// kapacitor plugin install --codex. +/// +internal sealed class CodexHooksNotInstalledException : Exception { + public CodexHooksNotInstalledException(string message) : base(message) { } +} From f90b4724539889d86b50c5c7a5ea7a677786ebda Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 23:44:35 +0200 Subject: [PATCH 16/36] [AI-68] daemon: CodexConfigWriter pre-trusts worktrees in ~/.codex/config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses Tomlyn 2.4.0 TomlTable model with AOT-safe GetBuiltInTypeInfo via a thin TomlSerializerContext subclass — avoids RequiresDynamicCode/ RequiresUnreferencedCode on the generic Deserialize overload. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/CodexConfigWriter.cs | 97 ++++++++++ .../CodexConfigWriterTests.cs | 172 ++++++++++++++++++ .../kapacitor.Tests.Unit.csproj | 1 + 3 files changed, 270 insertions(+) create mode 100644 src/Kapacitor.Daemon/Services/CodexConfigWriter.cs create mode 100644 test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs diff --git a/src/Kapacitor.Daemon/Services/CodexConfigWriter.cs b/src/Kapacitor.Daemon/Services/CodexConfigWriter.cs new file mode 100644 index 0000000000..40ec4b07a2 --- /dev/null +++ b/src/Kapacitor.Daemon/Services/CodexConfigWriter.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Logging; +using Tomlyn; +using Tomlyn.Model; +using Tomlyn.Serialization; + +namespace kapacitor.Daemon.Services; + +/// +/// Writes the per-worktree pre-trust entry into ~/.codex/config.toml: +/// +/// [projects."/abs/worktree/path"] +/// trust_level = "trusted" +/// +/// Tomlyn's TomlTable mode round-trips preserve all other top-level tables +/// (model, mcp_servers, plugins, marketplaces, ad-hoc user keys) but DO NOT +/// preserve user comments or original formatting. This is acceptable because +/// the file is daemon-managed and human edits are expected to be sparse. +/// +internal static class CodexConfigWriter { + static readonly Lock _writeLock = new(); + static readonly TomlTableTypeInfo _tomlTypeInfo = new(); + + public static void TrustWorktree(string worktreePath, ILogger logger) { + lock (_writeLock) { + var configPath = Path.Combine(CodexPaths.Home, "config.toml"); + + TomlTable root; + if (File.Exists(configPath)) { + try { + root = TomlSerializer.Deserialize(File.ReadAllText(configPath), _tomlTypeInfo.TableInfo) + ?? new TomlTable(); + } catch (Exception ex) { + logger.LogWarning(ex, "Failed to parse {Path}; aborting pre-trust", configPath); + return; + } + } else { + root = new TomlTable(); + } + + if (!root.TryGetValue("projects", out var projectsObj) || projectsObj is not TomlTable projects) { + projects = new TomlTable(); + root["projects"] = projects; + } + + if (!projects.TryGetValue(worktreePath, out var entryObj) || entryObj is not TomlTable entry) { + entry = new TomlTable(); + projects[worktreePath] = entry; + } + + var alreadyTrusted = entry.TryGetValue("trust_level", out var existing) && + existing is string s && + string.Equals(s, "trusted", StringComparison.Ordinal); + + if (alreadyTrusted) return; + + entry["trust_level"] = "trusted"; + + try { + // First-time users have no ~/.codex; create it before the atomic rename. + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + WriteTomlAtomic(configPath, root); + } catch (Exception ex) { + logger.LogWarning(ex, "Failed to write {Path}; pre-trust not persisted", configPath); + } + } + } + + static void WriteTomlAtomic(string path, TomlTable root) { + var tmp = path + ".tmp-" + Environment.ProcessId + "-" + Guid.NewGuid().ToString("N"); + File.WriteAllText(tmp, TomlSerializer.Serialize(root, _tomlTypeInfo.TableInfo)); + + try { + File.Move(tmp, path, overwrite: true); + } catch { + try { File.Delete(tmp); } catch { /* best-effort */ } + throw; + } + } + + /// + /// AOT-safe accessor for of . + /// is protected, so a thin + /// subclass is used to surface the built-in untyped-model metadata without reflection. + /// + sealed class TomlTableTypeInfo : TomlSerializerContext { + static readonly TomlSerializerOptions DefaultOptions = new(); + + public readonly TomlTypeInfo TableInfo; + + public TomlTableTypeInfo() { + TableInfo = GetBuiltInTypeInfo(DefaultOptions)!; + } + + public override TomlTypeInfo? GetTypeInfo(Type type, TomlSerializerOptions options) => + type == typeof(TomlTable) ? TableInfo : null; + } +} diff --git a/test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs b/test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs new file mode 100644 index 0000000000..51cba672bf --- /dev/null +++ b/test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs @@ -0,0 +1,172 @@ +using kapacitor.Daemon.Services; +using Microsoft.Extensions.Logging.Abstractions; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using Tomlyn; +using Tomlyn.Model; + +namespace kapacitor.Tests.Unit; + +// Tests modify the HOME env var; they must not run in parallel with each other +// (or with any other test that reads CodexPaths.Home) so that the scoped HOME is stable. +public class CodexConfigWriterTests { + const string ParallelKey = nameof(CodexConfigWriterTests); + + static (DirectoryInfo Dir, string? OriginalHome) ScopedHome() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-codexconfig-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + return (tmp, originalHome); + } + + static void RestoreHome(string? originalHome, DirectoryInfo tmp) { + Environment.SetEnvironmentVariable("HOME", originalHome); + try { tmp.Delete(recursive: true); } catch { /* best-effort */ } + } + + static TomlTable ReadToml(string path) => + TomlSerializer.Deserialize(File.ReadAllText(path))!; + + [Test, NotInParallel(ParallelKey)] + public async Task Writes_initial_projects_table_when_config_toml_missing() { + var (tmp, original) = ScopedHome(); + try { + Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")); + CodexConfigWriter.TrustWorktree("/tmp/some-worktree", NullLogger.Instance); + + var configPath = Path.Combine(tmp.FullName, ".codex", "config.toml"); + await Assert.That(File.Exists(configPath)).IsTrue(); + + var root = ReadToml(configPath); + var projects = (TomlTable)root["projects"]; + var entry = (TomlTable)projects["/tmp/some-worktree"]; + await Assert.That((string)entry["trust_level"]).IsEqualTo("trusted"); + } finally { RestoreHome(original, tmp); } + } + + [Test, NotInParallel(ParallelKey)] + public async Task Writes_to_fresh_home_creates_codex_directory() { + var (tmp, original) = ScopedHome(); + // Explicitly NOT pre-creating .codex + try { + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + var codexDir = Path.Combine(tmp.FullName, ".codex"); + await Assert.That(Directory.Exists(codexDir)).IsTrue(); + await Assert.That(File.Exists(Path.Combine(codexDir, "config.toml"))).IsTrue(); + } finally { RestoreHome(original, tmp); } + } + + [Test, NotInParallel(ParallelKey)] + public async Task Adds_entry_to_existing_config_preserving_other_tables() { + var (tmp, original) = ScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + File.WriteAllText(Path.Combine(codexDir, "config.toml"), """ + model = "gpt-5.5" + + [mcp_servers.linear] + url = "https://mcp.linear.app/mcp" + + [projects."/existing/path"] + trust_level = "trusted" + """); + + CodexConfigWriter.TrustWorktree("/tmp/new-wt", NullLogger.Instance); + + var root = ReadToml(Path.Combine(codexDir, "config.toml")); + await Assert.That((string)root["model"]).IsEqualTo("gpt-5.5"); + var mcp = (TomlTable)((TomlTable)root["mcp_servers"])["linear"]; + await Assert.That((string)mcp["url"]).IsEqualTo("https://mcp.linear.app/mcp"); + + var projects = (TomlTable)root["projects"]; + await Assert.That((string)((TomlTable)projects["/existing/path"])["trust_level"]).IsEqualTo("trusted"); + await Assert.That((string)((TomlTable)projects["/tmp/new-wt"])["trust_level"]).IsEqualTo("trusted"); + } finally { RestoreHome(original, tmp); } + } + + [Test, NotInParallel(ParallelKey)] + public async Task Updates_trust_level_if_present_but_not_trusted() { + var (tmp, original) = ScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + File.WriteAllText(Path.Combine(codexDir, "config.toml"), """ + [projects."/tmp/wt"] + trust_level = "ask" + """); + + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + var root = ReadToml(Path.Combine(codexDir, "config.toml")); + var entry = (TomlTable)((TomlTable)root["projects"])["/tmp/wt"]; + await Assert.That((string)entry["trust_level"]).IsEqualTo("trusted"); + } finally { RestoreHome(original, tmp); } + } + + [Test, NotInParallel(ParallelKey)] + public async Task No_op_when_trust_level_already_trusted() { + var (tmp, original) = ScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + var configPath = Path.Combine(codexDir, "config.toml"); + File.WriteAllText(configPath, """ + [projects."/tmp/wt"] + trust_level = "trusted" + """); + var originalMtime = File.GetLastWriteTimeUtc(configPath); + + await Task.Delay(20); // ensure mtime resolution gap + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + await Assert.That(File.GetLastWriteTimeUtc(configPath)).IsEqualTo(originalMtime); + } finally { RestoreHome(original, tmp); } + } + + [Test, NotInParallel(ParallelKey)] + public async Task Atomic_rename_leaves_no_tmp_files() { + var (tmp, original) = ScopedHome(); + try { + CodexConfigWriter.TrustWorktree("/tmp/wt-1", NullLogger.Instance); + CodexConfigWriter.TrustWorktree("/tmp/wt-2", NullLogger.Instance); + + var codexDir = Path.Combine(tmp.FullName, ".codex"); + var leftover = Directory.GetFiles(codexDir).Where(f => Path.GetFileName(f).Contains(".tmp-")).ToList(); + await Assert.That(leftover).IsEmpty(); + } finally { RestoreHome(original, tmp); } + } + + [Test, NotInParallel(ParallelKey)] + public async Task Concurrent_writers_serialise_safely() { + var (tmp, original) = ScopedHome(); + try { + var tasks = Enumerable.Range(0, 20) + .Select(i => Task.Run(() => CodexConfigWriter.TrustWorktree($"/tmp/wt-{i}", NullLogger.Instance))) + .ToArray(); + await Task.WhenAll(tasks); + + var configPath = Path.Combine(tmp.FullName, ".codex", "config.toml"); + var root = ReadToml(configPath); + var projects = (TomlTable)root["projects"]; + for (var i = 0; i < 20; i++) { + var entry = (TomlTable)projects[$"/tmp/wt-{i}"]; + await Assert.That((string)entry["trust_level"]).IsEqualTo("trusted"); + } + } finally { RestoreHome(original, tmp); } + } + + [Test, NotInParallel(ParallelKey)] + public async Task Malformed_existing_config_is_skipped_not_overwritten() { + var (tmp, original) = ScopedHome(); + try { + var codexDir = Directory.CreateDirectory(Path.Combine(tmp.FullName, ".codex")).FullName; + var configPath = Path.Combine(codexDir, "config.toml"); + const string garbage = "{{{ not valid TOML"; + File.WriteAllText(configPath, garbage); + + CodexConfigWriter.TrustWorktree("/tmp/wt", NullLogger.Instance); + + // File untouched, no throw + await Assert.That(File.ReadAllText(configPath)).IsEqualTo(garbage); + } finally { RestoreHome(original, tmp); } + } +} diff --git a/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj b/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj index ab72f78f9c..ab74be02e7 100644 --- a/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj +++ b/test/kapacitor.Tests.Unit/kapacitor.Tests.Unit.csproj @@ -12,6 +12,7 @@ + From 90fff64e3e807df19559860d5576f89796b7c471 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 14 May 2026 23:55:27 +0200 Subject: [PATCH 17/36] [AI-68] test: serialise HOME-mutating tests via shared NotInParallel key --- .../CodexConfigWriterTests.cs | 18 +++++++++--------- .../CodexPathsHomeIsolationTests.cs | 1 + 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs b/test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs index 51cba672bf..a1f44d5cbf 100644 --- a/test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs +++ b/test/kapacitor.Tests.Unit/CodexConfigWriterTests.cs @@ -9,8 +9,8 @@ namespace kapacitor.Tests.Unit; // Tests modify the HOME env var; they must not run in parallel with each other // (or with any other test that reads CodexPaths.Home) so that the scoped HOME is stable. +[NotInParallel("HomeEnvVarMutation")] public class CodexConfigWriterTests { - const string ParallelKey = nameof(CodexConfigWriterTests); static (DirectoryInfo Dir, string? OriginalHome) ScopedHome() { var tmp = Directory.CreateTempSubdirectory("kapacitor-codexconfig-test-"); @@ -27,7 +27,7 @@ static void RestoreHome(string? originalHome, DirectoryInfo tmp) { static TomlTable ReadToml(string path) => TomlSerializer.Deserialize(File.ReadAllText(path))!; - [Test, NotInParallel(ParallelKey)] + [Test] public async Task Writes_initial_projects_table_when_config_toml_missing() { var (tmp, original) = ScopedHome(); try { @@ -44,7 +44,7 @@ public async Task Writes_initial_projects_table_when_config_toml_missing() { } finally { RestoreHome(original, tmp); } } - [Test, NotInParallel(ParallelKey)] + [Test] public async Task Writes_to_fresh_home_creates_codex_directory() { var (tmp, original) = ScopedHome(); // Explicitly NOT pre-creating .codex @@ -57,7 +57,7 @@ public async Task Writes_to_fresh_home_creates_codex_directory() { } finally { RestoreHome(original, tmp); } } - [Test, NotInParallel(ParallelKey)] + [Test] public async Task Adds_entry_to_existing_config_preserving_other_tables() { var (tmp, original) = ScopedHome(); try { @@ -85,7 +85,7 @@ public async Task Adds_entry_to_existing_config_preserving_other_tables() { } finally { RestoreHome(original, tmp); } } - [Test, NotInParallel(ParallelKey)] + [Test] public async Task Updates_trust_level_if_present_but_not_trusted() { var (tmp, original) = ScopedHome(); try { @@ -103,7 +103,7 @@ public async Task Updates_trust_level_if_present_but_not_trusted() { } finally { RestoreHome(original, tmp); } } - [Test, NotInParallel(ParallelKey)] + [Test] public async Task No_op_when_trust_level_already_trusted() { var (tmp, original) = ScopedHome(); try { @@ -122,7 +122,7 @@ public async Task No_op_when_trust_level_already_trusted() { } finally { RestoreHome(original, tmp); } } - [Test, NotInParallel(ParallelKey)] + [Test] public async Task Atomic_rename_leaves_no_tmp_files() { var (tmp, original) = ScopedHome(); try { @@ -135,7 +135,7 @@ public async Task Atomic_rename_leaves_no_tmp_files() { } finally { RestoreHome(original, tmp); } } - [Test, NotInParallel(ParallelKey)] + [Test] public async Task Concurrent_writers_serialise_safely() { var (tmp, original) = ScopedHome(); try { @@ -154,7 +154,7 @@ public async Task Concurrent_writers_serialise_safely() { } finally { RestoreHome(original, tmp); } } - [Test, NotInParallel(ParallelKey)] + [Test] public async Task Malformed_existing_config_is_skipped_not_overwritten() { var (tmp, original) = ScopedHome(); try { diff --git a/test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs b/test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs index d348491e38..0a98baa5eb 100644 --- a/test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs +++ b/test/kapacitor.Tests.Unit/CodexPathsHomeIsolationTests.cs @@ -3,6 +3,7 @@ namespace kapacitor.Tests.Unit; +[NotInParallel("HomeEnvVarMutation")] public class CodexPathsHomeIsolationTests { [Test] public async Task Home_reflects_current_HOME_env_var() { From 8d52377db8994f3f4ed5cfcdb1ef1bf885747399 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 00:09:07 +0200 Subject: [PATCH 18/36] [AI-68] models: required Vendor field on LaunchAgentCommand Add required string Vendor positional to LaunchAgentCommand (between AttachmentIds and Kind to satisfy C# required-before-optional rule). Daemon validates at top of HandleLaunchAgent; rejects anything other than "claude" or "codex" via LaunchFailedAsync. Wire-format test added to confirm round-trip serialization. Co-Authored-By: Claude Sonnet 4.6 --- src/Kapacitor.Core/Models.cs | 1 + .../Services/AgentOrchestrator.cs | 5 ++++ .../LaunchAgentCommandWireFormatTests.cs | 24 ++++++++++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Kapacitor.Core/Models.cs b/src/Kapacitor.Core/Models.cs index 75098f3181..cd121838b3 100644 --- a/src/Kapacitor.Core/Models.cs +++ b/src/Kapacitor.Core/Models.cs @@ -558,6 +558,7 @@ public readonly record struct LaunchAgentCommand( string RepoPath, string[]? Tools, string[]? AttachmentIds, + string Vendor, LaunchKind Kind = LaunchKind.Default, ReviewLaunchInfo? Review = null, string? BaseRef = null diff --git a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs index 0d8e098ff6..9890091928 100644 --- a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs +++ b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs @@ -134,6 +134,11 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { var attachmentIds = cmd.AttachmentIds; var isReview = cmd.Kind == LaunchKind.Review; + if (cmd.Vendor is not ("claude" or "codex")) { + await _server.LaunchFailedAsync(cmd.AgentId, $"Unknown vendor: {cmd.Vendor}"); + return; + } + WorktreeInfo? worktree = null; string? mcpConfigPath = null; diff --git a/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs b/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs index 3ce857cc7f..8240686e7a 100644 --- a/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs +++ b/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs @@ -33,7 +33,8 @@ public async Task DaemonDeserialises_DefaultKind() { Effort: null, RepoPath: "/tmp/repo", Tools: null, - AttachmentIds: null + AttachmentIds: null, + Vendor: "claude" ); var wire = JsonSerializer.Serialize(cmd, ServerWireOptions); @@ -53,6 +54,7 @@ public async Task DaemonDeserialises_ReviewKind_WithReviewInfo() { RepoPath: "/tmp/repo", Tools: null, AttachmentIds: null, + Vendor: "claude", Kind: LaunchKind.Review, Review: new ReviewLaunchInfo("kurrent-io", "kapacitor", 42) ); @@ -77,6 +79,7 @@ public async Task ServerWire_EncodesKindAsCamelCaseString() { RepoPath: "/r", Tools: null, AttachmentIds: null, + Vendor: "claude", Kind: LaunchKind.Review ); @@ -84,4 +87,23 @@ public async Task ServerWire_EncodesKindAsCamelCaseString() { await Assert.That(wire).Contains("\"kind\":\"review\""); } + + [Test] + public async Task Vendor_field_round_trips_through_json_serializer() { + var cmd = new LaunchAgentCommand( + AgentId: "agent-1", + Prompt: null, + Model: "claude-sonnet-4-6", + Effort: null, + RepoPath: "/tmp/repo", + Tools: null, + AttachmentIds: null, + Vendor: "codex" + ); + + var json = JsonSerializer.Serialize(cmd, KapacitorJsonContext.Default.LaunchAgentCommand); + var back = JsonSerializer.Deserialize(json, KapacitorJsonContext.Default.LaunchAgentCommand); + + await Assert.That(back.Vendor).IsEqualTo("codex"); + } } From 542c7cdace2a96c7e89287dcb6ccee7d28907483 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 00:18:51 +0200 Subject: [PATCH 19/36] [AI-68] models: required Vendor field on AgentRunStarted --- src/Kapacitor.Core/Models.cs | 3 ++- .../Services/AgentOrchestrator.cs | 3 ++- .../LaunchAgentCommandWireFormatTests.cs | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Kapacitor.Core/Models.cs b/src/Kapacitor.Core/Models.cs index cd121838b3..6d10c85ca9 100644 --- a/src/Kapacitor.Core/Models.cs +++ b/src/Kapacitor.Core/Models.cs @@ -783,7 +783,8 @@ record AgentRunStarted( string? Model, string? Effort, string? RepoPath, - string? WorktreePath + string? WorktreePath, + string Vendor ); record AgentRunStopped( diff --git a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs index 9890091928..384b5880e6 100644 --- a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs +++ b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs @@ -132,6 +132,7 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { var repoPath = cmd.RepoPath; var tools = cmd.Tools; var attachmentIds = cmd.AttachmentIds; + var vendor = cmd.Vendor; var isReview = cmd.Kind == LaunchKind.Review; if (cmd.Vendor is not ("claude" or "codex")) { @@ -344,7 +345,7 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { _ = _server.AppendAgentRunEventAsync( agentId, - new AgentRunStarted(prompt, model, effort, repoPath, worktree.Path) + new AgentRunStarted(prompt, model, effort, repoPath, worktree.Path, vendor) ); // Persist repo path and notify server so launch dialog updates diff --git a/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs b/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs index 8240686e7a..fe730a25e6 100644 --- a/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs +++ b/test/kapacitor.Tests.Unit/LaunchAgentCommandWireFormatTests.cs @@ -106,4 +106,20 @@ public async Task Vendor_field_round_trips_through_json_serializer() { await Assert.That(back.Vendor).IsEqualTo("codex"); } + + [Test] + public async Task AgentRunStarted_vendor_serialises_into_json_body() { + var evt = new AgentRunStarted( + Prompt: "do a thing", + Model: "claude-sonnet-4-6", + Effort: null, + RepoPath: "/tmp/repo", + WorktreePath: "/tmp/wt", + Vendor: "codex" + ); + + var json = JsonSerializer.Serialize(evt, KapacitorJsonContext.Default.AgentRunStarted); + await Assert.That(json).Contains("codex"); + await Assert.That(json.ToLowerInvariant()).Contains("\"vendor\""); + } } From 0d7ed1dda039ba44055f5e0e05e832c9f4cbe063 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 00:30:57 +0200 Subject: [PATCH 20/36] [AI-68] daemon: ServerConnection.RequestPermissionAsync gains vendor param --- src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs | 2 +- src/Kapacitor.Daemon/Services/ServerConnection.cs | 6 ++++-- test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs b/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs index c345e06851..fa02f2c7d4 100644 --- a/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs +++ b/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs @@ -186,7 +186,7 @@ async Task HandleAsync(HttpListenerContext context, CancellationToken ct) { PermissionDecision decision; try { - decision = await server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, ct); + decision = await server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, "claude", ct); } catch (Exception ex) { LogRequestPermissionFailed(logger, ex, sessionId); decision = new PermissionDecision("deny", null, null); diff --git a/src/Kapacitor.Daemon/Services/ServerConnection.cs b/src/Kapacitor.Daemon/Services/ServerConnection.cs index 9532195a8b..7066a831fb 100644 --- a/src/Kapacitor.Daemon/Services/ServerConnection.cs +++ b/src/Kapacitor.Daemon/Services/ServerConnection.cs @@ -285,7 +285,8 @@ public virtual Task RequestPermissionAsync( string? toolName, JsonElement? toolInput, JsonElement? suggestions, - CancellationToken ct + string vendor, + CancellationToken ct = default ) => _hub.InvokeAsync( "RequestPermission", @@ -293,7 +294,8 @@ CancellationToken ct toolName, toolInput, suggestions, - cancellationToken: ct + vendor, + ct ); public Task SendTerminalOutputAsync(string agentId, string base64Data) diff --git a/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs b/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs index b8888af416..7c2fa196dc 100644 --- a/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs +++ b/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs @@ -277,7 +277,8 @@ public override Task RequestPermissionAsync( string? toolName, JsonElement? toolInput, JsonElement? suggestions, - CancellationToken ct + string vendor, + CancellationToken ct = default ) { Calls.Add(new Call(sessionId, toolName, toolInput, suggestions)); From 3b04c855a4fdc761f46baf48c3b67b6e7cae29f5 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 00:36:38 +0200 Subject: [PATCH 21/36] [AI-68] cli: Claude permission-request posts to /claude/permission-request --- src/kapacitor/Commands/PermissionRequestCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kapacitor/Commands/PermissionRequestCommand.cs b/src/kapacitor/Commands/PermissionRequestCommand.cs index 0b579fcbc9..9009e4bfe1 100644 --- a/src/kapacitor/Commands/PermissionRequestCommand.cs +++ b/src/kapacitor/Commands/PermissionRequestCommand.cs @@ -61,7 +61,7 @@ static async Task HandleRenderedAgent(string baseUrl, JsonNode node, string // auth, so an accidentally / maliciously set non-loopback value would leak the // hook payload (tool name, raw tool input) to an arbitrary endpoint. if (TryGetLoopbackDaemonUrl(out var daemonUrl)) { - return await PostAsync(daemonUrl + "/permission-request", payload, authenticated: false); + return await PostAsync(daemonUrl + "/claude/permission-request", payload, authenticated: false); } return await PostAsync(baseUrl + "/hooks/permission-request", payload, authenticated: true); From 1458b3d4b22bd00499d255ea97372de39b528be4 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 00:38:34 +0200 Subject: [PATCH 22/36] [AI-68] daemon: define IHostedAgentLauncher strategy interface --- .../Services/IHostedAgentLauncher.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs diff --git a/src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs b/src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs new file mode 100644 index 0000000000..b606430cc2 --- /dev/null +++ b/src/Kapacitor.Daemon/Services/IHostedAgentLauncher.cs @@ -0,0 +1,56 @@ +using kapacitor; +using kapacitor.Commands; +using kapacitor.Daemon.Pty; + +namespace kapacitor.Daemon.Services; + +/// +/// Vendor-specific launch strategy. handles +/// lifecycle concerns (PTY spawn, status, heartbeat, cleanup); each impl owns +/// the vendor-specific bits: CLI binary path, args, settings overlay, pre-trust, +/// MCP config, and per-vendor cleanup. +/// +internal interface IHostedAgentLauncher { + /// Vendor token this launcher handles ("claude" or "codex"). + string Vendor { get; } + + /// Absolute path or bare command for the CLI. Pulled from DaemonConfig. + string CliPath { get; } + + /// + /// Per-vendor preparation BEFORE the PTY is spawned. Implementations: + /// • Overlay vendor-specific settings dir from source repo into worktree + /// • Pre-trust the worktree path in the vendor's config file + /// • Write any vendor-specific config (MCP, etc.) + /// • Merge dialog-selected tools into vendor-specific permission shape + /// • Run fail-fast preflight checks (e.g. required CLI hooks installed) + /// Two failure modes are supported and the orchestrator distinguishes them: + /// • Filesystem / parse errors are swallowed inside the launcher with a + /// warning log so a settings-overlay glitch never blocks launch. + /// • Typed preflight exceptions (e.g. CodexHooksNotInstalledException) + /// propagate out and the orchestrator converts them into LaunchFailed + /// with the exception's user-facing message. Use these sparingly. + /// + void Prepare(LauncherContext ctx); + + /// Build the argv array passed to the CLI. + LaunchArgs BuildArgs(LauncherContext ctx); + + /// Per-vendor cleanup AFTER the agent exits / is stopped. + void Cleanup(AgentInstance agent); +} + +internal sealed record LauncherContext( + string AgentId, + string SourceRepoPath, + WorktreeInfo Worktree, + string? Prompt, + string Model, + string? Effort, + string[]? Tools, + bool IsReview, + ReviewLaunchInfo? Review, + ReviewLaunchBuilder.ReviewLaunch? ReviewLaunch + ); + +internal readonly record struct LaunchArgs(string[] Args, string? McpConfigPath); From d3f80a910a51d5e5152ca7c084167bc4be913512 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 00:50:38 +0200 Subject: [PATCH 23/36] [AI-68] daemon: WIP extract ClaudeLauncher (next: wire through orchestrator) Co-Authored-By: Claude Sonnet 4.6 --- .../Services/AgentOrchestrator.cs | 408 +----------------- .../Services/ClaudeLauncher.cs | 400 +++++++++++++++++ 2 files changed, 419 insertions(+), 389 deletions(-) create mode 100644 src/Kapacitor.Daemon/Services/ClaudeLauncher.cs diff --git a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs index 384b5880e6..000df94293 100644 --- a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs +++ b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs @@ -1,8 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.RegularExpressions; using kapacitor.Auth; using kapacitor.Commands; @@ -211,56 +209,9 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { worktree = await _worktreeManager.CreateAsync(repoPath, baseRef: baseRef); - // Overlay .claude/ local settings from source repo into worktree. - // git worktree add creates tracked files (e.g. skills/), but gitignored - // local files (.local.md, settings.local.json, MCP configs) are missing. - // We merge them in without overwriting tracked files. - // Best-effort: filesystem errors here should not block agent launch. - try { - var sourceClaudeDir = Path.Combine(repoPath, ".claude"); - var destClaudeDir = Path.Combine(worktree.Path, ".claude"); - - if (Directory.Exists(sourceClaudeDir)) { - OverlayDirectory(sourceClaudeDir, destClaudeDir); - } - - // Symlink ~/.claude/projects/{worktree-path} → ~/.claude/projects/{source-path} - // so that project-level permissions and settings carry over. - SymlinkClaudeProjectDir(repoPath, worktree.Path); - } catch (Exception ex) { - LogOverlayFailed(ex, agentId); - } - - // Write .mcp.json into the worktree so Claude discovers MCP servers. - // Claude stores per-project MCP configs in ~/.claude.json keyed by the - // literal CWD path, so worktrees don't inherit them. Writing a repo-level - // .mcp.json is the simplest way to propagate them. - try { - WriteMcpConfig(repoPath, worktree.Path); - } catch (Exception ex) { - LogMcpConfigFailed(ex, agentId); - } - - // Pre-trust the worktree path in ~/.claude.json. Without this, claude - // blocks on the "Do you trust the files in this folder?" dialog for any - // new cwd — and since the daemon drives claude over a PTY with no - // interactive user, it would hang forever. - try { - TrustWorktreeInClaudeConfig(worktree.Path); - } catch (Exception ex) { - LogTrustWorktreeFailed(ex, agentId); - } - - // Merge dialog-selected tools into the worktree's settings.local.json - // instead of using --allowedTools (which overrides project permissions). - // Best-effort: filesystem/JSON errors should not block agent launch. - try { - if (tools is { Length: > 0 }) { - MergeToolPermissions(worktree.Path, tools); - } - } catch (Exception ex) { - LogToolPermissionsFailed(ex, agentId); - } + // TODO Task 14: dispatch through launcher.Prepare(ctx) + // Originally: OverlayDirectory + SymlinkClaudeProjectDir + WriteMcpConfig + + // TrustWorktreeInClaudeConfig + MergeToolPermissions. Moved to ClaudeLauncher. // Download attachments into worktree (best-effort) if (attachmentIds is { Length: > 0 }) { @@ -276,39 +227,10 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { } } - // Build claude CLI args + // TODO Task 14: dispatch through launcher.BuildArgs(ctx) + // Originally: review-vs-default arg construction. Moved to ClaudeLauncher.BuildArgs. var args = new List(); - if (isReview && cmd.Review is { } reviewArgs) { - var launch = await ReviewLaunchBuilder.BuildAsync(_config.ServerUrl ?? "", reviewArgs.Owner, reviewArgs.Repo, reviewArgs.PrNumber); - mcpConfigPath = launch.McpConfigPath; - - args.Add("--mcp-config"); - args.Add(launch.McpConfigPath); - args.Add("--system-prompt"); - args.Add(launch.SystemPrompt); - - if (!string.IsNullOrEmpty(model)) { - args.Add("--model"); - args.Add(model); - } - } else { - if (!string.IsNullOrEmpty(effort)) { - args.Add("--effort"); - args.Add(effort); - } - - if (!string.IsNullOrEmpty(model)) { - args.Add("--model"); - args.Add(model); - } - - if (!string.IsNullOrEmpty(prompt)) { - args.Add("--"); - args.Add(prompt); - } - } - var env = new Dictionary { ["KAPACITOR_RENDERED_AGENT"] = "1", ["KAPACITOR_AGENT_ID"] = agentId @@ -366,20 +288,18 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { // Clean up worktree if it was created but agent didn't start if (worktree != null) { - try { RemoveClaudeProjectSymlink(worktree.Path); } catch { - /* best-effort */ - } + // TODO Task 14: launcher.Cleanup(agent) — remove symlink via launcher + // try { RemoveClaudeProjectSymlink(worktree.Path); } catch { /* best-effort */ } try { await WorktreeManager.RemoveAsync(worktree); } catch { /* best-effort */ } } - if (mcpConfigPath is not null) { - try { File.Delete(mcpConfigPath); } catch { - /* best-effort */ - } - } + // TODO Task 14: launcher.Cleanup(agent) — remove mcp config via launcher + // if (mcpConfigPath is not null) { + // try { File.Delete(mcpConfigPath); } catch { /* best-effort */ } + // } await _server.LaunchFailedAsync(agentId, ex.Message); } @@ -801,9 +721,8 @@ async Task ReRegisterAgentsAsync() { } } - static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(90); - static readonly TimeSpan MinSessionLifespan = TimeSpan.FromSeconds(2); - static readonly JsonSerializerOptions IndentedJsonOpts = new() { WriteIndented = true }; + static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(90); + static readonly TimeSpan MinSessionLifespan = TimeSpan.FromSeconds(2); /// /// True when the agent process exited before establishing a real interactive @@ -817,10 +736,6 @@ async Task ReRegisterAgentsAsync() { internal static bool IsStartupFailure(DateTime createdAt, DateTime lastOutputAt, bool hasReceivedOutput) => !hasReceivedOutput || lastOutputAt - createdAt < MinSessionLifespan; - // Serializes writes to shared JSON config files (~/.claude.json and per-worktree - // settings.local.json) across concurrent agent launches. Atomic rename prevents - // partial writes from ever being visible. - static readonly Lock TrustWriteLock = new(); static readonly HashSet ValidEffortLevels = ["low", "medium", "high", "max"]; async Task RunHeartbeatLoopAsync(CancellationToken ct) { @@ -869,13 +784,15 @@ async Task CleanupAgentAsync(string agentId) { // Each cleanup step is best-effort so later steps still run try { await agent.Process.DisposeAsync(); } catch (Exception ex) { LogCleanupStepFailed(ex, "disposing process", agentId); } - try { RemoveClaudeProjectSymlink(agent.Worktree.Path); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing symlink", agentId); } + // TODO Task 14: launcher.Cleanup(agent) + // try { RemoveClaudeProjectSymlink(agent.Worktree.Path); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing symlink", agentId); } try { await WorktreeManager.RemoveAsync(agent.Worktree); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing worktree", agentId); } - if (agent.McpConfigPath is not null) { - try { File.Delete(agent.McpConfigPath); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing mcp config", agentId); } - } + // TODO Task 14: launcher.Cleanup(agent) + // if (agent.McpConfigPath is not null) { + // try { File.Delete(agent.McpConfigPath); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing mcp config", agentId); } + // } try { await _server.AgentUnregisteredAsync(agentId); } catch (Exception ex) { LogCleanupStepFailed(ex, "unregistering", agentId); } } @@ -900,281 +817,6 @@ public async ValueTask DisposeAsync() { _daemonHeartbeat.Dispose(); } - /// - /// Merges dialog-selected tool names into the worktree's .claude/settings.local.json - /// permissions.allow array. Existing granular rules (e.g. "Bash(git:*)") are preserved; - /// dialog selections add broad tool-level entries (e.g. "Bash"). - /// - internal static void MergeToolPermissions(string worktreePath, string[] tools) { - var settingsPath = Path.Combine(worktreePath, ".claude", "settings.local.json"); - - JsonNode? root = null; - - if (File.Exists(settingsPath)) { - try { - root = JsonNode.Parse(File.ReadAllText(settingsPath)); - } catch { - // Malformed JSON — start fresh - } - } - - if (root is not JsonObject rootObj) { - rootObj = []; - } - - if (rootObj["permissions"] is not JsonObject permissions) { - permissions = []; - rootObj["permissions"] = permissions; - } - - if (permissions["allow"] is not JsonArray allow) { - allow = []; - permissions["allow"] = allow; - } - - var existing = new HashSet( - allow.Select(n => (n as JsonValue)?.TryGetValue(out var s) == true ? s : null) - .Where(s => s != null)! - ); - - foreach (var tool in tools) { - if (!existing.Contains(tool)) { - allow.Add((JsonNode)JsonValue.Create(tool)!); - existing.Add(tool); - } - } - - Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); - - File.WriteAllText(settingsPath, rootObj.ToJsonString(IndentedJsonOpts)); - } - - /// - /// Copies files from into , - /// creating directories as needed but never overwriting existing files. - /// This preserves git-tracked files while adding gitignored local settings. - /// - static void OverlayDirectory(string source, string dest) { - Directory.CreateDirectory(dest); - - foreach (var file in Directory.GetFiles(source)) { - var destFile = Path.Combine(dest, Path.GetFileName(file)); - - if (!File.Exists(destFile)) { - File.Copy(file, destFile); - } - } - - foreach (var dir in Directory.GetDirectories(source)) { - var destDir = Path.Combine(dest, Path.GetFileName(dir)); - OverlayDirectory(dir, destDir); - } - } - - /// - /// Creates a symlink at ~/.claude/projects/{worktree-path-hash} pointing to - /// ~/.claude/projects/{source-path-hash} so project-level permissions, settings, - /// and memory are shared with the hosted agent. - /// - static void SymlinkClaudeProjectDir(string sourceRepoPath, string worktreePath) { - if (!Directory.Exists(ClaudePaths.Projects)) { - return; - } - - var sourceProjDir = ClaudePaths.ProjectDir(sourceRepoPath); - - if (!Directory.Exists(sourceProjDir)) { - return; - } - - var worktreeProjDir = ClaudePaths.ProjectDir(worktreePath); - - // Don't clobber an existing directory or symlink - if (Path.Exists(worktreeProjDir)) { - return; - } - - Directory.CreateSymbolicLink(worktreeProjDir, sourceProjDir); - } - - /// - /// Removes the ~/.claude/projects/{worktree-path-hash} symlink if it exists. - /// Only removes symlinks, never real directories. - /// - static void RemoveClaudeProjectSymlink(string worktreePath) { - var info = new DirectoryInfo(ClaudePaths.ProjectDir(worktreePath)); - - if (info is { Exists: true, LinkTarget: not null }) { - info.Delete(); - } - } - - static void TrustWorktreeInClaudeConfig(string worktreePath) { - // Serialize against concurrent agent launches. ~/.claude.json is shared - // across the whole user and {worktree}/.claude/settings.local.json is - // touched by MergeToolPermissions on the same path; interleaved reads and - // writes could drop updates or write truncated JSON. - lock (TrustWriteLock) { - // Workspace trust (the "Do you trust the files in this folder?" dialog) - // is stored globally in ~/.claude.json under projects[path]. On a fresh - // machine the file may not exist yet — create a minimal object so trust - // is always persisted. - var claudeJsonPath = Path.Combine(PathHelpers.HomeDirectory, ".claude.json"); - var root = LoadJsonObject(claudeJsonPath); - - if (root["projects"] is not JsonObject projects) { - projects = []; - root["projects"] = projects; - } - - if (projects[worktreePath] is not JsonObject entry) { - entry = []; - projects[worktreePath] = entry; - } - - var alreadyTrusted = entry["hasTrustDialogAccepted"] is JsonValue v && v.TryGetValue(out var b) && b; - - if (!alreadyTrusted) { - entry["hasTrustDialogAccepted"] = true; - WriteJsonAtomic(claudeJsonPath, root); - } - - // MCP server approval (the "New MCP server found in .mcp.json" dialog) - // is stored per-project in {worktree}/.claude/settings.local.json, NOT in - // ~/.claude.json. Claude requires BOTH fields: the blanket flag AND each - // server listed by name (the blanket flag alone still shows a per-server - // discovery prompt on first encounter). - var serverNames = ReadMcpJsonServerNames(worktreePath); - - if (serverNames.Count == 0) return; - - var settingsDir = Path.Combine(worktreePath, ".claude"); - var settingsPath = Path.Combine(settingsDir, "settings.local.json"); - - Directory.CreateDirectory(settingsDir); - - var settings = LoadJsonObject(settingsPath); - var sDirty = false; - - var allEnabled = settings["enableAllProjectMcpServers"] is JsonValue ev - && ev.TryGetValue(out var eb) - && eb; - - if (!allEnabled) { - settings["enableAllProjectMcpServers"] = true; - sDirty = true; - } - - var known = new HashSet(); - - if (settings["enabledMcpjsonServers"] is JsonArray arr) { - foreach (var item in arr) { - if (item is JsonValue jv && jv.TryGetValue(out var s)) { - known.Add(s); - } - } - } - - if (serverNames.Any(known.Add)) { - // Rebuild the array via JSON parsing to avoid AOT issues with - // JsonArray.Add(string) / JsonValue.Create. - var json = "[" + string.Join(",", known.Select(n => $"\"{JsonEncodedText.Encode(n)}\"")) + "]"; - settings["enabledMcpjsonServers"] = JsonNode.Parse(json); - sDirty = true; - } - - if (sDirty) { - WriteJsonAtomic(settingsPath, settings); - } - } - } - - /// - /// Loads a JSON object from disk, tolerating missing files and non-object roots - /// by returning a fresh . Ensures pre-trust logic never - /// throws on an unexpected config shape and reintroduces the launch hang. - /// - static JsonObject LoadJsonObject(string path) { - if (!File.Exists(path)) return new JsonObject(); - - try { - return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject(); - } catch { - return new JsonObject(); - } - } - - /// - /// Writes JSON to via a sibling temp file + atomic - /// rename (POSIX rename(2) / Win32 MoveFileEx with replace). - /// A crash or concurrent reader never sees a truncated file — either the - /// previous contents or the new contents, never a partial write. - /// - static void WriteJsonAtomic(string path, JsonNode root) { - var tmp = path + ".tmp-" + Environment.ProcessId + "-" + Guid.NewGuid().ToString("N"); - File.WriteAllText(tmp, root.ToJsonString(IndentedJsonOpts)); - - try { - File.Move(tmp, path, overwrite: true); - } catch { - try { File.Delete(tmp); } catch { - /* best-effort */ - } - - throw; - } - } - - static List ReadMcpJsonServerNames(string worktreePath) { - var mcpJsonPath = Path.Combine(worktreePath, ".mcp.json"); - - if (!File.Exists(mcpJsonPath)) return []; - - try { - var parsed = JsonNode.Parse(File.ReadAllText(mcpJsonPath)); - var servers = parsed?["mcpServers"]?.AsObject(); - - return servers is null ? [] : [..servers.Select(kv => kv.Key)]; - } catch { - return []; - } - } - - static void WriteMcpConfig(string sourceRepoPath, string worktreePath) { - var claudeJsonPath = Path.Combine(PathHelpers.HomeDirectory, ".claude.json"); - - if (!File.Exists(claudeJsonPath)) return; - - var root = JsonNode.Parse(File.ReadAllText(claudeJsonPath)); - var servers = root?["projects"]?[sourceRepoPath]?["mcpServers"]?.AsObject(); - - if (servers is null || servers.Count == 0) return; - - var mcpJsonPath = Path.Combine(worktreePath, ".mcp.json"); - - // Read existing .mcp.json if present (e.g. committed to git) - JsonObject merged; - - if (File.Exists(mcpJsonPath)) { - var existing = JsonNode.Parse(File.ReadAllText(mcpJsonPath)); - merged = existing?["mcpServers"]?.AsObject() ?? new JsonObject(); - } else { - merged = new JsonObject(); - } - - // Add servers from ~/.claude.json (don't overwrite repo-committed ones) - foreach (var (name, value) in servers) { - if (!merged.ContainsKey(name) && value is not null) { - var clone = value.DeepClone().AsObject(); - clone.Remove("env"); - merged[name] = clone; - } - } - - var wrapper = new JsonObject { ["mcpServers"] = merged }; - File.WriteAllText(mcpJsonPath, wrapper.ToJsonString(IndentedJsonOpts)); - } - /// /// Extracts readable text from the terminal output buffer by decoding UTF-8 /// and stripping ANSI escape sequences. Returns the last ~500 chars to keep @@ -1215,18 +857,6 @@ static string ExtractTerminalText(TerminalOutputBuffer buffer) { [LoggerMessage(Level = LogLevel.Information, Message = "Stopping agent {AgentId}")] partial void LogStopping(string agentId); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to overlay .claude settings for agent {AgentId} (continuing)")] - partial void LogOverlayFailed(Exception ex, string agentId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to merge tool permissions for agent {AgentId} (continuing)")] - partial void LogToolPermissionsFailed(Exception ex, string agentId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to write .mcp.json for agent {AgentId} (continuing)")] - partial void LogMcpConfigFailed(Exception ex, string agentId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to pre-trust worktree for agent {AgentId} (continuing)")] - partial void LogTrustWorktreeFailed(Exception ex, string agentId); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to download launch attachments for agent {AgentId} (continuing)")] partial void LogAttachmentDownloadFailed(Exception ex, string agentId); diff --git a/src/Kapacitor.Daemon/Services/ClaudeLauncher.cs b/src/Kapacitor.Daemon/Services/ClaudeLauncher.cs new file mode 100644 index 0000000000..a97d7add01 --- /dev/null +++ b/src/Kapacitor.Daemon/Services/ClaudeLauncher.cs @@ -0,0 +1,400 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using kapacitor; +using kapacitor.Commands; +using Microsoft.Extensions.Logging; + +namespace kapacitor.Daemon.Services; + +internal sealed partial class ClaudeLauncher( + DaemonConfig config, + ILogger logger + ) : IHostedAgentLauncher { + + public string Vendor => "claude"; + public string CliPath => config.ClaudePath; + + static readonly Lock TrustWriteLock = new(); + static readonly JsonSerializerOptions IndentedJsonOpts = new() { WriteIndented = true }; + + public void Prepare(LauncherContext ctx) { + // Overlay .claude/ local settings from source repo into worktree. + try { + var sourceClaudeDir = Path.Combine(ctx.SourceRepoPath, ".claude"); + var destClaudeDir = Path.Combine(ctx.Worktree.Path, ".claude"); + + if (Directory.Exists(sourceClaudeDir)) { + OverlayDirectory(sourceClaudeDir, destClaudeDir); + } + + SymlinkClaudeProjectDir(ctx.SourceRepoPath, ctx.Worktree.Path); + } catch (Exception ex) { + LogOverlayFailed(ex, ctx.AgentId); + } + + try { + WriteMcpConfig(ctx.SourceRepoPath, ctx.Worktree.Path); + } catch (Exception ex) { + LogMcpConfigFailed(ex, ctx.AgentId); + } + + try { + TrustWorktreeInClaudeConfig(ctx.Worktree.Path); + } catch (Exception ex) { + LogTrustWorktreeFailed(ex, ctx.AgentId); + } + + try { + if (ctx.Tools is { Length: > 0 }) { + MergeToolPermissions(ctx.Worktree.Path, ctx.Tools); + } + } catch (Exception ex) { + LogToolPermissionsFailed(ex, ctx.AgentId); + } + } + + public LaunchArgs BuildArgs(LauncherContext ctx) { + var args = new List(); + string? mcpConfigPath = null; + + if (ctx.IsReview && ctx.ReviewLaunch is { } launch) { + mcpConfigPath = launch.McpConfigPath; + + args.Add("--mcp-config"); + args.Add(launch.McpConfigPath); + args.Add("--system-prompt"); + args.Add(launch.SystemPrompt); + + if (!string.IsNullOrEmpty(ctx.Model)) { + args.Add("--model"); + args.Add(ctx.Model); + } + } else { + if (!string.IsNullOrEmpty(ctx.Effort)) { + args.Add("--effort"); + args.Add(ctx.Effort); + } + + if (!string.IsNullOrEmpty(ctx.Model)) { + args.Add("--model"); + args.Add(ctx.Model); + } + + if (!string.IsNullOrEmpty(ctx.Prompt)) { + args.Add("--"); + args.Add(ctx.Prompt); + } + } + + return new LaunchArgs(args.ToArray(), mcpConfigPath); + } + + public void Cleanup(AgentInstance agent) { + try { RemoveClaudeProjectSymlink(agent.Worktree.Path); } catch (Exception ex) { + LogCleanupSymlinkFailed(ex, agent.Id); + } + + if (agent.McpConfigPath is not null) { + try { File.Delete(agent.McpConfigPath); } catch (Exception ex) { + LogCleanupMcpConfigFailed(ex, agent.Id); + } + } + } + + // === Moved verbatim from AgentOrchestrator.cs === + + /// + /// Merges dialog-selected tool names into the worktree's .claude/settings.local.json + /// permissions.allow array. Existing granular rules (e.g. "Bash(git:*)") are preserved; + /// dialog selections add broad tool-level entries (e.g. "Bash"). + /// + internal static void MergeToolPermissions(string worktreePath, string[] tools) { + var settingsPath = Path.Combine(worktreePath, ".claude", "settings.local.json"); + + JsonNode? root = null; + + if (File.Exists(settingsPath)) { + try { + root = JsonNode.Parse(File.ReadAllText(settingsPath)); + } catch { + // Malformed JSON — start fresh + } + } + + if (root is not JsonObject rootObj) { + rootObj = []; + } + + if (rootObj["permissions"] is not JsonObject permissions) { + permissions = []; + rootObj["permissions"] = permissions; + } + + if (permissions["allow"] is not JsonArray allow) { + allow = []; + permissions["allow"] = allow; + } + + var existing = new HashSet( + allow.Select(n => (n as JsonValue)?.TryGetValue(out var s) == true ? s : null) + .Where(s => s != null)! + ); + + foreach (var tool in tools) { + if (!existing.Contains(tool)) { + allow.Add((JsonNode)JsonValue.Create(tool)!); + existing.Add(tool); + } + } + + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); + + File.WriteAllText(settingsPath, rootObj.ToJsonString(IndentedJsonOpts)); + } + + /// + /// Copies files from into , + /// creating directories as needed but never overwriting existing files. + /// This preserves git-tracked files while adding gitignored local settings. + /// + static void OverlayDirectory(string source, string dest) { + Directory.CreateDirectory(dest); + + foreach (var file in Directory.GetFiles(source)) { + var destFile = Path.Combine(dest, Path.GetFileName(file)); + + if (!File.Exists(destFile)) { + File.Copy(file, destFile); + } + } + + foreach (var dir in Directory.GetDirectories(source)) { + var destDir = Path.Combine(dest, Path.GetFileName(dir)); + OverlayDirectory(dir, destDir); + } + } + + /// + /// Creates a symlink at ~/.claude/projects/{worktree-path-hash} pointing to + /// ~/.claude/projects/{source-path-hash} so project-level permissions, settings, + /// and memory are shared with the hosted agent. + /// + static void SymlinkClaudeProjectDir(string sourceRepoPath, string worktreePath) { + if (!Directory.Exists(ClaudePaths.Projects)) { + return; + } + + var sourceProjDir = ClaudePaths.ProjectDir(sourceRepoPath); + + if (!Directory.Exists(sourceProjDir)) { + return; + } + + var worktreeProjDir = ClaudePaths.ProjectDir(worktreePath); + + // Don't clobber an existing directory or symlink + if (Path.Exists(worktreeProjDir)) { + return; + } + + Directory.CreateSymbolicLink(worktreeProjDir, sourceProjDir); + } + + /// + /// Removes the ~/.claude/projects/{worktree-path-hash} symlink if it exists. + /// Only removes symlinks, never real directories. + /// + static void RemoveClaudeProjectSymlink(string worktreePath) { + var info = new DirectoryInfo(ClaudePaths.ProjectDir(worktreePath)); + + if (info is { Exists: true, LinkTarget: not null }) { + info.Delete(); + } + } + + static void TrustWorktreeInClaudeConfig(string worktreePath) { + // Serialize against concurrent agent launches. ~/.claude.json is shared + // across the whole user and {worktree}/.claude/settings.local.json is + // touched by MergeToolPermissions on the same path; interleaved reads and + // writes could drop updates or write truncated JSON. + lock (TrustWriteLock) { + // Workspace trust (the "Do you trust the files in this folder?" dialog) + // is stored globally in ~/.claude.json under projects[path]. On a fresh + // machine the file may not exist yet — create a minimal object so trust + // is always persisted. + var claudeJsonPath = Path.Combine(PathHelpers.HomeDirectory, ".claude.json"); + var root = LoadJsonObject(claudeJsonPath); + + if (root["projects"] is not JsonObject projects) { + projects = []; + root["projects"] = projects; + } + + if (projects[worktreePath] is not JsonObject entry) { + entry = []; + projects[worktreePath] = entry; + } + + var alreadyTrusted = entry["hasTrustDialogAccepted"] is JsonValue v && v.TryGetValue(out var b) && b; + + if (!alreadyTrusted) { + entry["hasTrustDialogAccepted"] = true; + WriteJsonAtomic(claudeJsonPath, root); + } + + // MCP server approval (the "New MCP server found in .mcp.json" dialog) + // is stored per-project in {worktree}/.claude/settings.local.json, NOT in + // ~/.claude.json. Claude requires BOTH fields: the blanket flag AND each + // server listed by name (the blanket flag alone still shows a per-server + // discovery prompt on first encounter). + var serverNames = ReadMcpJsonServerNames(worktreePath); + + if (serverNames.Count == 0) return; + + var settingsDir = Path.Combine(worktreePath, ".claude"); + var settingsPath = Path.Combine(settingsDir, "settings.local.json"); + + Directory.CreateDirectory(settingsDir); + + var settings = LoadJsonObject(settingsPath); + var sDirty = false; + + var allEnabled = settings["enableAllProjectMcpServers"] is JsonValue ev + && ev.TryGetValue(out var eb) + && eb; + + if (!allEnabled) { + settings["enableAllProjectMcpServers"] = true; + sDirty = true; + } + + var known = new HashSet(); + + if (settings["enabledMcpjsonServers"] is JsonArray arr) { + foreach (var item in arr) { + if (item is JsonValue jv && jv.TryGetValue(out var s)) { + known.Add(s); + } + } + } + + if (serverNames.Any(known.Add)) { + // Rebuild the array via JSON parsing to avoid AOT issues with + // JsonArray.Add(string) / JsonValue.Create. + var json = "[" + string.Join(",", known.Select(n => $"\"{JsonEncodedText.Encode(n)}\"")) + "]"; + settings["enabledMcpjsonServers"] = JsonNode.Parse(json); + sDirty = true; + } + + if (sDirty) { + WriteJsonAtomic(settingsPath, settings); + } + } + } + + /// + /// Loads a JSON object from disk, tolerating missing files and non-object roots + /// by returning a fresh . Ensures pre-trust logic never + /// throws on an unexpected config shape and reintroduces the launch hang. + /// + static JsonObject LoadJsonObject(string path) { + if (!File.Exists(path)) return new JsonObject(); + + try { + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject(); + } catch { + return new JsonObject(); + } + } + + /// + /// Writes JSON to via a sibling temp file + atomic + /// rename (POSIX rename(2) / Win32 MoveFileEx with replace). + /// A crash or concurrent reader never sees a truncated file — either the + /// previous contents or the new contents, never a partial write. + /// + static void WriteJsonAtomic(string path, JsonNode root) { + var tmp = path + ".tmp-" + Environment.ProcessId + "-" + Guid.NewGuid().ToString("N"); + File.WriteAllText(tmp, root.ToJsonString(IndentedJsonOpts)); + + try { + File.Move(tmp, path, overwrite: true); + } catch { + try { File.Delete(tmp); } catch { + /* best-effort */ + } + + throw; + } + } + + static List ReadMcpJsonServerNames(string worktreePath) { + var mcpJsonPath = Path.Combine(worktreePath, ".mcp.json"); + + if (!File.Exists(mcpJsonPath)) return []; + + try { + var parsed = JsonNode.Parse(File.ReadAllText(mcpJsonPath)); + var servers = parsed?["mcpServers"]?.AsObject(); + + return servers is null ? [] : [..servers.Select(kv => kv.Key)]; + } catch { + return []; + } + } + + static void WriteMcpConfig(string sourceRepoPath, string worktreePath) { + var claudeJsonPath = Path.Combine(PathHelpers.HomeDirectory, ".claude.json"); + + if (!File.Exists(claudeJsonPath)) return; + + var root = JsonNode.Parse(File.ReadAllText(claudeJsonPath)); + var servers = root?["projects"]?[sourceRepoPath]?["mcpServers"]?.AsObject(); + + if (servers is null || servers.Count == 0) return; + + var mcpJsonPath = Path.Combine(worktreePath, ".mcp.json"); + + // Read existing .mcp.json if present (e.g. committed to git) + JsonObject merged; + + if (File.Exists(mcpJsonPath)) { + var existing = JsonNode.Parse(File.ReadAllText(mcpJsonPath)); + merged = existing?["mcpServers"]?.AsObject() ?? new JsonObject(); + } else { + merged = new JsonObject(); + } + + // Add servers from ~/.claude.json (don't overwrite repo-committed ones) + foreach (var (name, value) in servers) { + if (!merged.ContainsKey(name) && value is not null) { + var clone = value.DeepClone().AsObject(); + clone.Remove("env"); + merged[name] = clone; + } + } + + var wrapper = new JsonObject { ["mcpServers"] = merged }; + File.WriteAllText(mcpJsonPath, wrapper.ToJsonString(IndentedJsonOpts)); + } + + // === LoggerMessage source-generated methods === + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to overlay .claude settings for agent {AgentId} (continuing)")] + partial void LogOverlayFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to write .mcp.json for agent {AgentId} (continuing)")] + partial void LogMcpConfigFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to pre-trust worktree for agent {AgentId} (continuing)")] + partial void LogTrustWorktreeFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to merge tool permissions for agent {AgentId} (continuing)")] + partial void LogToolPermissionsFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to remove claude project symlink for agent {AgentId}")] + partial void LogCleanupSymlinkFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to remove mcp config for agent {AgentId}")] + partial void LogCleanupMcpConfigFailed(Exception ex, string agentId); +} From 2bbab72ff8179555ead62789ed0c0953b271aeff Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 01:04:05 +0200 Subject: [PATCH 24/36] [AI-68] daemon: AgentInstance carries Vendor for cleanup dispatch --- src/Kapacitor.Daemon/Services/AgentOrchestrator.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs index 000df94293..c35703f123 100644 --- a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs +++ b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs @@ -16,6 +16,7 @@ public record AgentInstance( string Model, string? Effort, string RepoPath, + string Vendor, IPtyProcess Process, WorktreeInfo Worktree, CancellationTokenSource ReadCts @@ -257,7 +258,7 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { LogAgentSpawned(agentId, process.Pid, worktree.Path, _config.ClaudePath); var cts = new CancellationTokenSource(); - var agent = new AgentInstance(agentId, prompt, model, effort, repoPath, process, worktree, cts) { + var agent = new AgentInstance(agentId, prompt, model, effort, repoPath, cmd.Vendor, process, worktree, cts) { McpConfigPath = mcpConfigPath }; _agents[agentId] = agent; From 96640191e5486f003779fa64c1a0aaf9313d40cd Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 01:19:19 +0200 Subject: [PATCH 25/36] [AI-68] daemon: orchestrator dispatches launches through IHostedAgentLauncher HandleLaunchAgent now looks up the vendor-specific launcher from a constructor-injected dictionary, invokes Prepare/BuildArgs/Cleanup, and surfaces CodexHooksNotInstalledException as an actionable LaunchFailed. CleanupAgentAsync and the failed-launch catch block dispatch teardown through the same dictionary, with a NoopPtyProcess stand-in so the launcher can run without a live PTY. A few ServerConnection methods become virtual so tests can override them. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Kapacitor.Daemon/DaemonRunner.cs | 9 + .../Services/AgentOrchestrator.cs | 164 ++++++-- .../Services/ServerConnection.cs | 14 +- .../AgentOrchestratorVendorTests.cs | 387 ++++++++++++++++++ 4 files changed, 525 insertions(+), 49 deletions(-) create mode 100644 test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs diff --git a/src/Kapacitor.Daemon/DaemonRunner.cs b/src/Kapacitor.Daemon/DaemonRunner.cs index c806d31e7a..da0283ab5c 100644 --- a/src/Kapacitor.Daemon/DaemonRunner.cs +++ b/src/Kapacitor.Daemon/DaemonRunner.cs @@ -118,6 +118,15 @@ public static async Task RunAsync(string[] args) { builder.Services.AddSingleton(); builder.Services.AddHttpClient("Attachments", client => client.BaseAddress = new Uri(config.ServerUrl)); + + // Task 14: orchestrator dispatches launches through this dictionary. Task 19 + // wires the concrete IHostedAgentLauncher impls (Claude, Codex) into DI. + // Until then, the dictionary resolves to whatever ToDictionary sees — empty + // if none registered. Production hosted launches stay broken until Task 19. + builder.Services.AddSingleton>(sp => + sp.GetServices().ToDictionary(l => l.Vendor) + ); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs index c35703f123..03becbfcdc 100644 --- a/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs +++ b/src/Kapacitor.Daemon/Services/AgentOrchestrator.cs @@ -65,16 +65,17 @@ public List GetAll() { } internal partial class AgentOrchestrator : IAsyncDisposable { - readonly ConcurrentDictionary _agents = new(); - readonly DaemonConfig _config; - readonly ServerConnection _server; - readonly WorktreeManager _worktreeManager; - readonly RepoMatcher _repoMatcher; - readonly IPtyProcessFactory _ptyFactory; - readonly IHttpClientFactory _httpClientFactory; - readonly LocalPermissionBridge _permissionBridge; - readonly ILogger _logger; - readonly PeriodicTimer _heartbeatTimer = new(TimeSpan.FromSeconds(30)); + readonly ConcurrentDictionary _agents = new(); + readonly DaemonConfig _config; + readonly ServerConnection _server; + readonly WorktreeManager _worktreeManager; + readonly RepoMatcher _repoMatcher; + readonly IPtyProcessFactory _ptyFactory; + readonly IHttpClientFactory _httpClientFactory; + readonly LocalPermissionBridge _permissionBridge; + readonly IReadOnlyDictionary _launchers; + readonly ILogger _logger; + readonly PeriodicTimer _heartbeatTimer = new(TimeSpan.FromSeconds(30)); // AI-79: heartbeat tightened from 60 s SendAsync to 15 s round-trip Ping. // Server's default ClientTimeoutInterval is 30 s, and the staging incident // showed daemons holding a displaced slot for nearly a minute before @@ -84,14 +85,15 @@ internal partial class AgentOrchestrator : IAsyncDisposable { readonly CancellationTokenSource _shutdownCts = new(); public AgentOrchestrator( - DaemonConfig config, - ServerConnection server, - WorktreeManager worktreeManager, - RepoMatcher repoMatcher, - IPtyProcessFactory ptyFactory, - IHttpClientFactory httpClientFactory, - LocalPermissionBridge permissionBridge, - ILogger logger + DaemonConfig config, + ServerConnection server, + WorktreeManager worktreeManager, + RepoMatcher repoMatcher, + IPtyProcessFactory ptyFactory, + IHttpClientFactory httpClientFactory, + LocalPermissionBridge permissionBridge, + IReadOnlyDictionary launchers, + ILogger logger ) { _config = config; _server = server; @@ -100,6 +102,7 @@ ILogger logger _ptyFactory = ptyFactory; _httpClientFactory = httpClientFactory; _permissionBridge = permissionBridge; + _launchers = launchers; _logger = logger; // Wire up server commands @@ -139,6 +142,16 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { return; } + if (!_launchers.TryGetValue(cmd.Vendor, out var launcher)) { + await _server.LaunchFailedAsync(cmd.AgentId, $"No launcher registered for vendor: {cmd.Vendor}"); + return; + } + + if (isReview && cmd.Vendor == "codex") { + await _server.LaunchFailedAsync(cmd.AgentId, "PR review for Codex is not yet supported"); + return; + } + WorktreeInfo? worktree = null; string? mcpConfigPath = null; @@ -210,10 +223,6 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { worktree = await _worktreeManager.CreateAsync(repoPath, baseRef: baseRef); - // TODO Task 14: dispatch through launcher.Prepare(ctx) - // Originally: OverlayDirectory + SymlinkClaudeProjectDir + WriteMcpConfig + - // TrustWorktreeInClaudeConfig + MergeToolPermissions. Moved to ClaudeLauncher. - // Download attachments into worktree (best-effort) if (attachmentIds is { Length: > 0 }) { try { @@ -228,9 +237,35 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { } } - // TODO Task 14: dispatch through launcher.BuildArgs(ctx) - // Originally: review-vs-default arg construction. Moved to ClaudeLauncher.BuildArgs. - var args = new List(); + var launcherCtx = new LauncherContext( + AgentId: agentId, + SourceRepoPath: repoPath, + Worktree: worktree, + Prompt: prompt, + Model: model, + Effort: effort, + Tools: tools, + IsReview: isReview, + Review: cmd.Review, + ReviewLaunch: isReview && cmd.Review is { } reviewArgs + ? await ReviewLaunchBuilder.BuildAsync(_config.ServerUrl ?? "", reviewArgs.Owner, reviewArgs.Repo, reviewArgs.PrNumber) + : null + ); + + try { + launcher.Prepare(launcherCtx); + } catch (CodexHooksNotInstalledException ex) { + await _server.LaunchFailedAsync(agentId, ex.Message); + // Still need to clean up the worktree before returning + try { await WorktreeManager.RemoveAsync(worktree); } catch { /* best-effort */ } + return; + } catch (Exception ex) { + LogPrepareSoftFailure(ex, agentId); + } + + var launchArgs = launcher.BuildArgs(launcherCtx); + var args = launchArgs.Args; + mcpConfigPath = launchArgs.McpConfigPath; var env = new Dictionary { ["KAPACITOR_RENDERED_AGENT"] = "1", @@ -253,9 +288,9 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { env["KAPACITOR_REVIEW_PR"] = reviewEnv.PrNumber.ToString(); } - var process = _ptyFactory.Spawn(_config.ClaudePath, args.ToArray(), worktree.Path, env); + var process = _ptyFactory.Spawn(launcher.CliPath, args, worktree.Path, env); - LogAgentSpawned(agentId, process.Pid, worktree.Path, _config.ClaudePath); + LogAgentSpawned(agentId, process.Pid, worktree.Path, launcher.CliPath); var cts = new CancellationTokenSource(); var agent = new AgentInstance(agentId, prompt, model, effort, repoPath, cmd.Vendor, process, worktree, cts) { @@ -287,21 +322,28 @@ async Task HandleLaunchAgent(LaunchAgentCommand cmd) { } catch (Exception ex) { LogLaunchFailed(ex, agentId); - // Clean up worktree if it was created but agent didn't start if (worktree != null) { - // TODO Task 14: launcher.Cleanup(agent) — remove symlink via launcher - // try { RemoveClaudeProjectSymlink(worktree.Path); } catch { /* best-effort */ } + if (_launchers.TryGetValue(cmd.Vendor, out var launcherForCleanup)) { + try { + // Build a transient AgentInstance with a no-op PTY just so launcher.Cleanup + // can run its symlink/mcp-config teardown without a live agent. + var transient = new AgentInstance( + agentId, prompt, model, effort, repoPath, cmd.Vendor, + NoopPtyProcess.Instance, worktree, new CancellationTokenSource() + ) { + McpConfigPath = mcpConfigPath + }; + launcherForCleanup.Cleanup(transient); + } catch (Exception cleanupEx) { + LogCleanupStepFailed(cleanupEx, "launcher.Cleanup (failed-launch)", agentId); + } + } try { await WorktreeManager.RemoveAsync(worktree); } catch { /* best-effort */ } } - // TODO Task 14: launcher.Cleanup(agent) — remove mcp config via launcher - // if (mcpConfigPath is not null) { - // try { File.Delete(mcpConfigPath); } catch { /* best-effort */ } - // } - await _server.LaunchFailedAsync(agentId, ex.Message); } } @@ -785,16 +827,12 @@ async Task CleanupAgentAsync(string agentId) { // Each cleanup step is best-effort so later steps still run try { await agent.Process.DisposeAsync(); } catch (Exception ex) { LogCleanupStepFailed(ex, "disposing process", agentId); } - // TODO Task 14: launcher.Cleanup(agent) - // try { RemoveClaudeProjectSymlink(agent.Worktree.Path); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing symlink", agentId); } + if (_launchers.TryGetValue(agent.Vendor, out var launcher)) { + try { launcher.Cleanup(agent); } catch (Exception ex) { LogCleanupStepFailed(ex, "launcher.Cleanup", agentId); } + } try { await WorktreeManager.RemoveAsync(agent.Worktree); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing worktree", agentId); } - // TODO Task 14: launcher.Cleanup(agent) - // if (agent.McpConfigPath is not null) { - // try { File.Delete(agent.McpConfigPath); } catch (Exception ex) { LogCleanupStepFailed(ex, "removing mcp config", agentId); } - // } - try { await _server.AgentUnregisteredAsync(agentId); } catch (Exception ex) { LogCleanupStepFailed(ex, "unregistering", agentId); } } @@ -885,6 +923,9 @@ static string ExtractTerminalText(TerminalOutputBuffer buffer) { [LoggerMessage(Level = LogLevel.Warning, Message = "Error {Step} for agent {AgentId}")] partial void LogCleanupStepFailed(Exception ex, string step, string agentId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Launcher Prepare soft-failure for agent {AgentId} (continuing)")] + partial void LogPrepareSoftFailure(Exception ex, string agentId); + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to launch agent {AgentId}")] partial void LogLaunchFailed(Exception ex, string agentId); @@ -914,4 +955,43 @@ static string ExtractTerminalText(TerminalOutputBuffer buffer) { [GeneratedRegex(@"\x1B\[[0-9;]*[A-Za-z]|\x1B\].*?\x07|\x1B[()][AB012]|\x1B\[[\?]?[0-9;]*[hlm]")] private static partial Regex StripAnsiRegex(); + + /// + /// Method exposed for unit tests so they can drive + /// without going through SignalR. Keeps the private handler private to everyone else. + /// + internal Task HandleLaunchAgentForTest(LaunchAgentCommand cmd) => HandleLaunchAgent(cmd); +} + +/// +/// Stand-in for failed-launch cleanup paths where we +/// need an to satisfy launcher.Cleanup but no +/// live PTY ever existed. All members are no-ops; the launcher only reads +/// and . +/// +internal sealed class NoopPtyProcess : IPtyProcess { + public static readonly NoopPtyProcess Instance = new(); + NoopPtyProcess() { } + + public int Pid => 0; + public bool HasExited => true; + public int? ExitCode => 0; + + public ValueTask DisposeAsync() => default; + + public Task WaitForExitAsync(TimeSpan? timeout = null) => Task.CompletedTask; + public Task TerminateAsync(TimeSpan? timeout = null) => Task.CompletedTask; + +#pragma warning disable CS1998 + public async IAsyncEnumerable ReadOutputAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default + ) { + yield break; + } +#pragma warning restore CS1998 + + public Task WriteAsync(string input) => Task.CompletedTask; + public Task WriteAsync(byte[] data) => Task.CompletedTask; + public void Resize(ushort cols, ushort rows) { } + public void SendInterrupt() { } } diff --git a/src/Kapacitor.Daemon/Services/ServerConnection.cs b/src/Kapacitor.Daemon/Services/ServerConnection.cs index 7066a831fb..580da91bf1 100644 --- a/src/Kapacitor.Daemon/Services/ServerConnection.cs +++ b/src/Kapacitor.Daemon/Services/ServerConnection.cs @@ -232,7 +232,7 @@ public virtual Task PingAsync(CancellationToken ct) /// public virtual Task ForceReconnectAsync() => _hub.StopAsync(_ct); - public async Task UpdateRepoPathsAsync() { + public virtual async Task UpdateRepoPathsAsync() { try { var repoPaths = await MergeRepoPathsAsync(); await _hub.InvokeAsync("DaemonUpdateRepoPaths", repoPaths, cancellationToken: _ct); @@ -242,16 +242,16 @@ public async Task UpdateRepoPathsAsync() { } // Outgoing messages to server - public Task AgentRegisteredAsync(string agentId, string? prompt, string? model, string? effort, string? repoPath) + public virtual Task AgentRegisteredAsync(string agentId, string? prompt, string? model, string? effort, string? repoPath) => _hub.InvokeAsync("AgentRegistered", new AgentRegistered(agentId, prompt, model, effort, repoPath), cancellationToken: _ct); - public Task AgentStatusChangedAsync(string agentId, string status, string? sessionId) + public virtual Task AgentStatusChangedAsync(string agentId, string status, string? sessionId) => _hub.InvokeAsync("AgentStatusChanged", new AgentStatusChanged(agentId, status, sessionId), cancellationToken: _ct); - public Task AgentUnregisteredAsync(string agentId) + public virtual Task AgentUnregisteredAsync(string agentId) => _hub.InvokeAsync("AgentUnregistered", new AgentUnregistered(agentId), cancellationToken: _ct); - public Task LaunchFailedAsync(string agentId, string reason) + public virtual Task LaunchFailedAsync(string agentId, string reason) => _hub.InvokeAsync("LaunchFailed", new LaunchFailed(agentId, reason), cancellationToken: _ct); /// @@ -298,7 +298,7 @@ public virtual Task RequestPermissionAsync( ct ); - public Task SendTerminalOutputAsync(string agentId, string base64Data) + public virtual Task SendTerminalOutputAsync(string agentId, string base64Data) => _hub.SendAsync("SendTerminalOutput", new TerminalOutput(agentId, base64Data), cancellationToken: _ct); // ── Eval progress events (DEV-1440) ──────────────────────────────────── @@ -332,7 +332,7 @@ public Task EvalRetrospectiveCompletedAsync(string sessionId, string evalRunId) public Task EvalRetrospectiveFailedAsync(string sessionId, string evalRunId, string reason) => _hub.SendAsync("EvalRetrospectiveFailed", new EvalRetrospectiveFailed(sessionId, evalRunId, reason), cancellationToken: _ct); - public Task AppendAgentRunEventAsync(string agentId, object evt) { + public virtual Task AppendAgentRunEventAsync(string agentId, object evt) { _eventChannel.Writer.TryWrite(new PendingEvent(agentId, evt)); return Task.CompletedTask; diff --git a/test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs b/test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs new file mode 100644 index 0000000000..22cda7ddb7 --- /dev/null +++ b/test/kapacitor.Tests.Unit/AgentOrchestratorVendorTests.cs @@ -0,0 +1,387 @@ +using System.Diagnostics; +using System.Text.Json; +using kapacitor; +using kapacitor.Commands; +using kapacitor.Daemon; +using kapacitor.Daemon.Pty; +using kapacitor.Daemon.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace kapacitor.Tests.Unit; + +/// +/// Covers the vendor-routing logic in +/// added in Task 14. Verifies that: +/// • Unknown vendors short-circuit with LaunchFailed before any worktree work. +/// • Claude/Codex commands route to the matching . +/// • Review launches for Codex are rejected (v1 limitation). +/// • from Prepare surfaces as a +/// LaunchFailed with the exception's message and no PTY ever spawns. +/// +public class AgentOrchestratorVendorTests { + static (string repoPath, Action cleanup) CreateGitRepo() { + var repoPath = Path.Combine(Path.GetTempPath(), "kapacitor-orch-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(repoPath); + + Git(repoPath, "init", "-q"); + Git(repoPath, "config", "user.email", "test@example.com"); + Git(repoPath, "config", "user.name", "Test"); + File.WriteAllText(Path.Combine(repoPath, "README.md"), "test"); + Git(repoPath, "add", "-A"); + Git(repoPath, "commit", "-q", "-m", "initial"); + + return (repoPath, () => { + try { Directory.Delete(repoPath, true); } catch { /* best-effort */ } + }); + } + + static void Git(string cwd, params string[] args) { + var psi = new ProcessStartInfo("git", args) { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var proc = Process.Start(psi)!; + proc.WaitForExit(); + if (proc.ExitCode != 0) { + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {proc.StandardError.ReadToEnd()}"); + } + } + + static AgentOrchestrator BuildOrchestrator( + CaptureServerConnection server, + IPtyProcessFactory ptyFactory, + IReadOnlyDictionary launchers, + string? allowedRepoPath = null + ) { + var config = new DaemonConfig { + Name = "test", + ServerUrl = "http://127.0.0.1:1", + ClaudePath = "claude", + MaxConcurrentAgents = 5, + WorktreeRoot = Path.Combine(Path.GetTempPath(), "kapacitor-orch-wt-" + Guid.NewGuid().ToString("N")[..8]) + }; + + if (allowedRepoPath is not null) { + config.AllowedRepoPaths = [allowedRepoPath]; + } + + var worktreeManager = new WorktreeManager(config, NullLogger.Instance); + var repoMatcher = new RepoMatcher(config, NullLogger.Instance); + var httpFactory = new StubHttpClientFactory(); + var permissionBridge = new LocalPermissionBridge(server, NullLogger.Instance); + + return new AgentOrchestrator( + config, + server, + worktreeManager, + repoMatcher, + ptyFactory, + httpFactory, + permissionBridge, + launchers, + NullLogger.Instance + ); + } + + [Test] + public async Task Launch_with_unknown_vendor_emits_launch_failed_and_does_not_spawn_pty() { + var server = new CaptureServerConnection(); + var ptyFactory = new SpyPtyProcessFactory(); + var launchers = new Dictionary(); + + await using var orch = BuildOrchestrator(server, ptyFactory, launchers); + + var cmd = new LaunchAgentCommand( + AgentId: "agent-bogus", + Prompt: "hi", + Model: "opus", + Effort: null, + RepoPath: "/tmp/does-not-matter", + Tools: null, + AttachmentIds: null, + Vendor: "bogus" + ); + + await orch.HandleLaunchAgentForTest(cmd); + + await Assert.That(server.LaunchFailedCalls.Count).IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].AgentId).IsEqualTo("agent-bogus"); + await Assert.That(server.LaunchFailedCalls[0].Reason).Contains("Unknown vendor"); + await Assert.That(ptyFactory.SpawnCalls).IsEqualTo(0); + } + + [Test] + public async Task Launch_with_vendor_claude_calls_claude_launcher() { + var (repoPath, cleanup) = CreateGitRepo(); + try { + var server = new CaptureServerConnection(); + var ptyFactory = new SpyPtyProcessFactory(); + var claudeSpy = new SpyHostedAgentLauncher("claude", cliPath: "spy-claude"); + var codexSpy = new SpyHostedAgentLauncher("codex", cliPath: "spy-codex"); + var launchers = new Dictionary { + ["claude"] = claudeSpy, + ["codex"] = codexSpy + }; + + await using var orch = BuildOrchestrator(server, ptyFactory, launchers, allowedRepoPath: repoPath); + + var cmd = new LaunchAgentCommand( + AgentId: "agent-c1", + Prompt: "do work", + Model: "opus", + Effort: null, + RepoPath: repoPath, + Tools: null, + AttachmentIds: null, + Vendor: "claude" + ); + + await orch.HandleLaunchAgentForTest(cmd); + + await Assert.That(claudeSpy.BuildArgsCalls).IsEqualTo(1); + await Assert.That(claudeSpy.PrepareCalls).IsEqualTo(1); + await Assert.That(codexSpy.BuildArgsCalls).IsEqualTo(0); + await Assert.That(codexSpy.PrepareCalls).IsEqualTo(0); + + // PTY spawn must have used the claude launcher's CLI path. + await Assert.That(ptyFactory.SpawnCalls).IsEqualTo(1); + await Assert.That(ptyFactory.LastCommand).IsEqualTo("spy-claude"); + } finally { + cleanup(); + } + } + + [Test] + public async Task Launch_with_vendor_codex_calls_codex_launcher() { + var (repoPath, cleanup) = CreateGitRepo(); + try { + var server = new CaptureServerConnection(); + var ptyFactory = new SpyPtyProcessFactory(); + var claudeSpy = new SpyHostedAgentLauncher("claude", cliPath: "spy-claude"); + var codexSpy = new SpyHostedAgentLauncher("codex", cliPath: "spy-codex"); + var launchers = new Dictionary { + ["claude"] = claudeSpy, + ["codex"] = codexSpy + }; + + await using var orch = BuildOrchestrator(server, ptyFactory, launchers, allowedRepoPath: repoPath); + + var cmd = new LaunchAgentCommand( + AgentId: "agent-x1", + Prompt: "do work", + Model: "gpt-5", + Effort: null, + RepoPath: repoPath, + Tools: null, + AttachmentIds: null, + Vendor: "codex" + ); + + await orch.HandleLaunchAgentForTest(cmd); + + await Assert.That(codexSpy.BuildArgsCalls).IsEqualTo(1); + await Assert.That(codexSpy.PrepareCalls).IsEqualTo(1); + await Assert.That(claudeSpy.BuildArgsCalls).IsEqualTo(0); + await Assert.That(claudeSpy.PrepareCalls).IsEqualTo(0); + + await Assert.That(ptyFactory.SpawnCalls).IsEqualTo(1); + await Assert.That(ptyFactory.LastCommand).IsEqualTo("spy-codex"); + } finally { + cleanup(); + } + } + + [Test] + public async Task Launch_review_kind_with_vendor_codex_emits_launch_failed() { + var server = new CaptureServerConnection(); + var ptyFactory = new SpyPtyProcessFactory(); + var codexSpy = new SpyHostedAgentLauncher("codex", cliPath: "spy-codex"); + var launchers = new Dictionary { + ["codex"] = codexSpy + }; + + await using var orch = BuildOrchestrator(server, ptyFactory, launchers); + + var cmd = new LaunchAgentCommand( + AgentId: "agent-r1", + Prompt: null, + Model: "gpt-5", + Effort: null, + RepoPath: "/tmp/whatever", + Tools: null, + AttachmentIds: null, + Vendor: "codex", + Kind: LaunchKind.Review, + Review: new ReviewLaunchInfo("acme", "widgets", 42) + ); + + await orch.HandleLaunchAgentForTest(cmd); + + await Assert.That(server.LaunchFailedCalls.Count).IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].AgentId).IsEqualTo("agent-r1"); + await Assert.That(server.LaunchFailedCalls[0].Reason).Contains("PR review for Codex"); + await Assert.That(codexSpy.BuildArgsCalls).IsEqualTo(0); + await Assert.That(codexSpy.PrepareCalls).IsEqualTo(0); + await Assert.That(ptyFactory.SpawnCalls).IsEqualTo(0); + } + + [Test] + public async Task Codex_hooks_not_installed_exception_during_prepare_yields_actionable_launch_failed() { + var (repoPath, cleanup) = CreateGitRepo(); + try { + var server = new CaptureServerConnection(); + var ptyFactory = new SpyPtyProcessFactory(); + var codexSpy = new SpyHostedAgentLauncher("codex", cliPath: "spy-codex") { + PrepareThrow = new CodexHooksNotInstalledException("Run plugin install --codex") + }; + var launchers = new Dictionary { + ["codex"] = codexSpy + }; + + await using var orch = BuildOrchestrator(server, ptyFactory, launchers, allowedRepoPath: repoPath); + + var cmd = new LaunchAgentCommand( + AgentId: "agent-h1", + Prompt: "go", + Model: "gpt-5", + Effort: null, + RepoPath: repoPath, + Tools: null, + AttachmentIds: null, + Vendor: "codex" + ); + + await orch.HandleLaunchAgentForTest(cmd); + + await Assert.That(server.LaunchFailedCalls.Count).IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].AgentId).IsEqualTo("agent-h1"); + await Assert.That(server.LaunchFailedCalls[0].Reason).IsEqualTo("Run plugin install --codex"); + await Assert.That(codexSpy.PrepareCalls).IsEqualTo(1); + await Assert.That(codexSpy.BuildArgsCalls).IsEqualTo(0); + await Assert.That(ptyFactory.SpawnCalls).IsEqualTo(0); + } finally { + cleanup(); + } + } + + // ── Test doubles ───────────────────────────────────────────────────── + + sealed class SpyHostedAgentLauncher(string vendor, string cliPath) : IHostedAgentLauncher { + public string Vendor { get; } = vendor; + public string CliPath { get; } = cliPath; + + public int PrepareCalls { get; private set; } + public int BuildArgsCalls { get; private set; } + public int CleanupCalls { get; private set; } + public Exception? PrepareThrow { get; init; } + + public void Prepare(LauncherContext ctx) { + PrepareCalls++; + if (PrepareThrow is not null) throw PrepareThrow; + } + + public LaunchArgs BuildArgs(LauncherContext ctx) { + BuildArgsCalls++; + return new LaunchArgs(Args: [], McpConfigPath: null); + } + + public void Cleanup(AgentInstance agent) { + CleanupCalls++; + } + } + + sealed class SpyPtyProcessFactory : IPtyProcessFactory { + public int SpawnCalls { get; private set; } + public string? LastCommand { get; private set; } + + public IPtyProcess Spawn(string command, string[] args, string cwd, + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40) { + SpawnCalls++; + LastCommand = command; + return new StubPtyProcess(); + } + } + + /// + /// Stand-in PTY process used after a successful Spawn so the orchestrator's + /// read-output loop completes immediately and the test doesn't hang waiting + /// on a real process. ReadOutputAsync yields nothing; everything else is no-op. + /// + sealed class StubPtyProcess : IPtyProcess { + public int Pid => 0; + public bool HasExited => true; + public int? ExitCode => 0; + + public ValueTask DisposeAsync() => default; + public Task WaitForExitAsync(TimeSpan? _) => Task.CompletedTask; + public Task TerminateAsync(TimeSpan? _) => Task.CompletedTask; + +#pragma warning disable CS1998 + public async IAsyncEnumerable ReadOutputAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken _ = default + ) { + yield break; + } +#pragma warning restore CS1998 + + public Task WriteAsync(string _) => Task.CompletedTask; + public Task WriteAsync(byte[] _) => Task.CompletedTask; + public void Resize(ushort _, ushort __) { } + public void SendInterrupt() { } + } + + /// + /// Captures LaunchFailedAsync calls and no-ops the other server-side methods + /// so the orchestrator's launch flow doesn't touch a real SignalR connection. + /// + sealed class CaptureServerConnection : ServerConnection { + public List<(string AgentId, string Reason)> LaunchFailedCalls { get; } = []; + + public CaptureServerConnection() : base( + new DaemonConfig { Name = "test", ServerUrl = "http://127.0.0.1:1" }, + NullLoggerFactory.Instance, + NullLogger.Instance + ) { } + + public override Task LaunchFailedAsync(string agentId, string reason) { + LaunchFailedCalls.Add((agentId, reason)); + return Task.CompletedTask; + } + + public override Task AgentRegisteredAsync(string agentId, string? prompt, string? model, string? effort, string? repoPath) + => Task.CompletedTask; + + public override Task AgentStatusChangedAsync(string agentId, string status, string? sessionId) + => Task.CompletedTask; + + public override Task AgentUnregisteredAsync(string agentId) + => Task.CompletedTask; + + public override Task UpdateRepoPathsAsync() + => Task.CompletedTask; + + public override Task SendTerminalOutputAsync(string agentId, string base64Data) + => Task.CompletedTask; + + public override Task AppendAgentRunEventAsync(string agentId, object evt) + => Task.CompletedTask; + + public override Task EndAgentSessionAsync(string agentId, string reason) + => Task.FromResult(new EndAgentSessionResult()); + + public override Task RequestPermissionAsync( + string sessionId, + string? toolName, + JsonElement? toolInput, + JsonElement? suggestions, + string vendor, + CancellationToken ct = default + ) => Task.FromResult(new PermissionDecision("deny", null, null)); + } + + /// Minimal IHttpClientFactory so the orchestrator can be constructed without DI. + sealed class StubHttpClientFactory : IHttpClientFactory { + public HttpClient CreateClient(string name) => new(); + } +} From 067b95b4173ccefc560eadacb050736d095a1a0f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 01:24:38 +0200 Subject: [PATCH 26/36] [AI-68] daemon: add CodexPath config field --- src/Kapacitor.Daemon/DaemonConfig.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Kapacitor.Daemon/DaemonConfig.cs b/src/Kapacitor.Daemon/DaemonConfig.cs index 161e6bb606..07546220d0 100644 --- a/src/Kapacitor.Daemon/DaemonConfig.cs +++ b/src/Kapacitor.Daemon/DaemonConfig.cs @@ -13,6 +13,7 @@ public class DaemonConfig { ); public string ClaudePath { get; set; } = "claude"; + public string CodexPath { get; set; } = "codex"; /// /// Path to the kapacitor CLI binary. Used by the daemon to spawn auxiliary From 8c4f8d928f8478d771e42bcbf0bdae338050ece2 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 01:38:05 +0200 Subject: [PATCH 27/36] [AI-68] daemon: CodexLauncher with overlay + hook preflight + pre-trust Co-Authored-By: Claude Sonnet 4.6 --- .../Services/ClaudeLauncher.cs | 24 +- .../Services/CodexLauncher.cs | 108 ++++++++ .../Services/FileSystemOverlay.cs | 23 ++ .../CodexLauncherTests.cs | 250 ++++++++++++++++++ 4 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 src/Kapacitor.Daemon/Services/CodexLauncher.cs create mode 100644 src/Kapacitor.Daemon/Services/FileSystemOverlay.cs create mode 100644 test/kapacitor.Tests.Unit/CodexLauncherTests.cs diff --git a/src/Kapacitor.Daemon/Services/ClaudeLauncher.cs b/src/Kapacitor.Daemon/Services/ClaudeLauncher.cs index a97d7add01..27514cf81e 100644 --- a/src/Kapacitor.Daemon/Services/ClaudeLauncher.cs +++ b/src/Kapacitor.Daemon/Services/ClaudeLauncher.cs @@ -24,7 +24,7 @@ public void Prepare(LauncherContext ctx) { var destClaudeDir = Path.Combine(ctx.Worktree.Path, ".claude"); if (Directory.Exists(sourceClaudeDir)) { - OverlayDirectory(sourceClaudeDir, destClaudeDir); + FileSystemOverlay.OverlayDirectory(sourceClaudeDir, destClaudeDir); } SymlinkClaudeProjectDir(ctx.SourceRepoPath, ctx.Worktree.Path); @@ -152,28 +152,6 @@ internal static void MergeToolPermissions(string worktreePath, string[] tools) { File.WriteAllText(settingsPath, rootObj.ToJsonString(IndentedJsonOpts)); } - /// - /// Copies files from into , - /// creating directories as needed but never overwriting existing files. - /// This preserves git-tracked files while adding gitignored local settings. - /// - static void OverlayDirectory(string source, string dest) { - Directory.CreateDirectory(dest); - - foreach (var file in Directory.GetFiles(source)) { - var destFile = Path.Combine(dest, Path.GetFileName(file)); - - if (!File.Exists(destFile)) { - File.Copy(file, destFile); - } - } - - foreach (var dir in Directory.GetDirectories(source)) { - var destDir = Path.Combine(dest, Path.GetFileName(dir)); - OverlayDirectory(dir, destDir); - } - } - /// /// Creates a symlink at ~/.claude/projects/{worktree-path-hash} pointing to /// ~/.claude/projects/{source-path-hash} so project-level permissions, settings, diff --git a/src/Kapacitor.Daemon/Services/CodexLauncher.cs b/src/Kapacitor.Daemon/Services/CodexLauncher.cs new file mode 100644 index 0000000000..45b0bfd360 --- /dev/null +++ b/src/Kapacitor.Daemon/Services/CodexLauncher.cs @@ -0,0 +1,108 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace kapacitor.Daemon.Services; + +internal sealed partial class CodexLauncher( + DaemonConfig config, + ILogger logger + ) : IHostedAgentLauncher { + + public string Vendor => "codex"; + public string CliPath => config.CodexPath; + + static readonly string[] CriticalHookEvents = ["SessionStart", "Stop", "PermissionRequest"]; + + public void Prepare(LauncherContext ctx) { + // Step 1: overlay source/.codex into worktree FIRST so project-scope + // hooks (kapacitor plugin install --codex --project) become visible to + // the preflight in step 2. Best-effort. + try { + var sourceCodexDir = Path.Combine(ctx.SourceRepoPath, ".codex"); + if (Directory.Exists(sourceCodexDir)) { + FileSystemOverlay.OverlayDirectory(sourceCodexDir, Path.Combine(ctx.Worktree.Path, ".codex")); + } + } catch (Exception ex) { + LogOverlayFailed(ex, ctx.AgentId); + } + + // Step 2: hook preflight (fail-fast). Either worktree-scope (after overlay) + // OR user-scope is sufficient. + var worktreeHooks = Path.Combine(ctx.Worktree.Path, ".codex", "hooks.json"); + if (!HooksInstalledIn(worktreeHooks) && !HooksInstalledIn(CodexPaths.UserHooksJson)) { + throw new CodexHooksNotInstalledException( + "Codex hooks not installed. Run `kapacitor plugin install --codex` " + + "(user scope) or `kapacitor plugin install --codex --project` " + + "(project scope) and try again." + ); + } + + // Step 3: pre-trust the worktree in ~/.codex/config.toml. Best-effort. + try { + CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, logger); + } catch (Exception ex) { + LogTrustFailed(ex, ctx.AgentId); + } + + if (ctx.Tools is { Length: > 0 }) { + LogToolsIgnoredForCodex(ctx.AgentId, ctx.Tools.Length); + } + } + + public LaunchArgs BuildArgs(LauncherContext ctx) { + var args = new List(); + + args.Add("--cd"); + args.Add(ctx.Worktree.Path); + + args.Add("--sandbox"); + args.Add("workspace-write"); + + args.Add("--ask-for-approval"); + args.Add("on-request"); + + if (!string.IsNullOrEmpty(ctx.Model)) { + args.Add("-m"); + args.Add(ctx.Model); + } + + var effort = ctx.Effort; + if (!string.IsNullOrEmpty(effort) && !string.Equals(effort, "auto", StringComparison.OrdinalIgnoreCase)) { + var mapped = string.Equals(effort, "max", StringComparison.OrdinalIgnoreCase) ? "xhigh" : effort; + args.Add("-c"); + args.Add($"model_reasoning_effort=\"{mapped}\""); + } + + args.Add("--no-alt-screen"); + + if (!string.IsNullOrEmpty(ctx.Prompt)) { + args.Add("--"); + args.Add(ctx.Prompt); + } + + return new LaunchArgs(args.ToArray(), McpConfigPath: null); + } + + public void Cleanup(AgentInstance agent) { + // No-op: ~/.codex/config.toml trust entries are intentionally persistent. + } + + static bool HooksInstalledIn(string hooksPath) { + if (!File.Exists(hooksPath)) return false; + try { + var root = JsonNode.Parse(File.ReadAllText(hooksPath)) as JsonObject; + return root is not null && CodexHooksParser.HasKapacitorHooksFor(root, CriticalHookEvents); + } catch { + return false; + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to overlay .codex settings for agent {AgentId} (continuing)")] + partial void LogOverlayFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to pre-trust worktree for agent {AgentId} (continuing)")] + partial void LogTrustFailed(Exception ex, string agentId); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Tools array of length {Count} ignored for vendor=codex (no allowlist concept) — agent {AgentId}")] + partial void LogToolsIgnoredForCodex(string agentId, int count); +} diff --git a/src/Kapacitor.Daemon/Services/FileSystemOverlay.cs b/src/Kapacitor.Daemon/Services/FileSystemOverlay.cs new file mode 100644 index 0000000000..667757a613 --- /dev/null +++ b/src/Kapacitor.Daemon/Services/FileSystemOverlay.cs @@ -0,0 +1,23 @@ +namespace kapacitor.Daemon.Services; + +/// +/// Copies files from a source dir into a destination, creating directories +/// as needed but never overwriting existing files. Used by both +/// and to merge +/// vendor-specific dotfiles from the source repo into the worktree without +/// clobbering tracked content. +/// +internal static class FileSystemOverlay { + public static void OverlayDirectory(string source, string dest) { + Directory.CreateDirectory(dest); + + foreach (var file in Directory.GetFiles(source)) { + var destFile = Path.Combine(dest, Path.GetFileName(file)); + if (!File.Exists(destFile)) File.Copy(file, destFile); + } + + foreach (var dir in Directory.GetDirectories(source)) { + OverlayDirectory(dir, Path.Combine(dest, Path.GetFileName(dir))); + } + } +} diff --git a/test/kapacitor.Tests.Unit/CodexLauncherTests.cs b/test/kapacitor.Tests.Unit/CodexLauncherTests.cs new file mode 100644 index 0000000000..a25a13664e --- /dev/null +++ b/test/kapacitor.Tests.Unit/CodexLauncherTests.cs @@ -0,0 +1,250 @@ +using kapacitor.Daemon; +using kapacitor.Daemon.Services; +using Microsoft.Extensions.Logging.Abstractions; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +[NotInParallel("HomeEnvVarMutation")] +public class CodexLauncherTests { + static CodexLauncher NewLauncher() => + new(new DaemonConfig { CodexPath = "codex" }, NullLogger.Instance); + + static LauncherContext NewCtx( + string? prompt = null, + string model = "gpt-5.3-codex", + string? effort = null + ) => new( + AgentId: "a-1", + SourceRepoPath: "/tmp/repo", + Worktree: new WorktreeInfo(Path: "/tmp/wt", Branch: "wt-branch", SourceRepo: "/tmp/repo"), + Prompt: prompt, + Model: model, + Effort: effort, + Tools: null, + IsReview: false, + Review: null, + ReviewLaunch: null + ); + + [Test] + public async Task BuildArgs_includes_workspace_write_sandbox_and_on_request_approval() { + var args = NewLauncher().BuildArgs(NewCtx()).Args; + await Assert.That(args).Contains("--sandbox"); + await Assert.That(args).Contains("workspace-write"); + await Assert.That(args).Contains("--ask-for-approval"); + await Assert.That(args).Contains("on-request"); + } + + [Test] + public async Task BuildArgs_maps_effort_max_to_xhigh() { + var args = NewLauncher().BuildArgs(NewCtx(effort: "max")).Args; + var joined = string.Join(' ', args); + await Assert.That(joined).Contains("model_reasoning_effort=\"xhigh\""); + } + + [Test] + [Arguments("low")] + [Arguments("medium")] + [Arguments("high")] + public async Task BuildArgs_passes_effort_through_unchanged(string effort) { + var args = NewLauncher().BuildArgs(NewCtx(effort: effort)).Args; + await Assert.That(string.Join(' ', args)).Contains($"model_reasoning_effort=\"{effort}\""); + } + + [Test] + [Arguments(null)] + [Arguments("auto")] + public async Task BuildArgs_omits_effort_when_null_or_auto(string? effort) { + var args = NewLauncher().BuildArgs(NewCtx(effort: effort)).Args; + await Assert.That(string.Join(' ', args)).DoesNotContain("model_reasoning_effort"); + } + + [Test] + public async Task BuildArgs_appends_prompt_after_double_dash_when_present() { + var args = NewLauncher().BuildArgs(NewCtx(prompt: "do a thing")).Args; + var dashIdx = Array.IndexOf(args, "--"); + await Assert.That(dashIdx).IsGreaterThan(-1); + await Assert.That(args[dashIdx + 1]).IsEqualTo("do a thing"); + } + + [Test] + public async Task BuildArgs_emits_no_alt_screen_flag() { + var args = NewLauncher().BuildArgs(NewCtx()).Args; + await Assert.That(args).Contains("--no-alt-screen"); + } + + [Test] + public async Task BuildArgs_includes_cd_with_worktree_path() { + var args = NewLauncher().BuildArgs(NewCtx()).Args; + var cdIdx = Array.IndexOf(args, "--cd"); + await Assert.That(cdIdx).IsGreaterThan(-1); + await Assert.That(args[cdIdx + 1]).IsEqualTo("/tmp/wt"); + } + + [Test] + public async Task BuildArgs_includes_model_when_set() { + var args = NewLauncher().BuildArgs(NewCtx(model: "gpt-5.4")).Args; + var mIdx = Array.IndexOf(args, "-m"); + await Assert.That(mIdx).IsGreaterThan(-1); + await Assert.That(args[mIdx + 1]).IsEqualTo("gpt-5.4"); + } + + [Test] + public async Task Prepare_overlays_codex_settings_dir_from_source_repo() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + var srcCodex = Directory.CreateDirectory(Path.Combine(sourceRepo, ".codex")).FullName; + File.WriteAllText(Path.Combine(srcCodex, "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + + await Assert.That(File.Exists(Path.Combine(worktree, ".codex", "hooks.json"))).IsTrue(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } + } + + [Test] + public async Task Prepare_throws_when_no_hooks_json_anywhere() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + var ex = await Assert.ThrowsAsync(async () => { + NewLauncher().Prepare(ctx); + await Task.CompletedTask; + }); + await Assert.That(ex!.Message).Contains("kapacitor plugin install --codex"); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } + } + + [Test] + public async Task Prepare_succeeds_when_user_scope_hooks_json_has_all_three_critical_events() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + Directory.CreateDirectory(Path.Combine(home, ".codex")); + File.WriteAllText(Path.Combine(home, ".codex", "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + + await Assert.That(File.Exists(Path.Combine(home, ".codex", "config.toml"))).IsTrue(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } + } + + [Test] + public async Task Prepare_succeeds_when_project_scope_hooks_json_present_after_overlay() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + Directory.CreateDirectory(Path.Combine(sourceRepo, ".codex")); + File.WriteAllText(Path.Combine(sourceRepo, ".codex", "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + await Assert.That(File.Exists(Path.Combine(home, ".codex", "config.toml"))).IsTrue(); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } + } + + [Test] + public async Task Prepare_invokes_codex_config_writer_with_worktree_path() { + var sourceRepo = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-src-").FullName; + var worktree = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-wt-").FullName; + var home = Directory.CreateTempSubdirectory("kapacitor-codexlauncher-home-").FullName; + var originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", home); + + try { + Directory.CreateDirectory(Path.Combine(home, ".codex")); + File.WriteAllText(Path.Combine(home, ".codex", "hooks.json"), """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kapacitor codex-hook"}]}] + }} + """); + + var ctx = NewCtxWith(source: sourceRepo, worktree: worktree); + NewLauncher().Prepare(ctx); + + var configToml = File.ReadAllText(Path.Combine(home, ".codex", "config.toml")); + await Assert.That(configToml).Contains($"\"{worktree}\""); + await Assert.That(configToml).Contains("trust_level = \"trusted\""); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + Directory.Delete(sourceRepo, recursive: true); + Directory.Delete(worktree, recursive: true); + Directory.Delete(home, recursive: true); + } + } + + static LauncherContext NewCtxWith(string source, string worktree) => new( + AgentId: "a-1", + SourceRepoPath: source, + Worktree: new WorktreeInfo(Path: worktree, Branch: "br", SourceRepo: source), + Prompt: null, + Model: "gpt-5.3-codex", + Effort: null, + Tools: null, + IsReview: false, + Review: null, + ReviewLaunch: null + ); +} From 4049422e0aad7286a3b57daed8ce60abd5f03fa9 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 01:48:08 +0200 Subject: [PATCH 28/36] [AI-68] daemon: LocalPermissionBridge per-vendor URL routing + response shapes Switch from flat /{token}/permission-request to /{token}/{vendor}/permission-request. Claude responses carry the full shape (applyPermissions, updatedInput); Codex responses emit behavior only. Vendor is forwarded to RequestPermissionAsync replacing the "claude" stub. Legacy URL now returns 404. FakeServerConnection captures vendor for assertion. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/LocalPermissionBridge.cs | 50 ++++- .../LocalPermissionBridgeTests.cs | 182 +++++++++++++++++- 2 files changed, 214 insertions(+), 18 deletions(-) diff --git a/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs b/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs index fa02f2c7d4..c24a02308b 100644 --- a/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs +++ b/src/Kapacitor.Daemon/Services/LocalPermissionBridge.cs @@ -25,7 +25,7 @@ internal sealed partial class LocalPermissionBridge( ILogger logger ) : IHostedService, IAsyncDisposable { const int MaxBindAttempts = 8; - const string PathPrefix = "/permission-request"; + const string PathSuffix = "/permission-request"; HttpListener? _listener; Task? _acceptLoop; @@ -129,13 +129,25 @@ async Task AcceptLoopAsync(CancellationToken ct) { async Task HandleAsync(HttpListenerContext context, CancellationToken ct) { try { - // Require token + endpoint match. Token check first (constant-time-ish via string - // equality is fine — discovery vector is the env var, not timing). The HttpListener - // prefix already filters to /{token}/, but check explicitly so a misconfigured - // listener prefix can't quietly admit anything. + // Require token + vendor + endpoint match. The HttpListener prefix already filters + // to /{token}/, but we check explicitly so a misconfigured prefix can't quietly + // admit anything. Token is checked first; vendor determines the response shape. var path = context.Request.Url?.AbsolutePath; - if (path != $"/{_token}{PathPrefix}" || context.Request.HttpMethod != "POST") { + if (path is null + || !path.StartsWith($"/{_token}/", StringComparison.Ordinal) + || !path.EndsWith(PathSuffix, StringComparison.Ordinal) + || context.Request.HttpMethod != "POST") { + context.Response.StatusCode = 404; + context.Response.Close(); + return; + } + + var vendorStart = _token!.Length + 2; // skip "/{token}/" + var vendorEnd = path.Length - PathSuffix.Length; + var vendor = vendorEnd > vendorStart ? path[vendorStart..vendorEnd] : ""; + + if (vendor is not ("claude" or "codex")) { context.Response.StatusCode = 404; context.Response.Close(); return; @@ -186,13 +198,13 @@ async Task HandleAsync(HttpListenerContext context, CancellationToken ct) { PermissionDecision decision; try { - decision = await server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, "claude", ct); + decision = await server.RequestPermissionAsync(sessionId, toolName, toolInput, suggestions, vendor, ct); } catch (Exception ex) { LogRequestPermissionFailed(logger, ex, sessionId); decision = new PermissionDecision("deny", null, null); } - var responseJson = BuildHookResponseJson(decision); + var responseJson = BuildHookResponseJson(decision, vendor); var bytes = Encoding.UTF8.GetBytes(responseJson); context.Response.ContentType = "application/json"; @@ -221,7 +233,14 @@ async Task HandleAsync(HttpListenerContext context, CancellationToken ct) { return doc.RootElement.Clone(); } - static string BuildHookResponseJson(PermissionDecision decision) { + static string BuildHookResponseJson(PermissionDecision decision, string vendor) => + vendor switch { + "claude" => BuildClaudeResponse(decision), + "codex" => BuildCodexResponse(decision), + _ => throw new InvalidOperationException($"Unsupported vendor: {vendor}") + }; + + static string BuildClaudeResponse(PermissionDecision decision) { // Mirrors the server-side BuildHookResponse. Claude expects camelCase keys here // (hookSpecificOutput, hookEventName, applyPermissions, updatedInput) — these are // outside the server's snake_case JSON convention because Claude defines them. @@ -240,6 +259,19 @@ static string BuildHookResponseJson(PermissionDecision decision) { return payload.ToJsonString(); } + static string BuildCodexResponse(PermissionDecision decision) { + // Codex only consumes behavior — strip applyPermissions and updatedInput so the + // response stays valid for Codex's stricter hook schema. + var payload = new JsonObject { + ["hookSpecificOutput"] = new JsonObject { + ["hookEventName"] = "PermissionRequest", + ["decision"] = new JsonObject { ["behavior"] = decision.Behavior } + } + }; + + return payload.ToJsonString(); + } + [LoggerMessage(Level = LogLevel.Information, Message = "Local permission bridge listening on {BaseUrl}")] static partial void LogBridgeStarted(ILogger logger, string baseUrl); diff --git a/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs b/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs index 7c2fa196dc..afc8625166 100644 --- a/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs +++ b/test/kapacitor.Tests.Unit/LocalPermissionBridgeTests.cs @@ -52,7 +52,7 @@ public async Task PostingToWrongTokenReturns404() { await bridge.StartAsync(CancellationToken.None); var uri = new Uri(bridge.BaseUrl!); - var bogusUrl = $"http://127.0.0.1:{uri.Port}/{new string('0', 32)}/permission-request"; + var bogusUrl = $"http://127.0.0.1:{uri.Port}/{new string('0', 32)}/claude/permission-request"; using var client = CreateClient(); using var response = await client.PostAsync(bogusUrl, JsonContent.Create(new { session_id = "abc" })); @@ -101,7 +101,7 @@ public async Task MalformedJsonReturns400() { using var client = CreateClient(); using var content = new StringContent("{ this is not json", Encoding.UTF8, "application/json"); - using var response = await client.PostAsync($"{bridge.BaseUrl}/permission-request", content); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", content); await Assert.That((int)response.StatusCode).IsEqualTo(400); } finally { @@ -116,7 +116,7 @@ public async Task MissingSessionIdReturns400() { await bridge.StartAsync(CancellationToken.None); using var client = CreateClient(); - using var response = await client.PostAsync($"{bridge.BaseUrl}/permission-request", JsonContent.Create(new { tool_name = "Bash" })); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", JsonContent.Create(new { tool_name = "Bash" })); await Assert.That((int)response.StatusCode).IsEqualTo(400); } finally { @@ -140,7 +140,7 @@ public async Task ValidRequestStripsDashesAndForwardsArgsToServer() { tool_input = new { command = "ls" }, permission_suggestions = new { reason = "ok" } }; - using var response = await client.PostAsync($"{bridge.BaseUrl}/permission-request", JsonContent.Create(payload)); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", JsonContent.Create(payload)); await Assert.That((int)response.StatusCode).IsEqualTo(200); await Assert.That(server.Calls.Count).IsEqualTo(1); @@ -165,7 +165,7 @@ public async Task ResponseShapeMirrorsClaudeHookSchema() { await bridge.StartAsync(CancellationToken.None); using var client = CreateClient(); - using var response = await client.PostAsync($"{bridge.BaseUrl}/permission-request", JsonContent.Create(new { session_id = "abc" })); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", JsonContent.Create(new { session_id = "abc" })); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); @@ -191,7 +191,7 @@ public async Task ApplyPermissionsAndUpdatedInputAreCopiedIntoDecision() { await bridge.StartAsync(CancellationToken.None); using var client = CreateClient(); - using var response = await client.PostAsync($"{bridge.BaseUrl}/permission-request", JsonContent.Create(new { session_id = "abc" })); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", JsonContent.Create(new { session_id = "abc" })); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); @@ -214,7 +214,7 @@ public async Task ServerFailureFallsBackToDeny() { await bridge.StartAsync(CancellationToken.None); using var client = CreateClient(); - using var response = await client.PostAsync($"{bridge.BaseUrl}/permission-request", JsonContent.Create(new { session_id = "abc" })); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", JsonContent.Create(new { session_id = "abc" })); await Assert.That((int)response.StatusCode).IsEqualTo(200); @@ -251,6 +251,170 @@ public async Task StopAsyncReleasesPort() { await bridge.DisposeAsync(); } } + + // ── Per-vendor routing tests ────────────────────────────────────────────── + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Claude_path_returns_claude_response_shape() { + using var apDoc = JsonDocument.Parse("""{"allow":["Bash(*)"]}"""); + using var uiDoc = JsonDocument.Parse("""{"command":"ls"}"""); + + var (bridge, _) = CreateBridge((_, _, _, _, _) => + Task.FromResult(new PermissionDecision("allow", apDoc.RootElement.Clone(), uiDoc.RootElement.Clone())) + ); + + try { + await bridge.StartAsync(CancellationToken.None); + + using var client = CreateClient(); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", JsonContent.Create(new { session_id = "abc" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(200); + + var body = await response.Content.ReadAsStringAsync(); + await Assert.That(body.Contains("applyPermissions")).IsTrue(); + await Assert.That(body.Contains("updatedInput")).IsTrue(); + } finally { + await bridge.DisposeAsync(); + } + } + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Codex_path_returns_codex_response_shape() { + using var apDoc = JsonDocument.Parse("""{"allow":["Bash(*)"]}"""); + using var uiDoc = JsonDocument.Parse("""{"command":"ls"}"""); + + var (bridge, _) = CreateBridge((_, _, _, _, _) => + Task.FromResult(new PermissionDecision("allow", apDoc.RootElement.Clone(), uiDoc.RootElement.Clone())) + ); + + try { + await bridge.StartAsync(CancellationToken.None); + + using var client = CreateClient(); + using var response = await client.PostAsync($"{bridge.BaseUrl}/codex/permission-request", JsonContent.Create(new { session_id = "abc" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(200); + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var decision = doc.RootElement.GetProperty("hookSpecificOutput").GetProperty("decision"); + await Assert.That(decision.GetProperty("behavior").GetString()).IsEqualTo("allow"); + await Assert.That(body.Contains("applyPermissions")).IsFalse(); + await Assert.That(body.Contains("updatedInput")).IsFalse(); + } finally { + await bridge.DisposeAsync(); + } + } + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Legacy_path_without_vendor_returns_404() { + var (bridge, _) = CreateBridge(); + try { + await bridge.StartAsync(CancellationToken.None); + + using var client = CreateClient(); + using var response = await client.PostAsync($"{bridge.BaseUrl}/permission-request", JsonContent.Create(new { session_id = "abc" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(404); + } finally { + await bridge.DisposeAsync(); + } + } + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Unknown_vendor_returns_404() { + var (bridge, _) = CreateBridge(); + try { + await bridge.StartAsync(CancellationToken.None); + + using var client = CreateClient(); + using var response = await client.PostAsync($"{bridge.BaseUrl}/bogus/permission-request", JsonContent.Create(new { session_id = "abc" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(404); + } finally { + await bridge.DisposeAsync(); + } + } + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Codex_path_invokes_server_with_vendor_codex() { + var (bridge, server) = CreateBridge(); + try { + await bridge.StartAsync(CancellationToken.None); + + using var client = CreateClient(); + using var response = await client.PostAsync($"{bridge.BaseUrl}/codex/permission-request", JsonContent.Create(new { session_id = "abc" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(200); + await Assert.That(server.Calls.Count).IsEqualTo(1); + await Assert.That(server.Calls[0].Vendor).IsEqualTo("codex"); + } finally { + await bridge.DisposeAsync(); + } + } + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Claude_path_invokes_server_with_vendor_claude() { + var (bridge, server) = CreateBridge(); + try { + await bridge.StartAsync(CancellationToken.None); + + using var client = CreateClient(); + using var response = await client.PostAsync($"{bridge.BaseUrl}/claude/permission-request", JsonContent.Create(new { session_id = "abc" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(200); + await Assert.That(server.Calls.Count).IsEqualTo(1); + await Assert.That(server.Calls[0].Vendor).IsEqualTo("claude"); + } finally { + await bridge.DisposeAsync(); + } + } + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Codex_path_strips_apply_permissions_from_server_decision() { + using var apDoc = JsonDocument.Parse("""{"allow":["Bash(*)"]}"""); + + var (bridge, _) = CreateBridge((_, _, _, _, _) => + Task.FromResult(new PermissionDecision("allow", apDoc.RootElement.Clone(), null)) + ); + + try { + await bridge.StartAsync(CancellationToken.None); + + using var client = CreateClient(); + using var response = await client.PostAsync($"{bridge.BaseUrl}/codex/permission-request", JsonContent.Create(new { session_id = "abc" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(200); + + var body = await response.Content.ReadAsStringAsync(); + await Assert.That(body.Contains("applyPermissions")).IsFalse(); + } finally { + await bridge.DisposeAsync(); + } + } + + [Test, NotInParallel(nameof(LocalPermissionBridgeTests))] + public async Task Claude_cli_hook_post_target_lands_at_new_url() { + // Verify that the bridge accepts POSTs at the /{token}/claude/permission-request URL + // that the CLI's PermissionRequestCommand now targets (Task 10 migration). + var (bridge, server) = CreateBridge(); + try { + await bridge.StartAsync(CancellationToken.None); + + // Simulate the URL that PermissionRequestCommand builds: + // {KAPACITOR_DAEMON_URL}/claude/permission-request + var targetUrl = $"{bridge.BaseUrl}/claude/permission-request"; + using var client = CreateClient(); + using var response = await client.PostAsync(targetUrl, JsonContent.Create(new { session_id = "abc", tool_name = "Bash" })); + + await Assert.That((int)response.StatusCode).IsEqualTo(200); + await Assert.That(server.Calls.Count).IsEqualTo(1); + await Assert.That(server.Calls[0].Vendor).IsEqualTo("claude"); + } finally { + await bridge.DisposeAsync(); + } + } } /// @@ -280,12 +444,12 @@ public override Task RequestPermissionAsync( string vendor, CancellationToken ct = default ) { - Calls.Add(new Call(sessionId, toolName, toolInput, suggestions)); + Calls.Add(new Call(sessionId, toolName, toolInput, suggestions, vendor)); return _respond is null ? Task.FromResult(new PermissionDecision("allow", null, null)) : _respond(sessionId, toolName, toolInput, suggestions, ct); } - public sealed record Call(string SessionId, string? ToolName, JsonElement? ToolInput, JsonElement? Suggestions); + public sealed record Call(string SessionId, string? ToolName, JsonElement? ToolInput, JsonElement? Suggestions, string Vendor); } From 74c58dad4af5d9a847462cab71cb758fd35d6a7d Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 02:25:36 +0200 Subject: [PATCH 29/36] [AI-68] cli: codex-hook PermissionRequest bridges through daemon (fail-closed) Co-Authored-By: Claude Sonnet 4.6 --- src/kapacitor/Commands/CodexHookCommand.cs | 53 ++++- .../CodexHookCommandTests.cs | 197 +++++++++++++++++- 2 files changed, 245 insertions(+), 5 deletions(-) diff --git a/src/kapacitor/Commands/CodexHookCommand.cs b/src/kapacitor/Commands/CodexHookCommand.cs index e91a5d509f..bf12a7be34 100644 --- a/src/kapacitor/Commands/CodexHookCommand.cs +++ b/src/kapacitor/Commands/CodexHookCommand.cs @@ -107,15 +107,22 @@ static async Task HandleStop(string baseUrl, JsonNode node) { } static async Task HandlePermissionRequest(string baseUrl, JsonNode node) { + var daemonUrl = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + + return daemonUrl is null + ? await HandlePermissionRequestStub(baseUrl, node) + : await HandlePermissionRequestViaBridge(daemonUrl, node); + } + + static async Task HandlePermissionRequestStub(string baseUrl, JsonNode node) { // v1 stub — record the event server-side and return a default allow - // so recording-only sessions don't block. Full daemon-bridge - // translation lands in AI-68 with hosted Codex agents. + // so recording-only sessions don't block. await PostHookAsync(baseUrl, "permission-request/codex", node.ToJsonString()); var response = new JsonObject { ["hookSpecificOutput"] = new JsonObject { ["hookEventName"] = "PermissionRequest", - ["decision"] = new JsonObject { ["behavior"] = "allow" } + ["decision"] = new JsonObject { ["behavior"] = "allow" } } }; @@ -123,6 +130,46 @@ static async Task HandlePermissionRequest(string baseUrl, JsonNode node) { return 0; } + static async Task HandlePermissionRequestViaBridge(string daemonUrl, JsonNode node) { + if (!DaemonBridgeUrl.TryParseLoopback(daemonUrl, out var bridgeBase)) { + Console.Error.WriteLine( + $"[kapacitor] codex-hook permission-request: KAPACITOR_DAEMON_URL must be http loopback, got: {daemonUrl}"); + return EmitDenyAndExitNonzero(); + } + + using var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; + + try { + using var content = new StringContent(node.ToJsonString(), Encoding.UTF8, "application/json"); + using var resp = await client.PostAsync($"{bridgeBase}/codex/permission-request", content); + + if (!resp.IsSuccessStatusCode) { + Console.Error.WriteLine( + $"[kapacitor] codex-hook permission-request bridge: HTTP {(int)resp.StatusCode}"); + return EmitDenyAndExitNonzero(); + } + + var body = await resp.Content.ReadAsStringAsync(); + Console.Write(body); + return 0; + } catch (Exception ex) { + Console.Error.WriteLine($"[kapacitor] codex-hook permission-request bridge error: {ex.Message}"); + return EmitDenyAndExitNonzero(); + } + } + + static int EmitDenyAndExitNonzero() { + var response = new JsonObject { + ["hookSpecificOutput"] = new JsonObject { + ["hookEventName"] = "PermissionRequest", + ["decision"] = new JsonObject { ["behavior"] = "deny" } + } + }; + + Console.Write(response.ToJsonString()); + return 1; + } + static async Task PostHookAsync(string baseUrl, string endpoint, string body) { using var client = await HttpClientExtensions.CreateAuthenticatedClientAsync(); using var content = new StringContent(body, Encoding.UTF8, "application/json"); diff --git a/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs b/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs index 51b94862ee..1a67df43fb 100644 --- a/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs +++ b/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs @@ -7,6 +7,11 @@ namespace kapacitor.Tests.Unit; public class CodexHookCommandTests : IDisposable { + // All PermissionRequest tests that manipulate KAPACITOR_DAEMON_URL and + // Console.Out must not run in parallel with each other — both are + // process-level globals and parallel access corrupts the assertions. + const string PermissionRequestGroup = "PermissionRequest_SerialGroup"; + readonly WireMockServer _server = WireMockServer.Start(); public void Dispose() => _server.Stop(); @@ -66,8 +71,11 @@ public async Task Stop_maps_to_session_end_codex_route() { await Assert.That(wrong2.Count).IsEqualTo(0); } - [Test, NotInParallel(nameof(PermissionRequest_returns_default_allow_decision))] + [Test, NotInParallel(PermissionRequestGroup)] public async Task PermissionRequest_returns_default_allow_decision() { + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + _server.Given(Request.Create().WithPath("/hooks/permission-request/codex").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); @@ -82,7 +90,7 @@ public async Task PermissionRequest_returns_default_allow_decision() { } """; - var originalOut = Console.Out; + var originalOut = Console.Out; var stdoutWriter = new StringWriter(); try { Console.SetOut(stdoutWriter); @@ -100,6 +108,7 @@ await Assert.That(doc.RootElement .IsEqualTo("allow"); } finally { Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); } } @@ -180,4 +189,188 @@ public async Task Stop_with_numeric_session_id_returns_zero_without_crash() { await Assert.That(exit).IsEqualTo(0); } + + // ---- PermissionRequest daemon-bridge tests ---- + + [Test, NotInParallel(PermissionRequestGroup)] + public async Task PermissionRequest_without_daemon_url_still_uses_legacy_stub() { + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", null); + + _server.Given(Request.Create().WithPath("/hooks/permission-request/codex").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + var stdoutCapture = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(stdoutCapture); + + try { + var exit = await CodexHookCommand.Handle(_server.Url!, + new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(stdoutCapture.ToString()).Contains("\"behavior\":\"allow\""); + + var entries = _server.FindLogEntries(Request.Create().WithPath("/hooks/permission-request/codex").UsingPost()); + await Assert.That(entries.Count).IsEqualTo(1); // informational POST recorded + } finally { + Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); + } + } + + [Test, NotInParallel(PermissionRequestGroup)] + public async Task PermissionRequest_with_daemon_url_set_posts_to_bridge_and_forwards_response_to_stdout() { + using var bridge = WireMockServer.Start(); + var token = "abc123"; + bridge.Given(Request.Create().WithPath($"/{token}/codex/permission-request").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithBody("""{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}""")); + + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://127.0.0.1:{bridge.Ports[0]}/{token}"); + + var stdoutCapture = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(stdoutCapture); + + try { + var exit = await CodexHookCommand.Handle("http://server.example", + new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(stdoutCapture.ToString()).Contains("\"behavior\":\"allow\""); + } finally { + Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); + } + } + + [Test, NotInParallel(PermissionRequestGroup)] + public async Task PermissionRequest_with_daemon_url_emits_deny_and_exits_nonzero_on_500() { + using var bridge = WireMockServer.Start(); + var token = "abc123"; + bridge.Given(Request.Create().WithPath($"/{token}/codex/permission-request").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(500)); + + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://127.0.0.1:{bridge.Ports[0]}/{token}"); + + var stdoutCapture = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(stdoutCapture); + + try { + var exit = await CodexHookCommand.Handle("http://server.example", + new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(stdoutCapture.ToString()).Contains("\"behavior\":\"deny\""); + } finally { + Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); + } + } + + [Test, NotInParallel(PermissionRequestGroup)] + public async Task PermissionRequest_with_daemon_url_emits_deny_on_connection_refused() { + // Use a deliberately-unreachable port (e.g. 1) so the connection fails immediately. + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", "http://127.0.0.1:1/abc"); + + var stdoutCapture = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(stdoutCapture); + + try { + var exit = await CodexHookCommand.Handle("http://server.example", + new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(stdoutCapture.ToString()).Contains("\"behavior\":\"deny\""); + } finally { + Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); + } + } + + [Test, NotInParallel(PermissionRequestGroup)] + public async Task PermissionRequest_with_non_loopback_daemon_url_emits_deny_without_posting() { + using var bridge = WireMockServer.Start(); + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + // Non-loopback host should be rejected by DaemonBridgeUrl.TryParseLoopback + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://example.com:{bridge.Ports[0]}/abc"); + + var stdoutCapture = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(stdoutCapture); + + try { + var exit = await CodexHookCommand.Handle("http://server.example", + new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(stdoutCapture.ToString()).Contains("\"behavior\":\"deny\""); + await Assert.That(bridge.LogEntries.Count()).IsEqualTo(0); + } finally { + Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); + } + } + + [Test, NotInParallel(PermissionRequestGroup)] + public async Task PermissionRequest_with_https_daemon_url_emits_deny_without_posting() { + using var bridge = WireMockServer.Start(); + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"https://127.0.0.1:{bridge.Ports[0]}/abc"); + + var stdoutCapture = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(stdoutCapture); + + try { + var exit = await CodexHookCommand.Handle("http://server.example", + new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(stdoutCapture.ToString()).Contains("\"behavior\":\"deny\""); + await Assert.That(bridge.LogEntries.Count()).IsEqualTo(0); + } finally { + Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); + } + } + + [Test, NotInParallel(PermissionRequestGroup)] + public async Task PermissionRequest_with_daemon_url_does_not_double_post_to_server_hooks_endpoint() { + using var bridge = WireMockServer.Start(); + using var server = WireMockServer.Start(); + var token = "abc123"; + bridge.Given(Request.Create().WithPath($"/{token}/codex/permission-request").UsingPost()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithBody("""{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}""")); + + server.Given(Request.Create().WithPath("/hooks/permission-request/codex").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + + var previousEnv = Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_URL"); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", $"http://127.0.0.1:{bridge.Ports[0]}/{token}"); + + var stdoutCapture = new StringWriter(); + var originalOut = Console.Out; + Console.SetOut(stdoutCapture); + + try { + await CodexHookCommand.Handle($"http://127.0.0.1:{server.Ports[0]}", + new StringReader("""{"hook_event_name":"PermissionRequest","session_id":"s1"}""")); + + await Assert.That(server.LogEntries.Count()).IsEqualTo(0); // server NOT touched + await Assert.That(bridge.LogEntries.Count()).IsEqualTo(1); + } finally { + Console.SetOut(originalOut); + Environment.SetEnvironmentVariable("KAPACITOR_DAEMON_URL", previousEnv); + } + } } From b556ce01a1ac6f6aa9aaaa57b102274cb41cf10e Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 09:32:09 +0200 Subject: [PATCH 30/36] [AI-68] daemon: DI registers ClaudeLauncher + CodexLauncher --- src/Kapacitor.Daemon/DaemonRunner.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Kapacitor.Daemon/DaemonRunner.cs b/src/Kapacitor.Daemon/DaemonRunner.cs index da0283ab5c..cb7ef1d185 100644 --- a/src/Kapacitor.Daemon/DaemonRunner.cs +++ b/src/Kapacitor.Daemon/DaemonRunner.cs @@ -119,10 +119,9 @@ public static async Task RunAsync(string[] args) { builder.Services.AddHttpClient("Attachments", client => client.BaseAddress = new Uri(config.ServerUrl)); - // Task 14: orchestrator dispatches launches through this dictionary. Task 19 - // wires the concrete IHostedAgentLauncher impls (Claude, Codex) into DI. - // Until then, the dictionary resolves to whatever ToDictionary sees — empty - // if none registered. Production hosted launches stay broken until Task 19. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton>(sp => sp.GetServices().ToDictionary(l => l.Vendor) ); From 62f22da302a0133ffd3bffcda07a69cbe71c669c Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 09:43:44 +0200 Subject: [PATCH 31/36] [AI-68] docs: README + help text for hosted Codex agents Co-Authored-By: Claude Sonnet 4.6 --- README.md | 26 +++++++++++++++++-- .../Resources/help-codex-hook.txt | 15 ++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d6adbc7464..bac14454fd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ kapacitor setup --server-url https://capacitor.example.com --default-visibility #### Also using Codex CLI? -`setup` installs Claude Code hooks only. If you also use Codex CLI and want those sessions captured too, install the Codex hook surface separately: +`setup` installs Claude Code hooks only. If you also use Codex CLI — either for local sessions or for hosting agents via the daemon — install the Codex hook surface separately: ```bash kapacitor plugin install --codex # user-wide (~/.codex/hooks.json) @@ -191,7 +191,7 @@ Non-interactive runs (no TTY, e.g. CI) must pass both a scope flag and `--yes`. ### Agent daemon -The agent daemon connects to the Capacitor server and runs Claude Code agents in isolated git worktrees, controlled from the dashboard. +The agent daemon connects to the Capacitor server and runs Claude Code agents in isolated git worktrees, controlled from the dashboard. The daemon supports hosted Claude and Codex agents on macOS and Linux — choose the vendor from the dashboard's launch dialog. ```bash kapacitor agent start # start in foreground @@ -202,6 +202,28 @@ kapacitor agent stop # stop the daemon (foreground or background) `agent start` refuses to launch a second daemon when one is already alive, regardless of mode. Run `kapacitor agent stop` to terminate the existing one first. Two concurrent daemons under the same identity used to silently take down each other's hosted agents on the server. +#### Hosted Codex agents + +To launch hosted Codex agents from the dashboard, the Codex hook surface must be installed first: + +```bash +kapacitor plugin install --codex # user scope (~/.codex/hooks.json) +kapacitor plugin install --codex --project # project scope (/.codex/hooks.json) +``` + +The daemon starts Codex with `--sandbox workspace-write` and `--ask-for-approval on-request`. This lets Codex edit files in the agent's worktree but escalates sensitive operations (e.g. network calls, shell commands outside the worktree) through the daemon's permission bridge to the dashboard. + +PR review for hosted Codex agents is not yet supported (tracked in AI-632). The sandbox and approval-mode selectors in the launch dialog are also planned as a follow-up (AI-633). + +#### Daemon config settings + +The daemon reads its configuration from `~/.config/kapacitor/daemon.json`. Relevant settings: + +| Setting | Default | Description | +|---------|---------|-------------| +| `ClaudePath` | `"claude"` | Path to the Claude CLI binary. Resolved via `PATH` unless set to an absolute path. | +| `CodexPath` | `"codex"` | Path to the Codex CLI binary. Resolved via `PATH` unless set to an absolute path. | + ### Repository paths Manage known repo paths for the agent launch dialog. Repos are automatically added when agents are launched, but you can also manage the list manually: diff --git a/src/Kapacitor.Core/Resources/help-codex-hook.txt b/src/Kapacitor.Core/Resources/help-codex-hook.txt index e124107941..fc86211f91 100644 --- a/src/Kapacitor.Core/Resources/help-codex-hook.txt +++ b/src/Kapacitor.Core/Resources/help-codex-hook.txt @@ -8,10 +8,23 @@ forwards to the Capacitor server. Wired via `kapacitor plugin install --codex`. Hook events handled (Codex event → server route): SessionStart → POST /hooks/session-start/codex (also spawns watcher) Stop → POST /hooks/session-end/codex (kills watcher, drains transcript first) - PermissionRequest → POST /hooks/permission-request/codex (returns local {behavior: "allow"}) + PermissionRequest → see Daemon-bridge mode below UserPromptSubmit → swallowed (informational; no server route in v1) PreToolUse → swallowed (informational; no server route in v1) PostToolUse → swallowed (informational; no server route in v1) This command is not intended to be invoked directly. Use `kapacitor plugin install --codex` to register it via Codex's hooks framework. + +Daemon-bridge mode + +When KAPACITOR_DAEMON_URL is set (i.e. the hook is invoked inside a daemon- +launched hosted Codex agent), the PermissionRequest event bounces through the +local daemon's permission bridge and forwards the dashboard's decision back to +Codex. The legacy informational POST to the Capacitor server is skipped in +this mode. + +Bridge errors are fail-closed: the hook emits {"behavior": "deny"} and exits +non-zero rather than falling back to allow. Outside daemon context (no +KAPACITOR_DAEMON_URL), the hook returns the legacy allow-stub: +{"behavior": "allow"}. From cfccafdbab742dc6a7016d50a09d7cf335d222b9 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 09:58:13 +0200 Subject: [PATCH 32/36] [AI-68] verify: AOT clean + all tests green From eea94198d74b177d4af175ffe497f2bfe971b790 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 12:27:34 +0200 Subject: [PATCH 33/36] [AI-68] cli: long PermissionRequest hook timeout (24h) so dashboard decisions aren't killed at 30s Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 ++ src/kapacitor/Commands/PluginCommand.cs | 9 ++++++- .../PluginCommandCodexTests.cs | 25 +++++++++++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bac14454fd..e2479fc4cf 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,8 @@ kapacitor plugin install --codex --project # project scope (/.codex/hooks The daemon starts Codex with `--sandbox workspace-write` and `--ask-for-approval on-request`. This lets Codex edit files in the agent's worktree but escalates sensitive operations (e.g. network calls, shell commands outside the worktree) through the daemon's permission bridge to the dashboard. +> **Upgrading from an earlier version of kapacitor?** Re-run `kapacitor plugin install --codex` to refresh your `~/.codex/hooks.json`. Older installs used a 30-second timeout on the `PermissionRequest` hook, which would cause Codex to kill the hook before the dashboard could send back an approval or denial for prompts that take longer than 30 seconds to decide. The current install writes a 24-hour timeout for that event. + PR review for hosted Codex agents is not yet supported (tracked in AI-632). The sandbox and approval-mode selectors in the launch dialog are also planned as a follow-up (AI-633). #### Daemon config settings diff --git a/src/kapacitor/Commands/PluginCommand.cs b/src/kapacitor/Commands/PluginCommand.cs index 92b2dd460b..e507c6e9f6 100644 --- a/src/kapacitor/Commands/PluginCommand.cs +++ b/src/kapacitor/Commands/PluginCommand.cs @@ -8,6 +8,12 @@ public static class PluginCommand { const string CodexHookCommand = "kapacitor codex-hook"; + // PermissionRequest must wait for the dashboard's decision; the daemon-side + // bridge call is intentionally infinite. 86400s = 24h keeps Codex from + // killing the hook before the user approves or denies. + const int PermissionRequestTimeout = 86400; + const int DefaultHookTimeout = 30; + public static async Task HandleAsync(string[] args) { if (args.Length < 2) { PrintUsage(); @@ -187,12 +193,13 @@ public static bool InstallCodexHooks(string hooksPath) { } foreach (var evt in CodexHooksParser.CodexHookEvents) { + var timeout = evt == "PermissionRequest" ? PermissionRequestTimeout : DefaultHookTimeout; var kapacitorEntry = new JsonObject { ["hooks"] = new JsonArray( new JsonObject { ["type"] = "command", ["command"] = CodexHookCommand, - ["timeout"] = 30 + ["timeout"] = timeout } ) }; diff --git a/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs b/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs index 7cc570724b..99775f0b6b 100644 --- a/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs +++ b/test/kapacitor.Tests.Unit/PluginCommandCodexTests.cs @@ -20,13 +20,34 @@ public async Task InstallCodexHooks_writes_all_six_events() { var entries = hooks[evt]!.AsArray(); await Assert.That(entries.Count).IsEqualTo(1); - var inner = entries[0]!["hooks"]!.AsArray(); + var inner = entries[0]!["hooks"]!.AsArray(); + var expectedTimeout = evt == "PermissionRequest" ? 86400 : 30; await Assert.That(inner[0]!["type"]!.GetValue()).IsEqualTo("command"); await Assert.That(inner[0]!["command"]!.GetValue()).IsEqualTo("kapacitor codex-hook"); - await Assert.That(inner[0]!["timeout"]!.GetValue()).IsEqualTo(30); + await Assert.That(inner[0]!["timeout"]!.GetValue()).IsEqualTo(expectedTimeout); } } + [Test] + public async Task PermissionRequest_hook_uses_long_timeout_so_dashboard_decision_isnt_killed() { + using var tmp = new TempDir(); + var path = Path.Combine(tmp.Path, "hooks.json"); + + PluginCommand.InstallCodexHooks(path); + + var root = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + var permissionEntries = root["hooks"]!["PermissionRequest"]!.AsArray(); + var kapacitorEntry = permissionEntries.First(e => + (e!["hooks"] as JsonArray)!.Any(h => + h?["command"] is JsonValue v && v.TryGetValue(out var s) && s.Contains("kapacitor codex-hook")) + ); + var timeout = kapacitorEntry!["hooks"]!.AsArray()[0]!["timeout"]!.GetValue(); + + // 86400 = 24h; must be >= 86400 so Codex cannot kill the hook + // before the dashboard sends back an approval or denial. + await Assert.That(timeout).IsGreaterThanOrEqualTo(86400); + } + [Test] public async Task InstallCodexHooks_preserves_other_top_level_keys() { using var tmp = new TempDir(); From 78e49fe0fe7af1286ab7d8cf4f0668fc6c6a8d80 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 12:42:30 +0200 Subject: [PATCH 34/36] [AI-68] config: wire claude_path + codex_path through daemon profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add claude_path and codex_path as settable daemon profile fields so users can override the binary paths via `kapacitor config set daemon.claude_path` / `daemon.codex_path` or via KAPACITOR_CLAUDE_PATH / KAPACITOR_CODEX_PATH env vars. Previously DaemonConfig carried these fields but nothing populated them from the profile — the daemon always resolved "claude" / "codex" via PATH. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 22 +++- src/Kapacitor.Core/Config/AppConfig.cs | 6 + src/Kapacitor.Daemon/DaemonRunner.cs | 12 ++ src/kapacitor/Commands/ConfigCommand.cs | 6 + .../ConfigCommandTests.cs | 110 ++++++++++++++++++ .../DaemonConfigProfileTests.cs | 105 +++++++++++++++++ 6 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 test/kapacitor.Tests.Unit/DaemonConfigProfileTests.cs diff --git a/README.md b/README.md index e2479fc4cf..212790bd2f 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,24 @@ PR review for hosted Codex agents is not yet supported (tracked in AI-632). The #### Daemon config settings -The daemon reads its configuration from `~/.config/kapacitor/daemon.json`. Relevant settings: +Use `kapacitor config set` to configure the binary paths used by the daemon. The values are stored in the active profile and take effect the next time the daemon starts. -| Setting | Default | Description | -|---------|---------|-------------| -| `ClaudePath` | `"claude"` | Path to the Claude CLI binary. Resolved via `PATH` unless set to an absolute path. | -| `CodexPath` | `"codex"` | Path to the Codex CLI binary. Resolved via `PATH` unless set to an absolute path. | +```bash +kapacitor config set daemon.claude_path /opt/claude/bin/claude +kapacitor config set daemon.codex_path /opt/codex/bin/codex +``` + +| Key | Default | Description | +|-----|---------|-------------| +| `daemon.claude_path` | `"claude"` | Path to the Claude CLI binary. Resolved via `PATH` when not an absolute path. | +| `daemon.codex_path` | `"codex"` | Path to the Codex CLI binary. Resolved via `PATH` when not an absolute path. | + +You can also override these at runtime with environment variables (take precedence over the profile): + +```bash +KAPACITOR_CLAUDE_PATH=/opt/claude/bin/claude kapacitor daemon +KAPACITOR_CODEX_PATH=/opt/codex/bin/codex kapacitor daemon +``` ### Repository paths diff --git a/src/Kapacitor.Core/Config/AppConfig.cs b/src/Kapacitor.Core/Config/AppConfig.cs index 9816856314..c55f26440c 100644 --- a/src/Kapacitor.Core/Config/AppConfig.cs +++ b/src/Kapacitor.Core/Config/AppConfig.cs @@ -27,6 +27,12 @@ public record DaemonSettings { [JsonPropertyName("max_agents")] public int MaxAgents { get; init; } = 5; + + [JsonPropertyName("claude_path")] + public string? ClaudePath { get; init; } + + [JsonPropertyName("codex_path")] + public string? CodexPath { get; init; } } [JsonSerializable(typeof(KapacitorConfig))] diff --git a/src/Kapacitor.Daemon/DaemonRunner.cs b/src/Kapacitor.Daemon/DaemonRunner.cs index cb7ef1d185..3eaa2c56c9 100644 --- a/src/Kapacitor.Daemon/DaemonRunner.cs +++ b/src/Kapacitor.Daemon/DaemonRunner.cs @@ -64,6 +64,12 @@ public static async Task RunAsync(string[] args) { if (config.MaxConcurrentAgents == 5 && profileDaemon is { MaxAgents: var mx and not 5 }) config.MaxConcurrentAgents = mx; + if (!string.IsNullOrEmpty(profileDaemon?.ClaudePath)) + config.ClaudePath = profileDaemon.ClaudePath; + + if (!string.IsNullOrEmpty(profileDaemon?.CodexPath)) + config.CodexPath = profileDaemon.CodexPath; + if (Environment.GetEnvironmentVariable("KAPACITOR_DAEMON_NAME") is { } envName) { config.Name = envName; } @@ -75,6 +81,12 @@ public static async Task RunAsync(string[] args) { await Console.Error.WriteLineAsync($"Warning: ignoring invalid KAPACITOR_MAX_AGENTS={maxAgents}"); } + if (Environment.GetEnvironmentVariable("KAPACITOR_CLAUDE_PATH") is { Length: > 0 } envClaudePath) + config.ClaudePath = envClaudePath; + + if (Environment.GetEnvironmentVariable("KAPACITOR_CODEX_PATH") is { Length: > 0 } envCodexPath) + config.CodexPath = envCodexPath; + // Fall back to OS username, then machine name, then a static default if (string.IsNullOrEmpty(config.Name)) { var userName = Environment.UserName; diff --git a/src/kapacitor/Commands/ConfigCommand.cs b/src/kapacitor/Commands/ConfigCommand.cs index c370d8c854..e934a17690 100644 --- a/src/kapacitor/Commands/ConfigCommand.cs +++ b/src/kapacitor/Commands/ConfigCommand.cs @@ -67,6 +67,10 @@ public static Profile ApplySet(Profile profile, string key, string value) => "server_url" => profile with { ServerUrl = value }, "daemon.name" => profile with { Daemon = (profile.Daemon ?? new DaemonSettings()) with { Name = value } }, "daemon.max_agents" when int.TryParse(value, out var n) => profile with { Daemon = (profile.Daemon ?? new DaemonSettings()) with { MaxAgents = n } }, + "daemon.claude_path" when !string.IsNullOrEmpty(value) => profile with { Daemon = (profile.Daemon ?? new DaemonSettings()) with { ClaudePath = value } }, + "daemon.claude_path" => throw new ArgumentException("Invalid value for daemon.claude_path: must not be empty."), + "daemon.codex_path" when !string.IsNullOrEmpty(value) => profile with { Daemon = (profile.Daemon ?? new DaemonSettings()) with { CodexPath = value } }, + "daemon.codex_path" => throw new ArgumentException("Invalid value for daemon.codex_path: must not be empty."), "update_check" when bool.TryParse(value, out var b) => profile with { UpdateCheck = b }, "update_check" => throw new ArgumentException($"Invalid value for update_check: '{value}'. Must be true or false."), "disable_session_guidelines" when bool.TryParse(value, out var b) => profile with { DisableSessionGuidelines = b }, @@ -84,6 +88,8 @@ static int SetUsage() { Console.Error.WriteLine(" server_url Server URL"); Console.Error.WriteLine(" daemon.name Daemon name"); Console.Error.WriteLine(" daemon.max_agents Max concurrent agents"); + Console.Error.WriteLine(" daemon.claude_path Path to claude binary (default: claude)"); + Console.Error.WriteLine(" daemon.codex_path Path to codex binary (default: codex)"); Console.Error.WriteLine(" update_check Enable update check (true/false)"); Console.Error.WriteLine(" default_visibility Default session visibility (private, org_public, public)"); Console.Error.WriteLine(" disable_session_guidelines Skip injecting recurring-lessons context at SessionStart (true/false)"); diff --git a/test/kapacitor.Tests.Unit/ConfigCommandTests.cs b/test/kapacitor.Tests.Unit/ConfigCommandTests.cs index c90f06143b..987e97fe5b 100644 --- a/test/kapacitor.Tests.Unit/ConfigCommandTests.cs +++ b/test/kapacitor.Tests.Unit/ConfigCommandTests.cs @@ -1,9 +1,119 @@ +using System.Text.Json; using kapacitor.Commands; using kapacitor.Config; namespace kapacitor.Tests.Unit; public class ConfigCommandTests { + // ── daemon.claude_path ─────────────────────────────────────────────────── + + [Test] + public async Task ApplySet_DaemonClaudePath_StoresValue() { + var profile = new Profile(); + + var updated = ConfigCommand.ApplySet(profile, "daemon.claude_path", "/opt/claude/bin/claude"); + + await Assert.That(updated.Daemon!.ClaudePath).IsEqualTo("/opt/claude/bin/claude"); + } + + [Test] + public async Task ApplySet_DaemonClaudePath_EmptyString_Throws() { + var profile = new Profile(); + + await Assert.That(() => ConfigCommand.ApplySet(profile, "daemon.claude_path", "")) + .Throws(); + } + + [Test] + public async Task ApplySet_DaemonClaudePath_PreservesOtherDaemonSettings() { + var profile = new Profile { Daemon = new DaemonSettings { Name = "mybot", MaxAgents = 3 } }; + + var updated = ConfigCommand.ApplySet(profile, "daemon.claude_path", "/usr/local/bin/claude"); + + await Assert.That(updated.Daemon!.Name).IsEqualTo("mybot"); + await Assert.That(updated.Daemon.MaxAgents).IsEqualTo(3); + await Assert.That(updated.Daemon.ClaudePath).IsEqualTo("/usr/local/bin/claude"); + } + + // ── daemon.codex_path ──────────────────────────────────────────────────── + + [Test] + public async Task ApplySet_DaemonCodexPath_StoresValue() { + var profile = new Profile(); + + var updated = ConfigCommand.ApplySet(profile, "daemon.codex_path", "/opt/codex/bin/codex"); + + await Assert.That(updated.Daemon!.CodexPath).IsEqualTo("/opt/codex/bin/codex"); + } + + [Test] + public async Task ApplySet_DaemonCodexPath_EmptyString_Throws() { + var profile = new Profile(); + + await Assert.That(() => ConfigCommand.ApplySet(profile, "daemon.codex_path", "")) + .Throws(); + } + + [Test] + public async Task ApplySet_DaemonCodexPath_PreservesOtherDaemonSettings() { + var profile = new Profile { Daemon = new DaemonSettings { Name = "bot2", MaxAgents = 7 } }; + + var updated = ConfigCommand.ApplySet(profile, "daemon.codex_path", "/usr/bin/codex"); + + await Assert.That(updated.Daemon!.Name).IsEqualTo("bot2"); + await Assert.That(updated.Daemon.MaxAgents).IsEqualTo(7); + await Assert.That(updated.Daemon.CodexPath).IsEqualTo("/usr/bin/codex"); + } + + // ── DaemonSettings JSON round-trip ─────────────────────────────────────── + + [Test] + public async Task DaemonSettings_JsonRoundTrip_PreservesClaudeAndCodexPaths() { + var profile = new Profile { + Daemon = new DaemonSettings { + Name = "test", + ClaudePath = "/custom/claude", + CodexPath = "/custom/codex" + } + }; + var profileConfig = new ProfileConfig { + Profiles = new Dictionary { ["default"] = profile } + }; + + var json = JsonSerializer.Serialize(profileConfig, ProfileConfigJsonContextIndented.Default.ProfileConfig); + var decoded = JsonSerializer.Deserialize(json, ProfileConfigJsonContext.Default.ProfileConfig); + + var daemon = decoded?.Profiles["default"].Daemon; + await Assert.That(daemon?.ClaudePath).IsEqualTo("/custom/claude"); + await Assert.That(daemon?.CodexPath).IsEqualTo("/custom/codex"); + } + + [Test] + public async Task DaemonSettings_JsonRoundTrip_OldProfileWithoutPaths_DefaultsToNull() { + // Simulate an old config.json that has no claude_path / codex_path keys + const string json = """ + { + "version": 2, + "active_profile": "default", + "profiles": { + "default": { + "server_url": "https://example.com", + "daemon": { "name": "bot", "max_agents": 2 } + } + }, + "profile_bindings": {} + } + """; + + var decoded = JsonSerializer.Deserialize(json, ProfileConfigJsonContext.Default.ProfileConfig); + + var daemon = decoded?.Profiles["default"].Daemon; + await Assert.That(daemon?.ClaudePath).IsNull(); + await Assert.That(daemon?.CodexPath).IsNull(); + } + + // ── existing tests ─────────────────────────────────────────────────────── + [Test] public async Task ApplySet_DisableSessionGuidelines_True_UpdatesProfile() { var profile = new Profile(); diff --git a/test/kapacitor.Tests.Unit/DaemonConfigProfileTests.cs b/test/kapacitor.Tests.Unit/DaemonConfigProfileTests.cs new file mode 100644 index 0000000000..da0a21521a --- /dev/null +++ b/test/kapacitor.Tests.Unit/DaemonConfigProfileTests.cs @@ -0,0 +1,105 @@ +using kapacitor.Config; +using kapacitor.Daemon; + +namespace kapacitor.Tests.Unit; + +/// +/// Verifies that is correctly populated from +/// profile values — mirrors the logic in +/// DaemonRunner.RunAsync so that changes to either stay in sync. +/// +public class DaemonConfigProfileTests { + // Helper: simulate the DaemonRunner profile-wiring block in isolation. + static DaemonConfig ApplyProfileSettings(DaemonConfig config, DaemonSettings? profileDaemon) { + if (string.IsNullOrEmpty(config.Name) && !string.IsNullOrEmpty(profileDaemon?.Name)) + config.Name = profileDaemon.Name; + + if (config.MaxConcurrentAgents == 5 && profileDaemon is { MaxAgents: var mx and not 5 }) + config.MaxConcurrentAgents = mx; + + if (!string.IsNullOrEmpty(profileDaemon?.ClaudePath)) + config.ClaudePath = profileDaemon.ClaudePath; + + if (!string.IsNullOrEmpty(profileDaemon?.CodexPath)) + config.CodexPath = profileDaemon.CodexPath; + + return config; + } + + // ── claude_path from profile ───────────────────────────────────────────── + + [Test] + public async Task ClaudePath_FromProfile_OverridesDefault() { + var daemon = new DaemonSettings { ClaudePath = "/opt/claude/bin/claude" }; + var config = ApplyProfileSettings(new DaemonConfig(), daemon); + + await Assert.That(config.ClaudePath).IsEqualTo("/opt/claude/bin/claude"); + } + + [Test] + public async Task ClaudePath_NullInProfile_KeepsDefault() { + var daemon = new DaemonSettings { ClaudePath = null }; + var config = ApplyProfileSettings(new DaemonConfig(), daemon); + + await Assert.That(config.ClaudePath).IsEqualTo("claude"); + } + + [Test] + public async Task ClaudePath_EmptyInProfile_KeepsDefault() { + var daemon = new DaemonSettings { ClaudePath = "" }; + var config = ApplyProfileSettings(new DaemonConfig(), daemon); + + await Assert.That(config.ClaudePath).IsEqualTo("claude"); + } + + [Test] + public async Task ClaudePath_NullProfile_KeepsDefault() { + var config = ApplyProfileSettings(new DaemonConfig(), profileDaemon: null); + + await Assert.That(config.ClaudePath).IsEqualTo("claude"); + } + + // ── codex_path from profile ────────────────────────────────────────────── + + [Test] + public async Task CodexPath_FromProfile_OverridesDefault() { + var daemon = new DaemonSettings { CodexPath = "/opt/codex/bin/codex" }; + var config = ApplyProfileSettings(new DaemonConfig(), daemon); + + await Assert.That(config.CodexPath).IsEqualTo("/opt/codex/bin/codex"); + } + + [Test] + public async Task CodexPath_NullInProfile_KeepsDefault() { + var daemon = new DaemonSettings { CodexPath = null }; + var config = ApplyProfileSettings(new DaemonConfig(), daemon); + + await Assert.That(config.CodexPath).IsEqualTo("codex"); + } + + [Test] + public async Task CodexPath_EmptyInProfile_KeepsDefault() { + var daemon = new DaemonSettings { CodexPath = "" }; + var config = ApplyProfileSettings(new DaemonConfig(), daemon); + + await Assert.That(config.CodexPath).IsEqualTo("codex"); + } + + [Test] + public async Task CodexPath_NullProfile_KeepsDefault() { + var config = ApplyProfileSettings(new DaemonConfig(), profileDaemon: null); + + await Assert.That(config.CodexPath).IsEqualTo("codex"); + } + + // ── both paths set simultaneously ──────────────────────────────────────── + + [Test] + public async Task BothPaths_FromProfile_OverrideBothDefaults() { + var daemon = new DaemonSettings { ClaudePath = "/a/claude", CodexPath = "/b/codex" }; + var config = ApplyProfileSettings(new DaemonConfig(), daemon); + + await Assert.That(config.ClaudePath).IsEqualTo("/a/claude"); + await Assert.That(config.CodexPath).IsEqualTo("/b/codex"); + } +} From 4173de3dd872dbe7496aaa8be3e4b2d6f4709191 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 14:01:05 +0200 Subject: [PATCH 35/36] [AI-68] fix: harden HomeDirectory against empty/relative HOME and FileSystemOverlay against symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo review findings #2 (empty HOME breaks paths) and #3 (overlay follows symlinks): - PathHelpers.HomeDirectory now treats HOME as missing when it's empty, whitespace, or a non-rooted relative path — falling back to Environment.SpecialFolder.UserProfile in all three cases. Previously HOME="" or HOME="foo" would silently produce a relative HomeDirectory, causing daemon/CLI config reads/writes to land in CWD instead of the user profile. - FileSystemOverlay.OverlayDirectory passes EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint } to both GetFiles and GetDirectories. Symlinks (files and dirs) and other reparse points are now skipped during overlay, preventing symlink-loop recursion and accidental copy of out-of-repo content into the worktree. Both ClaudeLauncher and CodexLauncher use this helper to merge .claude/.codex from the source repo into the spawned worktree. Co-Authored-By: Claude Sonnet 4.6 --- src/Kapacitor.Core/PathHelpers.cs | 9 +- .../Services/FileSystemOverlay.cs | 6 +- .../FileSystemOverlayTests.cs | 88 +++++++++++++++++++ test/kapacitor.Tests.Unit/PathHelpersTests.cs | 63 +++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 test/kapacitor.Tests.Unit/FileSystemOverlayTests.cs create mode 100644 test/kapacitor.Tests.Unit/PathHelpersTests.cs diff --git a/src/Kapacitor.Core/PathHelpers.cs b/src/Kapacitor.Core/PathHelpers.cs index 84a3ff862b..bee9fe26dd 100644 --- a/src/Kapacitor.Core/PathHelpers.cs +++ b/src/Kapacitor.Core/PathHelpers.cs @@ -1,7 +1,14 @@ namespace kapacitor; static class PathHelpers { - public static string HomeDirectory => Environment.GetEnvironmentVariable("HOME") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + public static string HomeDirectory { + get { + var home = Environment.GetEnvironmentVariable("HOME"); + if (string.IsNullOrWhiteSpace(home) || !Path.IsPathRooted(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return home; + } + } static readonly string ConfigDir = Environment.GetEnvironmentVariable("KAPACITOR_CONFIG_DIR") ?? Path.Combine(HomeDirectory, ".config", "kapacitor"); diff --git a/src/Kapacitor.Daemon/Services/FileSystemOverlay.cs b/src/Kapacitor.Daemon/Services/FileSystemOverlay.cs index 667757a613..a0460f5ef5 100644 --- a/src/Kapacitor.Daemon/Services/FileSystemOverlay.cs +++ b/src/Kapacitor.Daemon/Services/FileSystemOverlay.cs @@ -9,14 +9,16 @@ namespace kapacitor.Daemon.Services; /// internal static class FileSystemOverlay { public static void OverlayDirectory(string source, string dest) { + var skipReparsePoints = new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint }; + Directory.CreateDirectory(dest); - foreach (var file in Directory.GetFiles(source)) { + foreach (var file in Directory.GetFiles(source, "*", skipReparsePoints)) { var destFile = Path.Combine(dest, Path.GetFileName(file)); if (!File.Exists(destFile)) File.Copy(file, destFile); } - foreach (var dir in Directory.GetDirectories(source)) { + foreach (var dir in Directory.GetDirectories(source, "*", skipReparsePoints)) { OverlayDirectory(dir, Path.Combine(dest, Path.GetFileName(dir))); } } diff --git a/test/kapacitor.Tests.Unit/FileSystemOverlayTests.cs b/test/kapacitor.Tests.Unit/FileSystemOverlayTests.cs new file mode 100644 index 0000000000..9d673b1159 --- /dev/null +++ b/test/kapacitor.Tests.Unit/FileSystemOverlayTests.cs @@ -0,0 +1,88 @@ +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using kapacitor.Daemon.Services; + +namespace kapacitor.Tests.Unit; + +public class FileSystemOverlayTests { + [Test] + public async Task OverlayDirectory_copies_regular_files() { + using var tmp = new TempDir(); + var sourceFile = Path.Combine(tmp.Source, "file.txt"); + File.WriteAllText(sourceFile, "hello"); + + FileSystemOverlay.OverlayDirectory(tmp.Source, tmp.Dest); + + await Assert.That(File.Exists(Path.Combine(tmp.Dest, "file.txt"))).IsTrue(); + } + + [Test] + public async Task OverlayDirectory_skips_symlinked_files() { + using var tmp = new TempDir(); + + // Create an external file that the symlink points to + var externalFile = Path.Combine(tmp.External, "secret.txt"); + File.WriteAllText(externalFile, "secret"); + + // Create a symlink inside source pointing to external file + var linkPath = Path.Combine(tmp.Source, "link.txt"); + File.CreateSymbolicLink(linkPath, externalFile); + + FileSystemOverlay.OverlayDirectory(tmp.Source, tmp.Dest); + + await Assert.That(File.Exists(Path.Combine(tmp.Dest, "link.txt"))).IsFalse(); + } + + [Test] + public async Task OverlayDirectory_skips_symlinked_directories() { + using var tmp = new TempDir(); + + // Create an external directory with content + var externalDir = Path.Combine(tmp.External, "extdir"); + Directory.CreateDirectory(externalDir); + File.WriteAllText(Path.Combine(externalDir, "inside.txt"), "contents"); + + // Create a symlinked subdir in source pointing to external dir + var linkDir = Path.Combine(tmp.Source, "linked-dir"); + Directory.CreateSymbolicLink(linkDir, externalDir); + + FileSystemOverlay.OverlayDirectory(tmp.Source, tmp.Dest); + + await Assert.That(Directory.Exists(Path.Combine(tmp.Dest, "linked-dir"))).IsFalse(); + } + + [Test] + public async Task OverlayDirectory_handles_symlink_loop_without_recursion() { + using var tmp = new TempDir(); + + // Create a symlink loop: source/loop → source (points back to its ancestor) + var loopLink = Path.Combine(tmp.Source, "loop"); + Directory.CreateSymbolicLink(loopLink, tmp.Source); + + // Should complete without StackOverflowException or hang + FileSystemOverlay.OverlayDirectory(tmp.Source, tmp.Dest); + + // No loop directory should have been created in dest + await Assert.That(Directory.Exists(Path.Combine(tmp.Dest, "loop"))).IsFalse(); + } + + sealed class TempDir : IDisposable { + readonly DirectoryInfo _root; + + public string Source { get; } + public string Dest { get; } + public string External { get; } + + public TempDir() { + _root = Directory.CreateTempSubdirectory("kapacitor-overlay-test-"); + Source = Path.Combine(_root.FullName, "source"); + Dest = Path.Combine(_root.FullName, "dest"); + External = Path.Combine(_root.FullName, "external"); + Directory.CreateDirectory(Source); + Directory.CreateDirectory(Dest); + Directory.CreateDirectory(External); + } + + public void Dispose() => _root.Delete(recursive: true); + } +} diff --git a/test/kapacitor.Tests.Unit/PathHelpersTests.cs b/test/kapacitor.Tests.Unit/PathHelpersTests.cs new file mode 100644 index 0000000000..cb89033c5c --- /dev/null +++ b/test/kapacitor.Tests.Unit/PathHelpersTests.cs @@ -0,0 +1,63 @@ +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace kapacitor.Tests.Unit; + +[NotInParallel("HomeEnvVarMutation")] +public class PathHelpersTests { + [Test] + public async Task HomeDirectory_uses_HOME_when_set_to_rooted_absolute_path() { + var tmp = Directory.CreateTempSubdirectory("kapacitor-pathhelpers-test-"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + Environment.SetEnvironmentVariable("HOME", tmp.FullName); + await Assert.That(PathHelpers.HomeDirectory).IsEqualTo(tmp.FullName); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + tmp.Delete(recursive: true); + } + } + + [Test] + public async Task HomeDirectory_falls_back_when_HOME_is_empty_string() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + Environment.SetEnvironmentVariable("HOME", ""); + var home = PathHelpers.HomeDirectory; + var expected = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + await Assert.That(home).IsEqualTo(expected); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + } + } + + [Test] + public async Task HomeDirectory_falls_back_when_HOME_is_whitespace() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + Environment.SetEnvironmentVariable("HOME", " "); + var home = PathHelpers.HomeDirectory; + var expected = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + await Assert.That(home).IsEqualTo(expected); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + } + } + + [Test] + public async Task HomeDirectory_falls_back_when_HOME_is_relative_path() { + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try { + Environment.SetEnvironmentVariable("HOME", "foo/bar"); + var home = PathHelpers.HomeDirectory; + var expected = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + await Assert.That(home).IsEqualTo(expected); + } finally { + Environment.SetEnvironmentVariable("HOME", originalHome); + } + } +} From 5633b19abcdd7e38fe1e32b6d8d61a9bfd5bf898 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 15 May 2026 14:17:27 +0200 Subject: [PATCH 36/36] [AI-68] test: globally serialize Stop + PermissionRequest_records to fix in-suite Console.SetOut race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests use Console.SetOut to capture stdout. Stop_maps_to_session_end_codex_route takes ~3m20s (real WatcherManager path through InlineDrainAsync + WhatsDoneGenerator spawn against an absent transcript), holding Console.Out captured for the full duration. With the suite at 727 tests, the long capture window plus TUnit's parallel scheduling drops captured output for the other ConsoleSerialGroup test intermittently — both pass in isolation, fail together in suite. Tightening from NotInParallel(ConsoleSerialGroup) (group-scoped) to plain NotInParallel (globally sequential) for these two tests guarantees they don't overlap with any other test, removing the race surface. Other ConsoleSerialGroup tests in the file (the new PermissionRequest_with_daemon_url_* set from AI-68) keep their group-scoped attribute — they're fast and serializing only against each other is sufficient. --- test/kapacitor.Tests.Unit/CodexHookCommandTests.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs b/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs index 4e70ad347c..4acad5fcc9 100644 --- a/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs +++ b/test/kapacitor.Tests.Unit/CodexHookCommandTests.cs @@ -52,7 +52,14 @@ public async Task SessionStart_posts_to_session_start_codex_with_normalized_sess // the watcher spawn. } - [Test, NotInParallel(ConsoleSerialGroup)] + // Globally sequential: this test takes ~3m20s and holds Console.Out + // captured for the duration. Sharing only ConsoleSerialGroup with other + // stdout-mutating tests is enough on a fast machine, but in suite runs + // the long capture window plus TUnit's parallel scheduling has been + // observed to interleave Console.SetOut from same-group tests, dropping + // the captured output. NotInParallel with no group key forces this test + // to run on its own. + [Test, NotInParallel] public async Task Stop_maps_to_session_end_codex_route() { _server.Given(Request.Create().WithPath("/hooks/session-end/codex").UsingPost()) .RespondWith(Response.Create().WithStatusCode(200).WithBody("{}")); @@ -98,7 +105,10 @@ public async Task Stop_maps_to_session_end_codex_route() { } } - [Test, NotInParallel(ConsoleSerialGroup)] + // Globally sequential alongside Stop_maps_to_session_end_codex_route — + // see that test for why ConsoleSerialGroup alone has been observed to + // interleave in suite runs and drop captured Console output. + [Test, NotInParallel] public async Task PermissionRequest_records_event_and_yields_decision_to_codex() { // The Codex permission-request hook must not silently auto-allow tool // calls; in the stub branch (no KAPACITOR_DAEMON_URL set) it records