diff --git a/README.md b/README.md index 70fd34894f..bf7db11472 100644 --- a/README.md +++ b/README.md @@ -2333,11 +2333,13 @@ kcap agent start claude -- --model opus # everything after `--` is passed kcap agent start codex --worktree -- -m gpt-5 # run in an isolated git worktree instead of in place kcap agent start claude -d # start without attaching; prints the agent id kcap agent start claude --title "Fix login" -- "fix the login redirect" # name the session +kcap agent start claude --account ~/.claude-work # run under a registered account (id prefix or directory) ``` -- **`--` boundary:** flags before `--` are kcap's; everything after `--` is forwarded to the `claude`/`codex` CLI unchanged. kcap flags: `--worktree`, `--private`, `--title `, `--daemon `, `-d`/`--detach`. +- **`--` boundary:** flags before `--` are kcap's; everything after `--` is forwarded to the `claude`/`codex` CLI unchanged. kcap flags: `--worktree`, `--private`, `--title `, `--account `, `--daemon `, `-d`/`--detach`. - **Visibility:** by default the agent is **registered with the server**, so it appears in your own web UI immediately and you can drive it from the browser — start in the terminal, continue from anywhere. It is **visible only to you** until you share it. Pass `--private` to keep it purely local: unregistered, not streamed to the server, and not shown in the web UI. - **Session title:** `--title ` (or `--title=`) names the agent's session, and that name sticks until it is renamed. Without it, the session starts titled after the prompt — the last argument after `--`, when neither it nor the argument before it is a flag — and a generated title replaces that once one lands. `--private` agents are never registered, so a title has no effect on them. +- **Account:** `--account ` (or `--account=`) runs a Claude or Codex agent under one of your [accounts](#accounts): an id prefix of at least 4 characters, or the account's directory (`~` is expanded). kcap refuses and starts nothing when the value matches no account of that vendor, when a prefix matches more than one, or when the daemon is too old to choose an account. Without the flag the agent uses the vendor's default configuration directory, as before. - **Work location:** by default the agent runs **in place in your current directory** (it edits your real files). Pass `--worktree` to run in a throwaway git worktree instead. - **What a worktree deliberately does NOT inherit:** an agent worktree is a checkout of whatever branch is @@ -2447,7 +2449,9 @@ kcap accounts rewire [] # reinstall recording into one or kcap accounts remove # remove kcap's recording and forget the account ``` -`` is an account id prefix (at least 4 characters) or its directory. `kcap setup` offers the directories it discovers in your home folder. `remove` leaves the directory and its login untouched. Codex asks you to trust the kcap hooks once per home, the next time you start it with that `CODEX_HOME`. For a Codex account, `add` and `rewire` also enable Codex sandbox network access for your server(s), as `kcap plugin install --codex` does; pass `--skip-codex-network-access` to leave that home's `config.toml` network keys untouched. +`` is an account id prefix (at least 4 characters) or its directory. `kcap agent start --account ` starts a [local agent](#local-agents-kcap-agent) under one. `kcap setup` offers the directories it discovers in your home folder. `remove` leaves the directory and its login untouched. Codex asks you to trust the kcap hooks once per home, the next time you start it with that `CODEX_HOME`. For a Codex account, `add` and `rewire` also enable Codex sandbox network access for your server(s), as `kcap plugin install --codex` does; pass `--skip-codex-network-access` to leave that home's `config.toml` network keys untouched. + +`add`, `remove`, `rename` and `rewire` accept `--json`: stdout then carries exactly one JSON line (`action`, `account_id`, `succeeded`, `steps` with `name`/`succeeded`/`detail`, `revision`, `error`) and errors still go to stderr. `revision` is the account registry revision the command committed after its wiring attempt (`null` when it committed nothing), so a reader that sees a snapshot at or past it knows the snapshot follows that attempt. `add` and `rewire` commit one even when wiring finds nothing to change, and an `add` whose wiring fails still registers the account and records the failure. A `remove` that cannot unwire leaves the account registered, records the failure on it and commits a revision too, since it may have unwired part of the account. The exit code is 0 only when `succeeded` is true. `list` ignores the flag. ### Profiles diff --git a/docs/superpowers/plans/2026-10-22-vendor-accounts-launch-choice-daemon.md b/docs/superpowers/plans/2026-10-22-vendor-accounts-launch-choice-daemon.md new file mode 100644 index 0000000000..7fbbf10567 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-vendor-accounts-launch-choice-daemon.md @@ -0,0 +1,454 @@ +# Vendor accounts at launch, PR 1 (daemon and CLI) 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:** A hosted session can be started under a chosen Claude or Codex account: the daemon resolves every launch to a config directory before the process starts, keeps it on the agent, lists the host's accounts and its own defaults to the app and the server, probes the Codex catalog per account, and the CLI can name an account on `kcap agent start` and report its `kcap accounts` verbs machine-readably. + +**Architecture:** Account resolution and the snapshot are pure Core code over the Part A registry (`Capacitor.Cli.Core/Accounts`); the daemon adds one background publisher that watches `accounts.json`, builds the snapshot, feeds local subscribers and the server, and nudges the capability refresh. Launchers stop holding a vendor layout and take one per launch from the resolved account; the PTY and app-server environment builders learn a removal set so the vendor's home variable is removed, not merely left out. Every wire change is a trailing optional field. + +**Tech Stack:** .NET 10, NativeAOT (System.Text.Json source generation), TUnit, local control socket frames, SignalR hub. + +**Spec:** `docs/superpowers/specs/2026-10-21-vendor-accounts-launch-choice-design.md` (Sections 3, 4, 5, 10 item 1, 11, 12), on top of `docs/superpowers/specs/2026-10-07-vendor-accounts-and-limits-design.md` Sections 3, 4, 6.1 and 6.3. + +## Global Constraints + +- Every wire field is appended and optional: `LaunchAgentCommand`, `DaemonConnect`, `AgentRegistered`, `LiveAgentInfo`, `AgentStatusDto`, `DaemonInfoDto`, `ResumeLaunchDescriptor` keep positional compatibility; `FrameType` values are never reused (next free: 86). +- Null account id means the daemon's default; an explicit id is honoured or refused with `account_unavailable`, never replaced by the default. A resume refuses the same way and never falls through to `kcap continue`. +- The daemon never writes to `accounts.json`; the host registry is read under no lock (`AccountStore.TryLoad`). +- The daemon's default directory per vendor is the one `HarnessRegistry.FromEnvironment` resolved in `DaemonRunner`; the snapshot carries it as a directory and a key, never a registry id. +- Directory keys are computed only where the directory lives: `AccountDirectory.Normalize` then case-fold unless `OperatingSystem.IsLinux()`. +- Child environment: remove `CLAUDE_CONFIG_DIR` / `CODEX_HOME` when the resolved directory is the vendor's built-in location (`AccountLayouts.DefaultDirectory`), set it to the resolved directory otherwise; the daemon's own environment is never changed. +- Local capability token `accounts/1`; hub one-way method `AccountsChanged`; throttle 30 s trailing. +- Project rules from `CLAUDE.md`: one type per file; `FrozenDictionary.Empty` for empty read-only maps; `TempDir` from Helpers; `[NotInParallel]` only for process-global state; no `InternalsVisibleTo` to a production assembly; comments scarce; README and `help-*.txt` change in the same PR; AOT publish shows no IL2026/IL3050; commit subjects end with `(#1806)`. + +## Review Focus + +- A malformed `accounts.json` mid-run (a hand edit, a half-written replace): the publisher keeps the last good snapshot and a launch naming an id reads the registry as empty, so it is refused, never defaulted. Pinned in Task 6 and Task 7. +- An ambiguous `--account` prefix matching two ids: refused with both candidates listed, nothing spawned. Pinned in Task 15. +- The registry rewritten many times in one burst (the npm refresh wires every account): one debounced snapshot, one trailing server send. Pinned in Task 6 and Task 14. +- A server without the `AccountsChanged` handler: the send is swallowed at debug level, the connection stays up. Pinned in Task 14. +- A Windows registry entry spelled with different casing than the daemon's environment: the keys are equal and the Default badge lands. Pinned in Task 1 and Task 4. + +Run one test class with: `dotnet run --project test//.csproj -- --treenode-filter "/*/*//*"`. + +--- + +### Task 1: Directory keys + +**Files:** +- Create: `src/Capacitor.Cli.Core/Accounts/DirectoryKey.cs` +- Test: `test/Capacitor.Cli.Core.Tests.Unit/Accounts/DirectoryKeyTests.cs` + +**Interfaces:** +- Produces: `public static class DirectoryKey { public static string Of(string directory); }` — `AccountDirectory.Normalize(directory)` then `ToLowerInvariant()` unless `OperatingSystem.IsLinux()`. Throws what `Normalize` throws. + +- [ ] **Step 1: Write the failing tests** + +```csharp +[Test] public async Task Same_directory_spelled_with_a_trailing_separator_has_one_key() + // DirectoryKey.Of(tmp.Path) == DirectoryKey.Of(tmp.Path + Path.DirectorySeparatorChar) +[Test] public async Task Symlinked_spelling_keys_to_the_physical_directory() + // create tmp.CreateDir("real"), symlink tmp.PathTo("link") -> real (skip on Windows without privilege); Of(link) == Of(real) +[Test] public async Task Case_differs_keys_equal_except_on_linux() + // var a = tmp.CreateDir("Work"); Of(a) vs Of(path with "WORK"): equal unless OperatingSystem.IsLinux(), where the path does not exist -> Normalize of a missing path: assert via Of(a).Equals(Of(a).ToLowerInvariant()) on non-Linux and ordinal on Linux +``` + +- [ ] **Step 2: Run to verify they fail** — Expected: compile error, `DirectoryKey` not defined. +- [ ] **Step 3: Implement `DirectoryKey.Of`.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Add host-computed directory keys for accounts (#1806)` + +### Task 2: Account resolution + +**Files:** +- Create: `src/Capacitor.Cli.Core/Accounts/ResolvedAccount.cs`, `src/Capacitor.Cli.Core/Accounts/AccountResolution.cs`, `src/Capacitor.Cli.Core/Accounts/AccountResolutionOutcome.cs` +- Test: `test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountResolutionTests.cs` + +**Interfaces:** +- Produces: `public sealed record ResolvedAccount(string Directory, string? AccountId, string Label)`. +- Produces: `public sealed record AccountResolutionOutcome(ResolvedAccount? Account, string? Refusal)` with `public bool Refused => Refusal is not null`. +- Produces: `public static class AccountResolution` with `public const string Unavailable = "account_unavailable";` and + `public static AccountResolutionOutcome Resolve(AccountRegistry? registry, HarnessId vendor, string? accountId, string defaultDirectory)`: + null or empty id → the default: `Account = new(defaultDirectory, registered-id-or-null, label)` where the id and label come from a registry entry of `vendor` whose `DirectoryKey.Of` equals the default's, else label is `Path.GetFileName(defaultDirectory)`; an id not in the registry, or in it under another vendor → `Refusal = $"{Unavailable}: no {vendor} account {id}"`; a registered entry whose directory does not exist → `Refusal = $"{Unavailable}: {directory} is missing"`. + `public static AccountResolutionOutcome ResolveForResume(AccountRegistry? registry, HarnessId vendor, string? accountId, string? directory, string defaultDirectory)`: `directory` null → `Resolve(registry, vendor, null, defaultDirectory)`; `accountId` non-null → must still be registered for `vendor` (any directory) and that directory must exist, else refused; `accountId` null → `directory` must exist, else refused; label as above. + `public static AccountResolutionOutcome ResolveArgument(AccountRegistry registry, HarnessId vendor, string idPrefixOrDirectory)` for the CLI: a prefix of 4+ chars matching exactly one id of `vendor` → that account; two or more matches → `Refusal` naming them; otherwise a path (`~` expanded by the caller) matched by `AccountDirectory.Same` against `vendor`'s entries; no match → `Refusal = $"{Unavailable}: {arg} is not a registered {vendor} account; run kcap accounts add"`. + +- [ ] **Step 1: Write the failing tests** (a registry built in memory with two Claude entries under `tmp.CreateDir`, one Codex) + +```csharp +Null_id_resolves_to_the_default_directory_with_its_registered_id() // Account.AccountId == the entry whose key matches +Null_id_with_an_unregistered_default_has_no_id_and_the_directory_name_as_label() +Unknown_id_is_refused_with_account_unavailable() // Refusal starts with "account_unavailable" +Id_registered_for_another_vendor_is_refused() +Registered_id_whose_directory_is_gone_is_refused_naming_the_directory() +Resume_with_null_directory_resolves_as_a_fresh_launch() +Resume_with_a_registered_id_now_removed_is_refused() +Resume_with_null_id_resumes_under_the_stored_directory_while_it_exists() +Argument_prefix_matching_one_id_resolves() +Argument_prefix_matching_two_ids_is_refused_listing_both() +Argument_directory_resolves_by_same_directory() +Argument_unregistered_directory_is_refused_with_the_add_remedy() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement the three files.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Resolve a launch to an account directory before it starts (#1806)` + +### Task 3: Wire contracts + +**Files:** +- Modify: `src/Capacitor.Cli.Core/Models.cs` (`LaunchAgentCommand`, `DaemonConnect`, `AgentRegistered`, `LiveAgentInfo`), `src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs` (`AgentStatusDto`, `DaemonInfoDto`), `src/Capacitor.Cli.Core/LocalIpc/FrameType.cs`, `src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs`, `src/Capacitor.Cli.Core/Resume/ResumeLaunchDescriptor.cs` +- Create: `src/Capacitor.Cli.Core/Accounts/AccountsSnapshotDto.cs`, `src/Capacitor.Cli.Core/Accounts/AccountViewDto.cs`, `src/Capacitor.Cli.Core/Accounts/DefaultDirectoryDto.cs`, `src/Capacitor.Cli.Core/Accounts/AccountsSnapshotJsonContext.cs` +- Test: `test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecAccountsTests.cs`, `test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecTests.cs` (spawn round-trip), `test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/DaemonStatusDtoTests.cs` (exact JSON gains the new members as null), `test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountsSnapshotDtoTests.cs` + +**Interfaces:** +- `LaunchAgentCommand`: append `string? AccountId = null`. +- `ResumeLaunchDescriptor`: append `string? AccountDirectory = null, string? AccountId = null, string? AccountLabel = null`. +- `AgentStatusDto`, `AgentRegistered`, `LiveAgentInfo`: append the same three. +- `DaemonInfoDto`: append `Dictionary? VendorModelsByAccount = null` (key: registry id). +- `DaemonConnect`: append `bool SupportsAccountLaunches = false, AccountsSnapshotDto? Accounts = null, Dictionary? VendorModelsByAccount = null`. +- `AccountsSnapshotDto(string HostId, long Revision, AccountViewDto[] Accounts, DefaultDirectoryDto[] Defaults)`; `AccountViewDto(string Id, string Vendor, string Directory, string DirectoryKey, string Label, string RecordingState)`; `DefaultDirectoryDto(string Vendor, string Directory, string DirectoryKey)`. Vendor is the launch token (`claude`, `codex`); RecordingState is the enum name in lowercase (`recording`, `installed`, `broken`, `not_wired`). `AccountsSnapshotJsonContext` snake_case, `[JsonSerializable(typeof(AccountsSnapshotDto))]`, used by the frame and the hub (register the type in `Models.cs`'s hub JSON context too). +- `FrameType.AccountsSubscribe = 32`, `FrameType.AccountsSnapshot = 87` (Text = JSON). +- `FrameCodec.Spawn(..., AgentStartTitle? title = null, string? accountId = null)`: after the title byte(s) write `WriteLp(ms, accountId ?? "")`; `ParseSpawn` reads it when bytes remain, empty → null; the tuple gains `string? accountId` last. `FrameCodec.AccountsSnapshot(string json)` → `LocalFrame` of type `AccountsSnapshot`, and `LocalFrame.AccountsSubscribe()`. + +- [ ] **Step 1: Write the failing tests** + +```csharp +// FrameCodecAccountsTests +Spawn_round_trips_an_account_id() // Spawn(..., accountId: "abc") -> ParseSpawn.accountId == "abc" +Spawn_without_account_bytes_parses_null() // a frame built by the old overload / truncated after the title -> null +Spawn_with_account_but_no_title_round_trips() // title null, account "abc" +AccountsSnapshot_frame_carries_the_json_as_text() +// AccountsSnapshotDtoTests +Snapshot_serializes_snake_case_with_every_field_present() // exact JSON string for one account, one default +// DaemonStatusDtoTests: extend the exact-JSON expectation with "vendor_models_by_account":null and the three agent fields as null +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Make the contract changes.** Keep every existing positional construction compiling (defaults only). +- [ ] **Step 4: Run the Core suite to verify everything passes, then** `dotnet build src/Capacitor.Cli/Capacitor.Cli.csproj` **to confirm the daemon and CLI still compile.** +- [ ] **Step 5: Commit** — `Carry an account on the launch, status and resume contracts (#1806)` + +### Task 4: Snapshot builder + +**Files:** +- Create: `src/Capacitor.Cli.Core/Accounts/AccountSnapshotBuilder.cs` +- Modify: `src/Capacitor.Cli.Core/Accounts/AccountWiring.cs` (add `public static RecordingState State(HarnessId vendor, string directory, UserHome home)`; the existing overload delegates to it) +- Test: `test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountSnapshotBuilderTests.cs` + +**Interfaces:** +- Produces: `public static class AccountSnapshotBuilder { public static AccountsSnapshotDto Build(AccountRegistry registry, string hostId, IReadOnlyDictionary defaults, UserHome home); }` — one `AccountViewDto` per registry entry with `DirectoryKey.Of` and `AccountWiring.State`; one `DefaultDirectoryDto` per entry of `defaults`. No synthesized row: the reader derives the Default badge and the unregistered-default row by key. +- Produces: `public static class VendorTokens { public static string Of(HarnessId vendor); public static HarnessId? Parse(string token); }` if no such mapping exists in Core yet (check `HarnessId` extensions first and reuse what is there). + +- [ ] **Step 1: Write the failing tests** (registry with a wired Claude dir, an unwired Codex dir; defaults pointing at the Claude dir and at an unregistered Codex dir) + +```csharp +Builds_one_view_per_account_with_key_label_and_recording_state() +Defaults_carry_directory_and_key_only() // no id field; the Claude default's key equals the Claude view's key +Unregistered_default_matches_no_view_by_key() +Keys_equal_across_casing_off_linux() // registry dir "Work", default dir "work" on macOS/Windows -> equal keys +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Build the accounts snapshot from the registry and daemon defaults (#1806)` + +### Task 5: `kcap accounts` commits after wiring and reports machine-readably + +**Files:** +- Modify: `src/Capacitor.Cli.Core/Accounts/VendorAccount.cs` (append `[property: JsonPropertyName("wired_at")] DateTimeOffset? WiredAt = null, [property: JsonPropertyName("wiring_failure")] string? WiringFailure = null`), `src/Capacitor.Cli/Commands/AccountsCommand.cs`, `src/Capacitor.Cli.Core/Resources/help-accounts.txt`, `README.md` (`### Accounts`) +- Create: `src/Capacitor.Cli.Core/Accounts/AccountActionResultDto.cs`, `src/Capacitor.Cli.Core/Accounts/WiringStepDto.cs`, `src/Capacitor.Cli.Core/Accounts/AccountActionResultJsonContext.cs` +- Test: `test/Capacitor.Cli.Tests.Unit/Commands/AccountsCommandTests.cs`, `test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountStoreTests.cs` + +**Interfaces:** +- `AccountActionResultDto(string Action, string? AccountId, bool Succeeded, WiringStepDto[] Steps, long? Revision, string? Error)`; `WiringStepDto(string Name, bool Succeeded, string Detail)`; snake_case context. +- `AccountsCommand`: a `--json` flag (stripped like `--skip-codex-network-access`) makes every verb print exactly one `AccountActionResultDto` line on stdout and nothing else there; errors go to stderr as today. Exit 0 only when `Succeeded`. +- After `WireAndReport` (add, rewire) and after `Unwire` (remove) the command calls `accounts.Mutate` to stamp `WiredAt = now` and `WiringFailure = null` or the failure summary on the entry (remove: drop the entry in that same mutation, which is the one it does today). A rename is already a mutation. The result's `Revision` is the revision that mutation produced (`Mutate` returns it: change `Mutate` callers to read `Load().Revision` after, or extend `Mutate` to return the new revision alongside `T`; pick the latter: `public (T Result, long Revision) MutateWithRevision(...)`). + +- [ ] **Step 1: Write the failing tests** + +```csharp +// AccountStoreTests +MutateWithRevision_returns_the_revision_it_wrote() +// AccountsCommandTests (stub PluginEnvironment as the existing tests do) +Rewire_that_changes_nothing_still_increments_the_revision() +Add_whose_wiring_fails_registers_and_reports_failure_with_the_committed_revision() // exit 1, Succeeded false, Revision == registry revision +Json_flag_prints_one_result_line_with_steps_and_revision() +Remove_reports_the_revision_of_the_removal() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** Update `help-accounts.txt` (`--json`) and the README `### Accounts` section in the same step. +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Commit a registry revision after every accounts verb and add --json (#1806)` + +### Task 6: The daemon publishes accounts locally + +**Files:** +- Create: `src/Capacitor.Cli.Daemon/Services/AccountsPublisher.cs`, `src/Capacitor.Cli.Daemon/Services/AccountsIpc.cs` +- Modify: `src/Capacitor.Cli.Daemon/DaemonConfig.cs` (`public AccountStore? Accounts { get; set; }`, `public IReadOnlyDictionary DefaultDirectories { get; set; } = FrozenDictionary.Empty;`), `src/Capacitor.Cli.Daemon/DaemonRunner.cs` (set both from `AccountStore.Beside(store)` and `harnesses.Of().Paths.Home` / `harnesses.Of().Paths.Home`; register the publisher and the IPC handler), `src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs` (route `AccountsSubscribe`), `src/Capacitor.Cli.Daemon/Services/LocalControlCapabilities.cs` (`"accounts/1"`) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherTests.cs`, `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsIpcTests.cs`, `test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlCapabilitiesTests.cs` (or wherever the capability list is pinned today) + +**Interfaces:** +- `internal sealed class AccountsPublisher : BackgroundService` with ctor `(DaemonConfig config, UserHome home, TimeProvider time, ILogger logger)`: watches `config.Accounts.Directory` for `accounts.json` with a `FileSystemWatcher` plus a 2-second trailing debounce (reuse the pattern `VendorCliWatcher` uses for catalog paths, or poll every 2 s when the watcher cannot be created); exposes `AccountsSnapshotDto? Current`, `event Action? Changed`, and `Task RebuildAsync(CancellationToken)`. A `TryLoad` that returns null keeps `Current` unchanged. The host id comes from `config.Accounts.HostId()` once at start. +- `internal sealed class AccountsIpc(AccountsPublisher publisher)` with `Task HandleSubscribeAsync(Stream stream, CancellationToken ct)` shaped like `DaemonStatusIpc.HandleSubscribeAsync`: writes `Current` as an `AccountsSnapshot` frame at once, then one frame per `Changed`, until the client disconnects. + +- [ ] **Step 1: Write the failing tests** (a `TempDir`-backed `AccountStore`, a fake clock) + +```csharp +// AccountsPublisherTests +Start_builds_the_snapshot_with_host_id_revision_and_defaults() +A_registry_rewrite_republishes_within_the_debounce() // mutate via AccountStore, expect Changed with the new revision within 5 s +A_burst_of_rewrites_yields_one_snapshot() // ten mutations inside 500 ms -> exactly one Changed after the debounce +A_malformed_registry_keeps_the_last_snapshot() // write garbage to accounts.json -> Current unchanged, no Changed +// AccountsIpcTests +Subscribe_writes_the_current_snapshot_then_each_change() +// capability list test: Current contains "accounts/1" +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** Register in `DaemonRunner` next to `VendorCliWatcher`. +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Publish the host's accounts over the local socket (#1806)` + +### Task 7: Launches resolve an account or are refused + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs` (`RuntimeStartContext`: append `ResolvedAccount? Account = null`), `src/Capacitor.Cli.Daemon/Services/IHostedAgentLauncher.cs` (`LauncherContext`: append `ResolvedAccount? Account = null`), `src/Capacitor.Cli.Daemon/Services/PtyHostedAgentRuntimeFactory.cs` (copy `ctx.Account` into the launcher context), `src/Capacitor.Cli.Daemon/Services/AgentInstance.cs` (or wherever `AgentInstance` is declared: `ResolvedAccount? Account`), `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs` (`HandleLaunchAgentCore`: resolve before the worktree is created; refuse with `LaunchFailed` carrying the refusal text when `Refused`), `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs` (Spawn: read the frame's account id and resolve the same way) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorAccountTests.cs` (model on `AgentOrchestratorVendorTests` fixtures) + +**Interfaces:** +- `AgentOrchestrator.ResolveAccount(string vendorToken, string? accountId)`: `AccountResolution.Resolve(_config.Accounts?.TryLoad(), vendor, accountId, _config.DefaultDirectories[vendor])`; for a vendor outside Claude and Codex returns `Account = null` and no refusal. Resume launches (`resume is not null`) use `ResolveForResume` from the ledger's fields instead (Task 11). +- Refusal: the same failure path as `CodexHooksNotInstalledException` today (LaunchFailed, no worktree left behind), with `Reason = outcome.Refusal`. + +- [ ] **Step 1: Write the failing tests** + +```csharp +Null_account_id_launches_under_the_daemon_default_directory() // launcher context Account.Directory == DefaultDirectories[Claude] +Registered_account_id_launches_under_its_directory_with_its_label() +Unknown_account_id_is_refused_with_account_unavailable_and_spawns_nothing() +Account_id_of_another_vendor_is_refused() +Registered_account_whose_directory_is_gone_is_refused() +Spawn_frame_account_id_resolves_like_a_server_launch() +Malformed_registry_refuses_an_explicit_id_and_defaults_a_null_one() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Resolve or refuse the account before a launch starts (#1806)` + +### Task 8: Claude launcher takes its layout per launch + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeLauncher.cs` (replace the `_paths` field with `ClaudePaths PathsFor(ResolvedAccount? account)` = `account is null ? harnesses.Of().Paths : AccountLayouts.Claude(home, account.Directory)`; every site at lines 68, 74, 80, 94, 274 uses it; the teardown at 274 takes the agent's `Account`), and whichever `UserHome` the launcher can reach (take it from `HarnessRegistry` or `DaemonConfig`, whichever already carries it) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeLauncherAccountTests.cs` + +- [ ] **Step 1: Write the failing tests** (two Claude directories under `TempDir`, `TestHarnesses.Under(Home)` pointing at one) + +```csharp +Prepare_writes_trust_and_mcp_config_under_the_account_directory() // .claude.json and the project dir under the other directory, nothing under the default +Prepare_with_no_account_uses_the_environment_layout() +Teardown_removes_the_project_symlink_from_the_account_directory() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Give the Claude launcher its layout per launch (#1806)` + +### Task 9: Codex launcher and app-server runtime take their layout per launch + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Harness/Codex/CodexLauncher.cs` (`CodexPaths PathsFor(ResolvedAccount? account)`; sites at 128, 139, 455; keep a `Paths` property for the environment layout), `src/Capacitor.Cli.Daemon/Harness/Codex/CodexHostedAgentRuntimeFactory.cs` (the `Prepare` call and the hooks validation after spawn use the launch's layout), `src/Capacitor.Cli.Daemon/Harness/Codex/CodexAppServerHostedAgentRuntime.cs` (hooks/list validation path if it reads the layout) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherAccountTests.cs`, extend `CodexHostedAgentRuntimeFactoryTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +Prepare_checks_hooks_in_the_account_directory_not_the_default() // hooks only in the account dir -> no CodexHooksNotInstalledException +Prepare_without_account_or_repository_hooks_in_the_account_directory_throws() +TrustWorktree_writes_the_account_config_toml() +Model_migrations_are_read_from_the_account_config_toml() +AppServer_factory_prepares_with_the_launch_account() // factory test: Prepare called with ctx.Account propagated +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Give the Codex launcher and app-server its layout per launch (#1806)` + +### Task 10: The child environment removes or sets the vendor home + +**Files:** +- Create: `src/Capacitor.Cli.Core/Accounts/VendorHomeVariable.cs` +- Modify: `src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs` (`BuildChildEnv(Dictionary? extraEnv, IReadOnlyCollection? removeEnv, ushort cols, ushort rows)` and the ctor/options that reach it), `src/Capacitor.Cli.Daemon/Pty/Windows/ConPtyProcess.cs` (`BuildEnvironmentBlock(Dictionary? extraEnv, IReadOnlyCollection? removeEnv)`), the PTY options record both read from (add `IReadOnlyCollection? RemoveEnv`), `src/Capacitor.Cli.Daemon/Services/PtyHostedAgentRuntimeFactory.cs` (after building `env`: apply `VendorHomeVariable.Plan`), `src/Capacitor.Cli.Daemon/Harness/Codex/CodexHostedAgentRuntimeFactory.cs` (`ApplyChildEnv(childEnv, overlay, IReadOnlyCollection remove)` and the spawn env for app-server) +- Test: `test/Capacitor.Cli.Core.Tests.Unit/Accounts/VendorHomeVariableTests.cs`, `test/Capacitor.Cli.Daemon.Tests.Unit/Pty/UnixPtyProcessEnvTests.cs` (make `BuildChildEnv` internal), `test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexHostedAgentRuntimeFactoryTests.cs`, `test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryEnvTests.cs` + +**Interfaces:** +- `public static class VendorHomeVariable { public static string? NameOf(HarnessId vendor); /* CLAUDE_CONFIG_DIR, CODEX_HOME, else null */ public static (string Variable, string? Value) Plan(HarnessId vendor, string directory, UserHome home); }` — `Value` null when `AccountDirectory.Same(directory, AccountLayouts.DefaultDirectory(vendor, home))`, else `directory`. Callers put a null-valued plan into the removal set and a valued one into the overlay. + +- [ ] **Step 1: Write the failing tests** + +```csharp +// VendorHomeVariableTests +Built_in_directory_plans_a_removal() +Custom_directory_plans_a_set() +Vendor_without_a_home_variable_plans_nothing() +// UnixPtyProcessEnvTests (run under EnvScope.Exclusive for CLAUDE_CONFIG_DIR, bare [NotInParallel]) +Inherited_variable_is_removed_when_named_in_the_removal_set() +Overlay_sets_the_variable_after_removal() +// PtyHostedAgentRuntimeFactoryEnvTests: with CLAUDE_CONFIG_DIR inherited as ~/.claude-work +Launch_resolved_to_the_built_in_directory_removes_the_inherited_variable() +Launch_resolved_to_the_daemons_own_custom_default_sets_it_explicitly() +Launch_resolved_to_another_custom_account_sets_that_directory() +// CodexHostedAgentRuntimeFactoryTests: the same three through the spawn-factory seam's env +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** The daemon process environment is never mutated; assert that in the factory tests by reading `Environment.GetEnvironmentVariable` after the launch. +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Remove or set the vendor home in every spawned agent's environment (#1806)` + +### Task 11: Resume keeps the account + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.Resume.cs` (`BuildLedgerEntry` and `BuildPreStartLedgerEntry` write `AccountDirectory`, `AccountId`, `AccountLabel` from the resolved account; `BuildResumeCommand` passes `AccountId: launch.AccountId`; the launch path for a resume calls `AccountResolution.ResolveForResume(registry, vendor, launch.AccountId, launch.AccountDirectory, default)`; `ResumeEntryAsync`: when the native failure reason starts with `account_unavailable`, skip `ContinueEntry` and interrupt with that reason; `NativeResumeAsync` returns the refusal text, not `launch_rejected`, when the rejection is an account refusal) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorResumeAccountTests.cs` (model on `AgentOrchestratorResumeOnBootTests`) + +- [ ] **Step 1: Write the failing tests** + +```csharp +Ledger_entry_records_the_resolved_account() +Resume_relaunches_under_the_ledger_directory_after_the_default_changed() // daemon default now another dir; launcher context Account.Directory == ledger directory +Resume_of_a_removed_registered_account_fails_with_account_unavailable_and_skips_continue() // entry Interrupted, LastError starts with account_unavailable, continue launcher never called +Resume_of_an_unregistered_default_uses_its_stored_directory() +Resume_of_an_unregistered_default_whose_directory_is_gone_fails_named() +Legacy_entry_without_account_fields_resumes_under_the_current_default() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Resume an agent under the account it was launched with (#1806)` + +### Task 12: Status and registration carry the account + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs` (the `new AgentStatusDto(` site: the three fields from `agent.Account`), `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs` (`AgentRegistered` construction and `LiveAgentInfo` construction: the same three) +- Test: extend `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorAccountTests.cs`; a registration-payload test beside the existing `AgentRegistered` tests + +- [ ] **Step 1: Write the failing tests** + +```csharp +Status_dto_names_the_resolved_account_directory_id_and_label() +Registration_and_live_agent_report_carry_the_resolved_account() +Unregistered_default_reports_the_directory_and_a_null_id() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Report each agent's account in status and registration (#1806)` + +### Task 13: Codex model catalog per account + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs` (add `Task?> ProbeModelsAsync(string? configDirectory, CancellationToken ct)` defaulting to the existing probe, and `IReadOnlyList CatalogFingerprintPathsFor(string? configDirectory)` defaulting to `CatalogFingerprintPaths`), `src/Capacitor.Cli.Daemon/Harness/Codex/CodexHostedAgentRuntimeFactory.cs` (probe with `CODEX_HOME` set per directory through the spawn factory's env, using a per-directory probe dir `CodexModelCatalogProbe.DirectoryFor(stateDir, DirectoryKey.Of(dir))`; fingerprint paths from `PathsFor(dir)`), `src/Capacitor.Cli.Daemon/Services/VendorModelCatalogs.cs` (`ProbeByAccountAsync(factories, IReadOnlyList<(string Vendor, string AccountId, string Directory)>, ct)` and `EqualByAccount`), `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs` (`RefreshAdvertisedCapabilities`: build `_config.VendorModelsByAccount` from the current snapshot's Codex accounts plus the unregistered default; `catalogChanged` compares both maps; a probe result for an id no longer in the snapshot at completion is dropped), `src/Capacitor.Cli.Daemon/DaemonConfig.cs` (`VendorModelsByAccount`), `src/Capacitor.Cli.Daemon/Services/VendorCliWatcher.cs` (`WatchSet` includes each Codex account's fingerprint paths; `AccountsPublisher.Changed` triggers a `WatchSet` rebuild and `RefreshAdvertisedCapabilities("accounts changed")`), `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs` (`DaemonInfoDto.VendorModelsByAccount`), `src/Capacitor.Cli.Daemon/Services/ServerConnection.cs` (`BuildDaemonConnect`: `VendorModelsByAccount`) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorModelCatalogsAccountTests.cs`, extend `VendorCliWatcherTests.cs`, `CodexHostedAgentRuntimeFactoryTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +Probe_runs_once_per_codex_account_with_its_home_in_the_environment() +Default_catalog_stays_in_vendor_models_and_accounts_land_in_the_map() +Adding_an_account_probes_it_and_extends_the_watch_set() +Removing_an_account_drops_its_catalog_and_a_late_probe_result_is_discarded() +Map_only_change_republishes_locally_and_to_the_server() // VendorModels equal, map differs -> pulse + re-register +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Probe and advertise the Codex catalog per account (#1806)` + +### Task 14: The daemon publishes accounts to the server + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Services/ServerConnection.cs` (`BuildDaemonConnect`: `SupportsAccountLaunches: true`, `Accounts: GetAccountsSnapshot?.Invoke()`; `public virtual Task AccountsChangedAsync(AccountsSnapshotDto snapshot)` as a one-way `SendAsync("AccountsChanged", snapshot)` with the `DaemonStatusReportAsync` catch), `src/Capacitor.Cli.Daemon/Services/AccountsPublisher.cs` (on `Changed`: a 30-second trailing throttle that sends the latest snapshot through the connection, immediately when nothing was sent in the last 30 s), `src/Capacitor.Cli.Daemon/DaemonRunner.cs` (wire `GetAccountsSnapshot`) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherServerTests.cs` (fake `ServerConnection` recording sends, `FakeTimeProvider`) + +- [ ] **Step 1: Write the failing tests** + +```csharp +Connect_payload_advertises_account_launches_and_carries_the_snapshot() +First_change_sends_at_once_and_a_burst_sends_the_last_change_after_thirty_seconds() +A_server_without_the_handler_logs_at_debug_and_keeps_the_connection() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Publish the accounts snapshot to the server behind its capability (#1806)` + +### Task 15: `kcap agent start --account` + +**Files:** +- Modify: `src/Capacitor.Cli.Core/AgentStartArgs.cs` (`public string? Account { get; }`; parse `--account ` and `--account=`; an empty value is an `Error`), `src/Capacitor.Cli/Commands/AgentCommand.cs` (`RunAsync`: when `Account` is set, load the registry through `AccountStore.Beside(store)`, resolve with `AccountResolution.ResolveArgument` (expand `~` as `AccountsCommand.TryNormalize` does; move that expansion into Core if it is private there), refuse on `Refusal` with exit 1; send `Hello` and refuse with "this daemon does not support choosing an account; update kcap" when `Capabilities` lacks `accounts/1`; pass the id to `FrameCodec.Spawn`), `src/Capacitor.Cli.Core/Resources/help-agent.txt` (`--account ` under "Options for start"), `README.md` (`### Local agents (kcap agent)` example line and `### Accounts`) +- Test: `test/Capacitor.Cli.Core.Tests.Unit/AgentStartArgsTests.cs` (or the existing args test file), `test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandAccountTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +// AgentStartArgsTests +Account_flag_with_a_value_parses() // both spellings +Account_flag_without_a_value_is_an_error() +// AgentCommandAccountTests (fake socket as AgentCommandRoutingTests does) +Account_prefix_resolves_and_the_spawn_frame_carries_the_id() +Ambiguous_prefix_is_refused_listing_both_candidates_and_nothing_is_sent() +Unregistered_directory_is_refused_with_the_add_remedy() +Daemon_without_accounts_capability_is_refused_before_spawn() +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** README and help text in the same step. +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Choose the account on kcap agent start (#1806)` + +### Task 16: Whole-PR verification + +- [ ] **Step 1:** `dotnet build src/Capacitor.Cli/Capacitor.Cli.csproj` — 0 warnings. +- [ ] **Step 2:** `dotnet test --solution Capacitor.slnx` — green (daemon timing flakes rerun alone per the project memory). +- [ ] **Step 3:** `dotnet publish src/Capacitor.Cli/Capacitor.Cli.csproj -c Release 2>&1 | grep -E 'IL[23][01][0-9]{2}'` — no output. +- [ ] **Step 4:** Live check on this Mac: `kcap accounts --json` shows the revision; `kcap agent start claude --account -d` spawns, and the agent's row in the daemon's status snapshot (the `StatusSubscribe` frame the app reads; `kcap daemon status --json` reports the service, not agents) carries the account's `account_directory`, `account_id` and `account_label`, with its `transcript_path` under that directory; the daemon log shows the agent's spawn and session link and no `account_unavailable` refusal (it names no account, so the account is read from the snapshot); the local snapshot frame lists both Claude directories with the daemon's default keyed to one of them. + +### Task 17: Session discovery and the model report read the launch's layout + +Runs between Task 9 and Task 10. The orchestrator still reads the environment layout in three +places an account launch must not use: the resolved-model report for Codex reads the environment +`config.toml`, and both transcript locators search the environment `projects/` (Claude) and +`sessions/` (Codex). A session started under a custom directory writes its transcript there, so +detection would never find it and the agent would have no session id and no transcript path. + +**Files:** +- Modify: `src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs` (`CodexResolvedModel` about line 3395 takes the launch's `ResolvedAccount?`; the `"claude"` / `"codex"` locator lambdas about lines 5724–5727 use the agent's account layout) +- Test: `test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorAccountTests.cs` (extend) + +**Interfaces:** +- Consumes: `AgentInstance.Account`, `AccountLayouts.Claude(home, dir)` / `AccountLayouts.Codex(home, dir)`, `DaemonConfig.Home`. +- Produces: `ClaudePaths ClaudePathsFor(ResolvedAccount? account)` and `CodexPaths CodexPathsFor(ResolvedAccount? account)` on the orchestrator, the environment layout for a null account (never touching `Home`), else the account's layout; every later orchestrator read of a vendor layout goes through them. + +- [ ] **Step 1: Write the failing tests** + +```csharp +Codex_session_detection_searches_the_account_sessions_directory() // a rollout for the agent's worktree under the account's sessions dir is found; the same file under the environment dir is not +Claude_session_detection_searches_the_account_projects_directory() // same for a Claude transcript under the account's projects dir +Codex_resolved_model_report_reads_the_account_config_toml() // the account's config.toml names a model; the report carries it, not the environment's +``` + +- [ ] **Step 2: Run to verify they fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run to verify they pass.** +- [ ] **Step 5: Commit** — `Find an account launch's transcript under its own directory (#1806)` diff --git a/docs/superpowers/specs/2026-10-21-vendor-accounts-launch-choice-design.md b/docs/superpowers/specs/2026-10-21-vendor-accounts-launch-choice-design.md new file mode 100644 index 0000000000..f17a1665f1 --- /dev/null +++ b/docs/superpowers/specs/2026-10-21-vendor-accounts-launch-choice-design.md @@ -0,0 +1,480 @@ +# Vendor accounts: choosing one when a session starts + +The second spec on vendor accounts. The first, `2026-10-07-vendor-accounts-and-limits-design.md`, +defined an account as one vendor plus one config directory on one host, put the registry beside the +daemons directory, and wired every registered account so its sessions record (Part A, shipped). Its +Part B, the usage readings and the screen that shows them, is designed there and not repeated here. +This spec adds what the first one left out: picking an account when a hosted session starts, a +per-agent config home in the daemon, remembering the pick per repository, managing accounts from the +desktop app's Settings window, and where the readings show once Part B exists. + +Mockups: https://kurrent.kcap.ai/pages/87dd7f8b111e40ec9df4ce7f78980acb + +## 1. Problem + +A person with two Claude or Codex subscriptions on one machine can record sessions from both, but +every hosted session the daemon starts runs under the one directory the daemon inherited from its +environment. There is no way to say "this repository runs on the work account", and no way to see, +before starting, which account has room. + +## 2. Decisions + +| Decision | Choice | +|---|---| +| When | An account is chosen only when a session starts. Switching a running session is a follow-up. | +| Where in the launcher | Inside the Agent chip's panel, above the model rows, shown only when the vendor has two or more rows on the target machine. No new chip. | +| Default | Per vendor and per daemon: the directory the daemon's own environment points at. Derived, never written to the registry; published by each daemon as its own fact, apart from the host's registry. | +| Resolved account | The daemon resolves every launch to a directory before the process starts and keeps that, not the request, on the agent. | +| Contract | One optional account id appended to the launch request on each hop, and to the local Spawn frame for `kcap agent start --account`. Null means the default. An explicit id is honoured or refused at every hop; no hop and no screen substitutes the default for it. | +| Support gate | A daemon advertises account launches; the server reports that per listed daemon and refuses an explicit id for a daemon without it; the app offers, restores and sends an explicit selection only for a target the server reports as supporting them, and holds Start while that support or the target's snapshot is unknown. | +| Memory | Per repository, vendor and host, read only from a complete snapshot. No memory means the default, never what another repository used. | +| Managing accounts | A tab in the Settings window, this machine only. Remote machines are read-only in the launcher. | +| Account actions | The app runs `kcap accounts` through its mutation lane; the daemon only watches and publishes. Every verb commits a registry revision after its wiring attempt. | +| Readings in a session | A strip under the chat composer, always shown for a Claude or Codex session whose account is known, read-only. | +| Reviewers | Review flows and PR reviews keep the vendor's default account. | +| Vocabulary | "Account" everywhere; "profile" stays a kcap server sign-in. | + +## 3. The default account + +The daemon resolves its vendor layouts once from its environment (`HarnessRegistry.FromEnvironment` +in `DaemonRunner`). That directory is the vendor's **default account** for this daemon: what a launch +with no account id resolves to. It is derived, never written: the daemon registers nothing, because +a registered directory gets wired at the next refresh (`plugin --if-installed` wires every +registered account), and the first spec records no directory nobody listed. Two daemons on one host +with different environments share the registry and each advertises its own default; the launcher +targets a daemon, so "default" always means the targeted daemon's, and the default travels as the +daemon's own fact, never inside the host's registry snapshot (Section 5). + +When the default directory is registered, that account carries the Default badge. When it is not, +which happens when the daemon's environment differs from the shell `kcap setup` ran in, both the +launcher panel and the Settings tab show it as a row of its own, labelled by directory name, with +the Default badge, the recording state **Not wired**, and Add as its one action. What a launch +under it does follows from the wiring that exists, not from registry membership: + +- Codex: both transports keep their preflights. The launcher's `Prepare` refuses to start when + neither the account directory nor the repository carries the kcap hooks, with the existing error, + whose remedy names adding the account, and the app-server runtime additionally validates the + hooks it lists after spawning. With repository hooks both transports start and record: the PTY + session through the hooks, the app-server session through the runtime's envelope transcript once + it has passed those checks. +- Claude: the launch starts; the session records only if the repository wires the kcap hooks. + +Adding the row registers and wires the directory, and the row becomes an ordinary account. +Removing a registered default turns it back into this row. The registry is otherwise as Part A left +it. Labels default to the directory name; Part B's identity probe replaces them with the email. + +## 4. Launching under an account + +### 4.1 The request and its gates + +`account_id` is appended as an optional field, null meaning the default, on each hop: + +- App: `LaunchRequest` and `LaunchAgentRequestV2Payload` in `Services/ILaunchClient.cs`, and + `LaunchDraft`, so the id is captured with the rest of the draft before the upload starts. +- Server: `RemoteLaunchRequest` and `DaemonLaunchCommand`, passed through untouched. +- Daemon: `LaunchAgentCommand` in `Capacitor.Cli.Core/Models.cs`, appended last under the same + positional wire-compatibility rule as the fields before it. + +An older hop drops the field silently: an old server never forwards it, and an old daemon ignores a +trailing field. Both would turn an explicit choice into a default launch, so support is gated at +every hop rather than inferred from merge order: + +- The daemon advertises `accounts/1` on `DaemonConnect`, beside its other advertised capabilities, + and on the local socket's capability list. +- The server keeps it on the listed daemon (`DaemonInfo`), and its launch capability policy, the one + that already refuses a Codex posture a daemon did not advertise, refuses an explicit `account_id` + for a daemon without `accounts/1`, with a named reason. It never forwards the id for the daemon to + ignore. +- The app offers account rows for a machine, local or remote, only when the listed daemon the + server reports carries `accounts/1`. An old server reports no such flag, so the rows stay absent + and every launch goes to the default by the person's own, visible, choice. The local socket's flag + is not enough on its own: local launches also travel through the server. + +The server does not validate the id beyond that gate. The daemon does: it reads the registry under +no lock (`accounts.json` is replaced atomically) and refuses the launch with a named reason, +`account_unavailable`, when the id is not registered, is registered for another vendor, or names a +directory that no longer exists. A refusal is reported like any other launch failure and is never +collapsed into a generic rejection. The daemon never substitutes the default for an id it cannot +honour: the person chose the account to spend, and a silent swap spends the wrong one. + +A launch with no id, including every review-flow and PR-review launch, resolves to the default. + +`kcap agent start` takes `--account `, resolved the way `kcap accounts` +resolves its arguments: a unique id prefix or a registered directory. A directory that is not +registered is refused by the CLI with the `kcap accounts add` remedy; nothing registers it. The +resolved id is appended to the local Spawn frame's payload after the title, under that frame's +trailing-bytes rule, and the daemon applies the same resolution and refusal as for a server launch. +An older daemon would ignore the trailing bytes and spend the default, so the CLI sends the flag +only to a daemon whose local capability list carries `accounts/1` and refuses otherwise. The flag +combines with `--private`; the resolved account is kept on the agent as in 4.2 either way. + +### 4.2 Per-launch layouts in the daemon + +Before the process starts, the daemon resolves the launch to a **resolved account**: the directory, +the registry id when the directory is registered, and the label (the registered label, else the +directory name). Everything below keys on that, never on the request's id. + +`ClaudeLauncher` and `CodexLauncher` each hold one `ClaudePaths` / `CodexPaths` taken from the +harness registry, and every write and read they make is keyed on it: workspace trust in `.claude.json`, +the MCP config, the project symlink, tool permissions, the Codex MCP inventory and `config.toml` +reads. Both launchers take the layout per launch instead, built with `AccountLayouts.Claude` / +`AccountLayouts.Codex` from the resolved directory. The same applies to the Codex app-server runtime +and to anything else that starts a vendor process for an agent. + +The child environment is an operation on the inherited one, not an overlay: the PTY and app-server +environment builders (`UnixPtyProcess.BuildChildEnv`, `ConPtyProcess.BuildEnvironmentBlock`, the +Codex factory's child-environment step) inherit the daemon's variables and then **remove** +`CLAUDE_CONFIG_DIR` / `CODEX_HOME` when the resolved directory is the vendor's built-in location +(`~/.claude`, `~/.codex`), and **set** it to the resolved directory in every other case, the daemon's +own custom default included. The daemon's process environment is never changed. Removing, rather than +leaving unset, is what keeps a daemon started with `CLAUDE_CONFIG_DIR=~/.claude-work` from handing +that directory to a launch resolved to `~/.claude`; the built-in location stays unset because naming +it as the override moves `.claude.json` away from where Claude reads it, the rule `AccountLayouts` +already encodes as a null override. + +Hooks and the transcript watcher already attribute a session to its account by path (Part A), and +`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` may strip the variable from hook environments; nothing here relies +on the variable reaching a hook. + +### 4.3 The agent keeps its resolved account + +`ResumeLaunchDescriptor` in the resume ledger gains the resolved account: directory, registry id or +null, label. A resume after a daemon restart relaunches under that directory, not under whatever the +restarted daemon's default is now: + +- A registered id that is no longer registered, or whose directory is gone, fails the resume with + `account_unavailable`; the entry is marked interrupted. The reason survives the native-resume + path: it is not collapsed into `launch_rejected`, and the continuation fallback that follows a + failed native resume is not attempted for it, since a continuation would spend another account. +- An unregistered default (null id) resumes under its stored directory while that directory exists, + and fails the same way when it does not. It is never registered by the resume. +- A ledger entry written before this field exists resumes under the current default, as today. + +`AgentStatusDto` in `LocalIpc/StatusIpc.cs` gains the resolved account's directory, id and label, +appended. The daemon's agent registration and status report to the server carry the same three +fields, so the server's agent row shows the account for a remote session and the composer strip can +name a directory even for an unregistered default. All optional; an older daemon reports none and +the app shows nothing for that session. + +### 4.4 The Codex model catalog is per account + +`CodexHostedAgentRuntimeFactory.ProbeModelsAsync` probes once per daemon with an empty environment +and fingerprints the default home's `models_cache` and `auth.json`. With accounts, it runs once per +registered Codex account, and once for an unregistered default, with `CODEX_HOME` set to that +directory and that account's files in the fingerprint set. Per-machine catalogs already travel on +`DaemonConnect.VendorModels` to the server's listed daemon and on `DaemonStatusDto.VendorModels` to +the local app, and `HomeViewModel.ModelChoicesFor` prefers the machine's list; a per-account map +keyed by registry id is appended beside `VendorModels` on both wires, with the default's catalog +staying in `VendorModels` for older readers. The launcher reads the selected account's catalog, +falling back to the machine's vendor catalog, then to the curated list, so a launch is never blocked +on this. + +The catalog follows the registry while the daemon runs. The registry watcher of Section 5.1 drives +it: an added account is probed and its files join the watched set; a removed account's catalog is +dropped and a probe still in flight for it is discarded on completion, matched by id; an +unregistered default that becomes registered moves from the default catalog to a map entry while +`VendorModels` stays as it was. A change to the account map alone, with `VendorModels` unchanged, is +published to the server and to the local app; the re-advertisement check compares both. + +## 5. Listing a machine's accounts + +### 5.1 This machine + +Two of the local-socket frames of the first spec, Section 6.3, are built: `AccountsSubscribe` and +`AccountsSnapshot`, appended to `FrameType` and advertised in `LocalControlCapabilities.Current` beside +their handlers. The daemon watches `accounts.json` as that spec's Section 6.1 describes and pushes a +snapshot on change. The snapshot has two parts: + +- The **host registry**: host id (from `host.json`), registry revision, and the public account view + per account: id, vendor, directory, label, recording state. Part B appends identity and the + reading. +- The **daemon's defaults**: the default directory per vendor, with its **directory key**, and + nothing else. This is the daemon's own fact, carries no revision, and changes only when the daemon + starts with another environment. Whether that directory is registered, and under which id, is + derived by every reader from the accepted host registry, never carried in this part: an id here + would outlive a removal or re-add that the registry part records under a revision the reader may + not have accepted yet. + +A **directory key** is computed only on the owning host, by the publishing daemon, for every +registry entry and every default: the directory as `AccountDirectory.Normalize` resolves it there +(symlinks followed, the host's physical path), case-folded when that host compares paths +case-insensitively and left as is on Linux. Readers, the local app included, match a default to an +account by ordinal equality of keys and never resolve or case-fold a remote path themselves: +`AccountDirectory.Same` resolves against the reader's own filesystem with the reader's own case +rule, so a macOS app would merge two distinct Linux directories differing only in case, and no +reader can follow a symlink on another host. Two daemons on one host compute equal keys for one +directory because they share its filesystem. + +A snapshot is **complete** when it carries both parts; the app acts on nothing less. +`AccountAction` and `AccountActionResult` are not built; Section 7 says why. + +### 5.2 A remote machine + +The daemon publishes the same two parts, without readings, to a server that advertises the +`accounts` capability: `DaemonConnect.Accounts`, `AccountsChanged` with the 30-second trailing +throttle, and the revision gate of the first spec's Sections 6.1 and 6.2 for the host registry. The +server stores the registry per host behind that gate, and the defaults per daemon with no gate: the +daemon's latest publication wins, and every connect republishes them, so a daemon restarted with +another environment updates its defaults without touching the host's revision. Two daemons on one +host publishing equal revisions in either order leave one registry and two sets of defaults. The +server serves both to the host's owner only, and a reader matches the targeted daemon's default +key against its host's accepted registry to find the default's row and its catalog; a rejected +older registry part leaves that daemon's defaults accepted, which is safe because they name only +directories. Part B adds readings to a snapshot that is already flowing. Until the +server part lands, the panel shows no account rows for a remote machine and the launch goes to its +default. + +## 6. The launcher + +### 6.1 Account rows in the Agent panel + +`LauncherPaneView.OnAgentChipClick` builds the panel: a vendor rail and searchable model rows. When +the selected vendor has two or more rows on the selected machine, an **Account** section goes above +the model rows, under the search field, with one row per registered account of that vendor and, when +the targeted daemon's default is unregistered, the row of Section 3: label, directory in faint text, +a neutral Default badge on the targeted daemon's default, the recording state where it is not +Recording, and the selection check the model rows use. Once Part B exists each row also carries one +bar per window with its percentage and the next reset. Picking a row sets the account and rebuilds +the model rows for it. The search field does not match accounts. + +With one row the section is absent and the panel is unchanged, except while the selection is +invalid (6.3): then the section stays, showing the invalid choice marked as removed beside the rows +that exist, so the Default row is always reachable. The account Default row is distinct from the +model list's "Default — Claude chooses" row, which picks a model, not an account. For an +unsupported target (6.3) the section is absent; while the selection is pending it shows one line, +"Loading accounts…", in place of rows, with a Retry action once a fetch has failed. + +### 6.2 The chip + +The chip label stays "Claude · Opus 4.1" while the chosen account is the default and becomes +"Claude · Opus 4.1 · work" otherwise. The label is identity, so it takes no status colour. + +### 6.3 Selection, the draft and Start + +**Support knowledge** for a target is one of three values, held per target and per server +connection: *unknown* until the current server's daemon listing has answered for that daemon, +*unsupported* when an authoritative listing shows the daemon without `accounts/1`, and *supported* +when it shows the flag. Unknown is the state before the first listing, after a failed fetch, and +after any reconnect or profile switch until the listing answers again; it is never read as +unsupported. The listing service refreshes only on connection changes and daemon-change +notifications today, so a transient failure on a healthy connection would leave support unknown for +good: while support is unknown for the selected target, the app retries the listing fetch with a +bounded backoff, starting at a few seconds and capped at half a minute, and the pending line offers +**Retry**, which fetches at once. Retries and their results are scoped to the current server +connection and target; a result from a superseded connection is rejected. A failure never becomes +unsupported and never selects the default. The flag is read from the server's listing for local and remote targets alike: the +local daemon's readiness and its socket snapshot never stand in for it, since the launch travels +through the server, and the daemon list is fetched after the connection and apart from local +readiness. A target is **account-capable** when it is supported and a complete snapshot (5.1) for +its host is at hand. + +`HomeViewModel` keeps the selected account per vendor beside `SelectedModel`, in one of four +states: *default* (no explicit choice), *explicit, valid* (the chosen id is in the latest complete +snapshot and the target is supported), *explicit, invalid* (chosen, but absent from the snapshot or +the target is not currently supported), and *pending* (no choice made on this target and no restore +has resolved yet). It is set from two sources only: a row the person picks, and the restore of +Section 6.4. + +**Entering a target**, when the repository, vendor or machine changes: unknown support, or supported +without a complete snapshot, gives pending; supported with a complete snapshot runs the restore and +gives default or explicit; unsupported gives default, with memory entries kept but not restored. +Pending ends only when support and the snapshot resolve and the restore has run. There is no +timeout that falls through to the default, because that would be the substitution this spec +forbids, and a daemon that advertises `accounts/1` publishes its snapshot on every connect. + +**While on a target**, a choice the person made stays selected through everything: support +withdrawn, support unknown again after a reconnect, the snapshot lost or the account gone all turn +it explicit-invalid, never default, and it returns to valid when the evidence returns and lists the +account. The chip shows the invalid choice marked as removed or unavailable. Only the person's own +pick of the Default row, or the Use default action below, makes it default. A default selection +stays default through the same events. + +**Start** is enabled only for a resolved selection: explicit-valid, or default established by a +resolved state (a restore that found no entry, an authoritative unsupported listing, or the +person's own pick). Pending disables Start with its reason, and no draft is captured. An +explicit-invalid selection refuses with a named message in the launcher and sends nothing; the +message offers **Use default**, which sets the selection to the default without launching, and the +person presses Start again. Start captures the resolved selection into the draft, null for the +default; a snapshot or listing that arrives while that launch is in flight changes nothing about +it. A captured explicit draft is refused the same way, the draft kept and never cleared to null, +when the attachment upload completes or a reconnect precedes the send and, at that moment, the +account is absent from the latest complete snapshot or the target is no longer supported. A +restore from memory may fall back to the default silently (6.4); an account the person chose may +not. + +### 6.4 Memory per repository + +`AppState` gains `AccountByRepo`, a map keyed by repository path, vendor and **host id** to a +registry id, beside `HarnessByRepo` and with its reserved scratch key. The host id comes from the +complete snapshot of the targeted machine, so two daemons on one host share one memory, a daemon +rename changes nothing, and two servers listing daemons of the same name never collide, since +`AppState` is shared across server profiles. + +- Picking a row writes the entry; picking the Default row removes it, so a later change of the + daemon's default is followed. +- The restore runs only for an account-capable target (6.3): it looks up the entry for the + repository, vendor and the snapshot's host id, and selects that account when the snapshot lists + it. +- An entry whose id is absent from a complete snapshot of its own host is dropped and the default + is restored. An entry is never dropped because the snapshot is missing, stale, from another host, + or because the machine is disconnected: unknown is not removed. +- No entry means the default, never another repository's account. Switching the vendor in the + panel restores that vendor's own entry for the repository. + +## 7. Settings: the Accounts tab + +A new tab after Profiles in `SettingsWindow.axaml`, named **Accounts**, with one line under the +title saying what it is: Claude and Codex config directories on this machine, each recording its +sessions and pickable when a session starts. The line exists because the Profiles tab beside it lists +kcap sign-ins. + +- Rows grouped by vendor: label, directory, Default badge, recording state as Part A reports it + (Recording, Needs trust with its hint, Broken, Not wired), the unregistered-default row of + Section 3 where it applies, and a menu with Rename, Re-wire and Remove. Part B adds email, plan, + the bars with resets, the reading age and Refresh. +- **Add account** offers the candidates `AccountDiscovery.Find` returns, then a directory picker. + Adding wires the account. Signing in is the person's job in the vendor CLI, as the first spec + states. +- Actions run the CLI: `kcap accounts add`, `remove`, `rename` and `rewire` through the app-lifetime + mutation lane's existing executor (`DaemonMutationLane` over `IKcapCli`), one at a time, as daemon + mutations do. The daemon does no wiring: the CLI already owns `AccountWiring` and its + `WiringOptions`, and a second wiring path in the daemon would need the plugin and MCP paths moved + out of the CLI assembly. +- **Every verb commits a revision after its wiring attempt**, under the registry lock, recording the + wiring outcome on the entry: an add that wired, an add whose wiring failed (still registered, as + today, and reported as a failure), a rewire that changed nothing, a rename, a removal. The + revision in the result is this post-wiring one, so a snapshot cached before the wiring cannot + satisfy it, and a rewire produces a snapshot the watcher can see. +- The CLI gains a machine-readable result for the lane carrying each wiring step's outcome and that + revision. Success is exit 0 with every step succeeded and a snapshot at or past the revision; a + failed step is a failure even when the row already looks right. A missing or malformed result, a + timeout, or a snapshot subscription lost before the revision arrives is **unconfirmed**, never + success; the next complete snapshot shows the true state. +- Each action carries its own request id, so the lane never coalesces two actions, equal or not, + the first spec's rule. The lane's request factory admits the account verbs without a server + profile, since they touch nothing on the server. +- This machine only. A daemon that does not advertise the frames gets the tab in a read-only state + that says to update kcap on this machine, and the launcher shows no account rows for it. + +## 8. In the session: the strip under the composer + +Under `ComposerCard` in `ChatTabView.axaml`, inside the composer band, one line for every Claude or +Codex session whose resolved account is known: the vendor glyph and the account label, then one bar +per window with its percentage and the next reset. Before any reading it shows the directory and +"no reading yet". When the account is blocked, the exhausted window's bar is danger-coloured and a +danger pill reads "Limit hit" with the reset; the existing usage-limit notice in the composer is +unchanged. + +The strip is read-only. It takes the resolved account from `ChatSessionInfo`, filled from +`AgentStatusDto` on the local lane and from the server's agent row on the remote lane, and the +reading, when the account is registered, from the host's registry snapshot. A session from a daemon +that reports no account shows no strip, as does any vendor without accounts. + +## 9. Readings + +Part B of the first spec is the data source for every bar here and is built as written there, with +three changes: + +- The Accounts screen of its Section 6.4 is the Settings tab of Section 7; there is no standalone + page and no host switcher. +- Its Sections 5.3 and 5.4 attribute app-server readings and limit hits to "the daemon's default + account until spec 2". With Section 4.3 the agent carries its resolved account, and both attribute + to it when it is registered; an unregistered default has no reading to attribute to. +- The bars also appear in the launcher rows (6.1) and the composer strip (8). + +## 10. Delivery + +Five pull requests, each shipping on its own. The server carries this CLI as a submodule, so the +daemon's contract change lands before the server's, and the server's account-launch support is +enabled only by the submodule update that carries it. Nothing relies on merge order for +correctness: the gates of Section 4.1 hold for every mixed pairing of app, server and daemon. + +1. **kcap-cli, daemon and CLI.** Sections 3, 4 and 5: the account id on the launch command, the + resolved account, per-launch layouts and the child-environment rule, the resume ledger field + and resume policy, agent status fields, per-account Codex catalogs with their lifecycle, the + registry watcher, the local snapshot frames, the `accounts/1` advertisement, publishing to the + server behind its capability, the CLI's post-wiring revisions and machine-readable results, and + `kcap agent start --account` with its help text and README lines. +2. **kcap-server.** Section 4.1's pass-through and refusal, `accounts/1` on the listed daemon, the + agent row's account, the per-account catalog on the listed daemon, and the accounts store of + Section 5.2 with its split between host registry and daemon defaults. +3. **kcap-cli, app.** Sections 6, 7 and the strip of Section 8 without bars. +4. **kcap-cli and kcap-server.** Part B of the first spec: the status line wrapper, the Codex + producers, the readings store, publishing. +5. **kcap-cli, app.** The bars, in the three places. + +README and `help-*.txt` change with the PR that changes user-facing behaviour. + +## 11. Testing + +- **Daemon, launch:** an explicit id resolves to its directory; null resolves to the daemon's + default; an unknown, cross-vendor or missing-directory id is refused with `account_unavailable` + and nothing is spawned; the child environment, for a daemon started with a custom inherited home, + removes the variable for the built-in location, sets it for the daemon's own default and for + another custom account, on the PTY and app-server paths alike, with `.claude.json` landing where + Claude reads it in each case; trust, MCP config and project symlink land in the resolved + directory and not the default's; the daemon's process environment is unchanged after a launch. +- **Daemon, resume:** a resume relaunches under the ledger's directory after the daemon restarts + with a different environment; a removed registered account fails with `account_unavailable`, not + `launch_rejected`, and no continuation is attempted; an unregistered default resumes under its + directory and fails named when the directory is gone; a legacy entry resumes under the current + default. +- **Daemon, snapshot and catalog:** the snapshot carries the host id, the revision and the daemon's + defaults, and registers nothing; a second daemon with another environment publishes different + defaults under the same revision, in both orders, and a restart of one with a new default + republishes defaults without a revision change; a removed and re-added default, followed by the + other daemon's lower-revision publication, resolves to the accepted registry's row and catalog; + an unregistered Codex default with no hooks is refused on the PTY and app-server transports and + starts and records on both with repository hooks; directory keys are computed on the host, so two + Linux directories differing only in case stay distinct rows for a macOS reader with the Default + badge on the right one, and a default spelled through a symlink in the daemon's environment + matches its registered row, the request carrying null for it and the explicit id for the other; + a registry change reaches the local snapshot and + the server within the throttle; adding, removing and re-adding a Codex account without a restart + probes, drops and re-probes its catalog, a late probe for a removed account is discarded, and an + auth or cache change on a non-default account republishes the map alone. +- **CLI:** each verb commits a post-wiring revision, including an unchanged rewire and an add whose + wiring failed; the result names each step's outcome and that revision; a failed step reports + failure with exit 1 even when the registry already holds the requested state; `kcap agent start + --account` resolves an id prefix and a registered directory, refuses an unregistered directory and + a daemon without `accounts/1`, and the daemon spawns under the resolved directory. +- **Server:** old daemon with new server and the reverse; the id reaches a daemon with `accounts/1` + unchanged and is refused, named, for one without; the agent row carries the resolved account; the + per-account catalog rides beside the per-vendor one for an older app; registry snapshots with + equal revisions from two daemons keep both daemons' defaults. +- **App, headless view-model tests:** the section appears at two rows and not at one, counting an + unregistered default; it is absent without a complete snapshot and for a daemon the server lists + without `accounts/1`; the chip label with and without a non-default account; the request carries + null for the default; restore per repository, vendor and host id runs on a repository, vendor or + machine change once the snapshot is complete, and a late snapshot restores unless the person has + picked since; an entry absent from its host's complete snapshot is dropped, and one is kept + through a missing snapshot, a disconnect and a switch to another host; the Default pick removes + the entry; a vendor switch restores the other vendor's entry; a remembered local account with a + new daemon behind an old server restores nothing and sends null, but only once that server's + listing has answered; a ready local daemon with a delayed or failed first listing and a saved + non-default account captures no draft and sends nothing until the listing and the snapshot + resolve, and with the connection kept up and no daemon-change notification the retry, or Retry, + fetches again, after which the remembered account restores and Start is available without a + restart or a reconnect; a result from a superseded connection is ignored; a reconnect returns + support to unknown and Start waits; a choice made on a supported + target survives support withdrawal while idle as explicit-invalid, Start refuses named and sends + nothing; Start before the first complete snapshot of a supported target is disabled and captures + no draft, and a snapshot arriving during an in-flight upload changes nothing; an explicit + selection removed before Start, one + removed during an attachment upload, and one whose target lost `accounts/1` before sending, fail + named with no request sent and the draft kept; removing the only non-default account keeps the + section with the Default row reachable, and Use default in the error lets the next Start succeed + without changing repository, vendor or machine; the lane + admits an account verb with no server profile, refuses success on a failed step, treats a lost + result or subscription as unconfirmed, and never coalesces two queued actions; the tab's read-only + state on an old daemon; the strip's three states, its directory for an unregistered default, and + its absence for other vendors and for an older daemon. +- **App smoke:** the panel opens with the section and keyboard focus reaches the account rows. + +## 12. Not doing + +- Switching the account of a running session, or choosing one again when a limit hits. +- A reviewer account per vendor. +- Reading, copying or brokering any vendor credential, as before. +- Changing a remote machine's accounts from the app. +- A standalone Accounts page or a host switcher. diff --git a/src/Capacitor.Cli.Core/Accounts/AccountActionResultDto.cs b/src/Capacitor.Cli.Core/Accounts/AccountActionResultDto.cs new file mode 100644 index 0000000000..e536d7b674 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountActionResultDto.cs @@ -0,0 +1,7 @@ +namespace Capacitor.Cli.Core.Accounts; + +/// The one line kcap accounts add|remove|rename|rewire --json prints. +/// is the registry revision the verb committed after its wiring attempt (null when it committed nothing), +/// so a snapshot at or past it was built after that attempt. is null when the +/// verb acted on no single account. +public sealed record AccountActionResultDto(string Action, string? AccountId, bool Succeeded, WiringStepDto[] Steps, long? Revision, string? Error); diff --git a/src/Capacitor.Cli.Core/Accounts/AccountActionResultJsonContext.cs b/src/Capacitor.Cli.Core/Accounts/AccountActionResultJsonContext.cs new file mode 100644 index 0000000000..ec09bfd328 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountActionResultJsonContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace Capacitor.Cli.Core.Accounts; + +/// Every field is always emitted, so this context must never gain a DefaultIgnoreCondition. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(AccountActionResultDto))] +public partial class AccountActionResultJsonContext : JsonSerializerContext; diff --git a/src/Capacitor.Cli.Core/Accounts/AccountDirectory.cs b/src/Capacitor.Cli.Core/Accounts/AccountDirectory.cs index cd8d602db6..df7f46d603 100644 --- a/src/Capacitor.Cli.Core/Accounts/AccountDirectory.cs +++ b/src/Capacitor.Cli.Core/Accounts/AccountDirectory.cs @@ -3,6 +3,13 @@ namespace Capacitor.Cli.Core.Accounts; public static class AccountDirectory { public static string Normalize(string path) => Path.TrimEndingDirectorySeparator(PhysicalPath.Of(path)); + /// A leading ~ or ~/ names the user's home. The shell does not expand it inside quotes + /// or after =, so a command taking a directory expands it itself. + public static string ExpandHome(string path, UserHome home) => + path == "~" ? home.Path + : path.StartsWith("~/", StringComparison.Ordinal) ? Path.Combine(home.Path, path[2..]) + : path; + public static bool Same(string a, string b) => string.Equals(Normalize(a), Normalize(b), OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); diff --git a/src/Capacitor.Cli.Core/Accounts/AccountResolution.cs b/src/Capacitor.Cli.Core/Accounts/AccountResolution.cs new file mode 100644 index 0000000000..4718ac9ec8 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountResolution.cs @@ -0,0 +1,105 @@ +using Capacitor.Cli.Core.Harness; + +namespace Capacitor.Cli.Core.Accounts; + +/// Turns a requested account into the directory a launch runs under. An explicit id is +/// honoured or refused, never replaced by the default directory. +public static class AccountResolution { + public const string Unavailable = "account_unavailable"; + + const int MinIdPrefix = 4; + + /// A null registry (unreadable) resolves like an empty one. + public static AccountResolutionOutcome Resolve(AccountRegistry? registry, HarnessId vendor, string? accountId, string defaultDirectory) { + if (string.IsNullOrEmpty(accountId)) return Accepted(defaultDirectory, ForDirectory(registry, vendor, defaultDirectory)); + + if (ForId(registry, vendor, accountId) is not { } entry) return NoAccount(vendor, accountId); + + return Directory.Exists(entry.Directory) ? Accepted(entry.Directory, entry) : Missing(entry.Directory); + } + + // A resumed session lives where it started, so the stored directory wins over the entry's + // current one; the registry only says whether the account may still be used. + public static AccountResolutionOutcome ResolveForResume( + AccountRegistry? registry, HarnessId vendor, string? accountId, string? directory, string defaultDirectory + ) { + if (string.IsNullOrEmpty(directory)) return Resolve(registry, vendor, accountId, defaultDirectory); + + if (string.IsNullOrEmpty(accountId)) { + return Directory.Exists(directory) ? Accepted(directory, ForDirectory(registry, vendor, directory)) : Missing(directory); + } + + if (ForId(registry, vendor, accountId) is not { } entry) return NoAccount(vendor, accountId); + + return Directory.Exists(directory) ? Accepted(directory, entry) : Missing(directory); + } + + /// The CLI's --account value: an id prefix, else a directory. The caller expands ~. + public static AccountResolutionOutcome ResolveArgument(AccountRegistry registry, HarnessId vendor, string idPrefixOrDirectory) { + var ofVendor = registry.Accounts.Where(a => a.Vendor == vendor).ToList(); + + if (idPrefixOrDirectory.Length >= MinIdPrefix) { + var byId = ofVendor.Where(a => a.Id.StartsWith(idPrefixOrDirectory, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (byId.Count == 1) return Accepted(byId[0].Directory, byId[0]); + + if (byId.Count > 1) { + return Refused($"{Unavailable}: {idPrefixOrDirectory} matches more than one {vendor} account: {string.Join(", ", byId.Select(a => a.Id))}"); + } + } + + return ofVendor.FirstOrDefault(a => SameDirectory(a.Directory, idPrefixOrDirectory)) is { } byDirectory + ? Accepted(byDirectory.Directory, byDirectory) + : Refused($"{Unavailable}: {idPrefixOrDirectory} is not a registered {vendor} account; run kcap accounts add"); + } + + static VendorAccount? ForId(AccountRegistry? registry, HarnessId vendor, string accountId) => + registry?.Accounts.FirstOrDefault(a => a.Vendor == vendor && a.Id == accountId); + + static VendorAccount? ForDirectory(AccountRegistry? registry, HarnessId vendor, string directory) { + var key = DirectoryKey.Of(directory); + + return registry?.Accounts.FirstOrDefault(a => a.Vendor == vendor && KeyOrNull(a.Directory) == key); + } + + // A hand-edited registry entry may hold anything; it must not throw out of a launch's resolution. + static string? KeyOrNull(string directory) { + try { + return DirectoryKey.Of(directory); + } catch (Exception ex) when (IsNotAPath(ex)) { + return null; + } + } + + static bool SameDirectory(string registered, string argument) { + try { + return AccountDirectory.Same(registered, argument); + } catch (Exception ex) when (IsNotAPath(ex)) { + return false; + } + } + + static bool IsNotAPath(Exception ex) => ex is ArgumentException or NotSupportedException or IOException; + + // The physical directory: a default reached through a symlink is recorded as the directory behind it, + // so retargeting the link before a resume cannot move the continuation to another account. + static AccountResolutionOutcome Accepted(string directory, VendorAccount? entry) { + var physical = Physical(directory); + + return new(new ResolvedAccount(physical, entry?.Id, entry?.Label ?? Path.GetFileName(physical)), null); + } + + static string Physical(string directory) { + try { + return AccountDirectory.Normalize(directory); + } catch (Exception ex) when (IsNotAPath(ex)) { + return Path.TrimEndingDirectorySeparator(directory); + } + } + + static AccountResolutionOutcome NoAccount(HarnessId vendor, string accountId) => Refused($"{Unavailable}: no {vendor} account {accountId}"); + + static AccountResolutionOutcome Missing(string directory) => Refused($"{Unavailable}: {directory} is missing"); + + static AccountResolutionOutcome Refused(string refusal) => new(null, refusal); +} diff --git a/src/Capacitor.Cli.Core/Accounts/AccountResolutionOutcome.cs b/src/Capacitor.Cli.Core/Accounts/AccountResolutionOutcome.cs new file mode 100644 index 0000000000..4fc7b57c64 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountResolutionOutcome.cs @@ -0,0 +1,6 @@ +namespace Capacitor.Cli.Core.Accounts; + +/// Exactly one of and is set. +public sealed record AccountResolutionOutcome(ResolvedAccount? Account, string? Refusal) { + public bool Refused => Refusal is not null; +} diff --git a/src/Capacitor.Cli.Core/Accounts/AccountSnapshotBuilder.cs b/src/Capacitor.Cli.Core/Accounts/AccountSnapshotBuilder.cs new file mode 100644 index 0000000000..6bfd33a0fe --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountSnapshotBuilder.cs @@ -0,0 +1,38 @@ +using Capacitor.Cli.Core.Harness; + +namespace Capacitor.Cli.Core.Accounts; + +/// Builds what the daemon publishes of a host's accounts. There is no row for a default +/// directory: a reader derives the Default badge, and the row for an unregistered default, by +/// matching against . +public static class AccountSnapshotBuilder { + public static AccountsSnapshotDto Build(AccountRegistry registry, string hostId, IReadOnlyDictionary defaults, UserHome home) { + var accounts = new List(); + + foreach (var account in registry.Accounts) { + if (!TryKey(account.Directory, out var key)) continue; + + var state = AccountWiring.State(account.Vendor, account.Directory, home); + accounts.Add(new(account.Id, account.Vendor.VendorId, account.Directory, key, account.Label, RecordingStates.Token(state))); + } + + var directories = defaults + .OrderBy(d => d.Key) + .Select(d => new DefaultDirectoryDto(d.Key.VendorId, d.Value, TryKey(d.Value, out var key) ? key : d.Value)) + .ToArray(); + + return new(hostId, registry.Revision, [.. accounts], directories); + } + + // An entry no key can be derived for cannot be matched by any reader, so one bad entry drops + // out of the snapshot instead of failing it; a default still ships, keyed by its raw spelling. + static bool TryKey(string directory, out string key) { + try { + key = DirectoryKey.Of(directory); + return true; + } catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { + key = ""; + return false; + } + } +} diff --git a/src/Capacitor.Cli.Core/Accounts/AccountStore.cs b/src/Capacitor.Cli.Core/Accounts/AccountStore.cs index 6471d8ef41..f0c7487dfc 100644 --- a/src/Capacitor.Cli.Core/Accounts/AccountStore.cs +++ b/src/Capacitor.Cli.Core/Accounts/AccountStore.cs @@ -13,7 +13,7 @@ public sealed class AccountStore(string directory) { public string Directory { get; } = directory; - string RegistryPath => Path.Combine(Directory, "accounts.json"); + public string RegistryPath => Path.Combine(Directory, "accounts.json"); string LockPath => Path.Combine(Directory, "accounts.lock"); string HostPath => Path.Combine(Directory, "host.json"); @@ -59,12 +59,19 @@ static bool Normalizable(string dir) { catch (UnauthorizedAccessException) { return null; } } - public T Mutate(Func change) { + public T Mutate(Func change) => MutateWithRevision(change).Result; + + /// Saves only when Next is a different reference from the registry it was given; a change + /// that keeps the reference writes nothing and reports the current revision. + public (T Result, long Revision) MutateWithRevision(Func change) { using var _ = Lock(); var current = Load(); var (next, result) = change(current); - if (!ReferenceEquals(next, current)) Save(next with { Revision = current.Revision + 1 }); - return result; + if (ReferenceEquals(next, current)) return (result, current.Revision); + + var saved = next with { Revision = current.Revision + 1 }; + Save(saved); + return (result, saved.Revision); } public VendorAccount? Find(HarnessId vendor, string directory) => diff --git a/src/Capacitor.Cli.Core/Accounts/AccountViewDto.cs b/src/Capacitor.Cli.Core/Accounts/AccountViewDto.cs new file mode 100644 index 0000000000..227d78e908 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountViewDto.cs @@ -0,0 +1,6 @@ +namespace Capacitor.Cli.Core.Accounts; + +/// is the launch token (claude, codex); +/// is the recording-state name in snake_case (recording, +/// installed, broken, not_wired). +public sealed record AccountViewDto(string Id, string Vendor, string Directory, string DirectoryKey, string Label, string RecordingState); diff --git a/src/Capacitor.Cli.Core/Accounts/AccountWiring.cs b/src/Capacitor.Cli.Core/Accounts/AccountWiring.cs index b9a23c5421..10eab6bfb2 100644 --- a/src/Capacitor.Cli.Core/Accounts/AccountWiring.cs +++ b/src/Capacitor.Cli.Core/Accounts/AccountWiring.cs @@ -36,10 +36,12 @@ public static IReadOnlyList Unwire(VendorAccount account, UserHome h }; } - public static RecordingState State(VendorAccount account, UserHome home) { - switch (account.Vendor) { + public static RecordingState State(VendorAccount account, UserHome home) => State(account.Vendor, account.Directory, home); + + public static RecordingState State(HarnessId vendor, string directory, UserHome home) { + switch (vendor) { case HarnessId.Claude: { - var settings = AccountLayouts.Claude(home, account.Directory).UserSettings; + var settings = AccountLayouts.Claude(home, directory).UserSettings; if (IsMalformed(settings)) return RecordingState.Broken; if (!ClaudePluginInstaller.IsPluginEnabled(settings)) return RecordingState.NotWired; if (ClaudePluginInstaller.IsEffectivelyInstalled(settings)) return RecordingState.Recording; @@ -48,7 +50,7 @@ public static RecordingState State(VendorAccount account, UserHome home) { return ClaudePluginInstaller.HasInstallRecord(settings) ? RecordingState.Broken : RecordingState.Installed; } case HarnessId.Codex: { - var hooks = AccountLayouts.Codex(home, account.Directory).UserHooksJson; + var hooks = AccountLayouts.Codex(home, directory).UserHooksJson; // Codex refuses a file with a byte-order mark; rewiring rewrites it without one. if (IsMalformed(hooks) || CodexHooksInstaller.StartsWithByteOrderMark(hooks)) return RecordingState.Broken; return CodexHooksInstaller.ReferencesKcapHook(hooks) ? RecordingState.Installed : RecordingState.NotWired; diff --git a/src/Capacitor.Cli.Core/Accounts/AccountsSnapshotDto.cs b/src/Capacitor.Cli.Core/Accounts/AccountsSnapshotDto.cs new file mode 100644 index 0000000000..5855c4b67c --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountsSnapshotDto.cs @@ -0,0 +1,5 @@ +namespace Capacitor.Cli.Core.Accounts; + +/// A host's accounts as the daemon publishes them, on the local socket and to the server. +/// only increases while stays the same. +public sealed record AccountsSnapshotDto(string HostId, long Revision, AccountViewDto[] Accounts, DefaultDirectoryDto[] Defaults); diff --git a/src/Capacitor.Cli.Core/Accounts/AccountsSnapshotJsonContext.cs b/src/Capacitor.Cli.Core/Accounts/AccountsSnapshotJsonContext.cs new file mode 100644 index 0000000000..2078290011 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/AccountsSnapshotJsonContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace Capacitor.Cli.Core.Accounts; + +/// Every field is always emitted, so this context must never gain a DefaultIgnoreCondition. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(AccountsSnapshotDto))] +public partial class AccountsSnapshotJsonContext : JsonSerializerContext; diff --git a/src/Capacitor.Cli.Core/Accounts/DefaultDirectoryDto.cs b/src/Capacitor.Cli.Core/Accounts/DefaultDirectoryDto.cs new file mode 100644 index 0000000000..4635c9644c --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/DefaultDirectoryDto.cs @@ -0,0 +1,3 @@ +namespace Capacitor.Cli.Core.Accounts; + +public sealed record DefaultDirectoryDto(string Vendor, string Directory, string DirectoryKey); diff --git a/src/Capacitor.Cli.Core/Accounts/DirectoryKey.cs b/src/Capacitor.Cli.Core/Accounts/DirectoryKey.cs new file mode 100644 index 0000000000..e541fb7da8 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/DirectoryKey.cs @@ -0,0 +1,10 @@ +namespace Capacitor.Cli.Core.Accounts; + +public static class DirectoryKey { + // Lower-cased off Linux so a reader on another machine matches with an ordinal compare. + public static string Of(string directory) { + var normalized = AccountDirectory.Normalize(directory); + + return OperatingSystem.IsLinux() ? normalized : normalized.ToLowerInvariant(); + } +} diff --git a/src/Capacitor.Cli.Core/Accounts/RecordingStates.cs b/src/Capacitor.Cli.Core/Accounts/RecordingStates.cs new file mode 100644 index 0000000000..6b3d485a5a --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/RecordingStates.cs @@ -0,0 +1,12 @@ +namespace Capacitor.Cli.Core.Accounts; + +/// The snake_case names a travels under in +/// . +public static class RecordingStates { + public static string Token(RecordingState state) => state switch { + RecordingState.Recording => "recording", + RecordingState.Installed => "installed", + RecordingState.Broken => "broken", + RecordingState.NotWired => "not_wired", + }; +} diff --git a/src/Capacitor.Cli.Core/Accounts/ResolvedAccount.cs b/src/Capacitor.Cli.Core/Accounts/ResolvedAccount.cs new file mode 100644 index 0000000000..01e1f48b38 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/ResolvedAccount.cs @@ -0,0 +1,5 @@ +namespace Capacitor.Cli.Core.Accounts; + +/// The vendor config directory a launch runs under. is null when the +/// directory is not in the registry. +public sealed record ResolvedAccount(string Directory, string? AccountId, string Label); diff --git a/src/Capacitor.Cli.Core/Accounts/VendorAccount.cs b/src/Capacitor.Cli.Core/Accounts/VendorAccount.cs index be6c3a7257..80d01c1f6f 100644 --- a/src/Capacitor.Cli.Core/Accounts/VendorAccount.cs +++ b/src/Capacitor.Cli.Core/Accounts/VendorAccount.cs @@ -4,10 +4,14 @@ namespace Capacitor.Cli.Core.Accounts; /// One vendor config directory kcap records: a Claude CLAUDE_CONFIG_DIR or a Codex -/// CODEX_HOME. is normalized and is the account's key. +/// CODEX_HOME. is normalized and is the account's key. +/// is the last wiring attempt that succeeded and the +/// summary of the latest one that did not; both are null on an entry written before either existed. public sealed record VendorAccount( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("vendor")] HarnessId Vendor, - [property: JsonPropertyName("directory")] string Directory, - [property: JsonPropertyName("label")] string Label, - [property: JsonPropertyName("added_at")] DateTimeOffset AddedAt); + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("vendor")] HarnessId Vendor, + [property: JsonPropertyName("directory")] string Directory, + [property: JsonPropertyName("label")] string Label, + [property: JsonPropertyName("added_at")] DateTimeOffset AddedAt, + [property: JsonPropertyName("wired_at")] DateTimeOffset? WiredAt = null, + [property: JsonPropertyName("wiring_failure")] string? WiringFailure = null); diff --git a/src/Capacitor.Cli.Core/Accounts/VendorHomeVariable.cs b/src/Capacitor.Cli.Core/Accounts/VendorHomeVariable.cs new file mode 100644 index 0000000000..1ae9c44217 --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/VendorHomeVariable.cs @@ -0,0 +1,21 @@ +using Capacitor.Cli.Core.Harness; + +namespace Capacitor.Cli.Core.Accounts; + +/// The environment variable that moves a vendor's config directory, and what a child launched +/// under an account gets for it. +public static class VendorHomeVariable { + public static string? NameOf(HarnessId vendor) => vendor switch { + HarnessId.Claude => "CLAUDE_CONFIG_DIR", + HarnessId.Codex => "CODEX_HOME", + _ => null, + }; + + /// A null Value means remove the variable: the built-in directory is reached by not + /// naming it, since CLAUDE_CONFIG_DIR=~/.claude moves .claude.json inside it. Null for a + /// vendor without a variable. + public static (string Variable, string? Value)? Plan(HarnessId vendor, string directory, UserHome home) => + NameOf(vendor) is { } variable + ? (variable, AccountDirectory.Same(directory, AccountLayouts.DefaultDirectory(vendor, home)) ? null : directory) + : null; +} diff --git a/src/Capacitor.Cli.Core/Accounts/WiringStepDto.cs b/src/Capacitor.Cli.Core/Accounts/WiringStepDto.cs new file mode 100644 index 0000000000..26d82225eb --- /dev/null +++ b/src/Capacitor.Cli.Core/Accounts/WiringStepDto.cs @@ -0,0 +1,3 @@ +namespace Capacitor.Cli.Core.Accounts; + +public sealed record WiringStepDto(string Name, bool Succeeded, string Detail); diff --git a/src/Capacitor.Cli.Core/AgentStartArgs.cs b/src/Capacitor.Cli.Core/AgentStartArgs.cs index 1d975524fa..9c7b238fbf 100644 --- a/src/Capacitor.Cli.Core/AgentStartArgs.cs +++ b/src/Capacitor.Cli.Core/AgentStartArgs.cs @@ -9,6 +9,7 @@ public sealed class AgentStartArgs { public bool Detached { get; private set; } public bool Private { get; private set; } public string? Title { get; private set; } + public string? Account { get; private set; } public string[] Passthrough { get; private set; } = []; public string? Error { get; private set; } @@ -16,7 +17,7 @@ public static AgentStartArgs Parse(string[] args) { var r = new AgentStartArgs(); if (args.Length == 0) { - r.Error = "usage: kcap agent start [--worktree] [--private] [--title |--title=] [--daemon ] [-d|--detach] [-- ]"; + r.Error = "usage: kcap agent start [--worktree] [--private] [--title |--title=] [--account |--account=] [--daemon ] [-d|--detach] [-- ]"; return r; } @@ -49,7 +50,7 @@ public static AgentStartArgs Parse(string[] args) { break; case "--title": - if (i + 1 >= kcap.Length || KcapFlags.Contains(kcap[i + 1])) { + if (i + 1 >= kcap.Length || IsKcapFlag(kcap[i + 1])) { r.Error = "--title requires a value"; return r; @@ -61,6 +62,20 @@ public static AgentStartArgs Parse(string[] args) { case var flag when flag.StartsWith("--title=", StringComparison.Ordinal): if (!r.TrySetTitle(flag["--title=".Length..])) return r; + break; + case "--account": + if (i + 1 >= kcap.Length || IsKcapFlag(kcap[i + 1])) { + r.Error = AccountNeedsValue; + + return r; + } + + if (!r.TrySetAccount(kcap[++i])) return r; + + break; + case var flag when flag.StartsWith("--account=", StringComparison.Ordinal): + if (!r.TrySetAccount(flag["--account=".Length..])) return r; + break; default: r.Error = $"unknown flag {kcap[i]} (agent args go after `--`)"; @@ -72,8 +87,27 @@ public static AgentStartArgs Parse(string[] args) { return r; } - // A title may itself start with '-', so only these are taken as a missing value. - static readonly string[] KcapFlags = ["--", "--worktree", "--private", "--daemon", "-d", "--detach", "--title"]; + const string AccountNeedsValue = "--account requires a value"; + + // A title or account may itself start with '-', so only these are taken as a missing value. + static readonly string[] KcapFlags = ["--", "--worktree", "--private", "--daemon", "-d", "--detach", "--title", "--account"]; + + static bool IsKcapFlag(string token) => + KcapFlags.Contains(token) + || token.StartsWith("--title=", StringComparison.Ordinal) + || token.StartsWith("--account=", StringComparison.Ordinal); + + bool TrySetAccount(string value) { + if (string.IsNullOrWhiteSpace(value)) { + Error = AccountNeedsValue; + + return false; + } + + Account = value.Trim(); + + return true; + } bool TrySetTitle(string value) { if (string.IsNullOrWhiteSpace(value)) { diff --git a/src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs b/src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs index ec340f0fbe..ab0d727d5a 100644 --- a/src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs +++ b/src/Capacitor.Cli.Core/LocalIpc/FrameCodec.cs @@ -45,7 +45,7 @@ static async Task ReadExactlyOrEof(Stream s, byte[] buf, CancellationToken static byte[] Encode(LocalFrame f) => f.Type switch { FrameType.Stdin or FrameType.Stdout => f.Bytes, FrameType.Resize => Dims(f.Cols, f.Rows), - FrameType.Detach or FrameType.StatusSubscribe => [], + FrameType.Detach or FrameType.StatusSubscribe or FrameType.AccountsSubscribe => [], FrameType.Exited => BeInt(f.ExitCode), FrameType.RuntimeStarted => BeLong(f.StartedAt.UtcTicks), FrameType.Error or FrameType.Attach or FrameType.AttachWithRuntime or FrameType.List or FrameType.AgentList @@ -65,7 +65,8 @@ or FrameType.AdmissionFenceAcquire or FrameType.AdmissionFenceCommit or FrameTyp or FrameType.AdmissionFenceAck or FrameType.Shutdown or FrameType.ShutdownAck or FrameType.AgentResume or FrameType.AgentResumeAck - or FrameType.AgentSession or FrameType.AgentSessionReply => Encoding.UTF8.GetBytes(f.Text), + or FrameType.AgentSession or FrameType.AgentSessionReply + or FrameType.AccountsSnapshot => Encoding.UTF8.GetBytes(f.Text), FrameType.Attached or FrameType.Spawn or FrameType.StopV2 or FrameType.AttachedReadOnly => f.Bytes, // pre-encoded by the helpers below _ => throw new InvalidDataException($"unencodable frame {f.Type}"), @@ -74,7 +75,7 @@ FrameType.Attached or FrameType.Spawn static LocalFrame Decode(FrameType t, byte[] p) => t switch { FrameType.Stdin or FrameType.Stdout => new(t) { Bytes = p }, FrameType.Resize => new(t) { Cols = Be16(p, 0), Rows = Be16(p, 2) }, - FrameType.Detach or FrameType.StatusSubscribe => new(t), + FrameType.Detach or FrameType.StatusSubscribe or FrameType.AccountsSubscribe => new(t), FrameType.Exited => new(t) { ExitCode = BinaryPrimitives.ReadInt32BigEndian(p) }, FrameType.RuntimeStarted => new(t) { StartedAt = StartedAt(p) }, FrameType.Error or FrameType.Attach or FrameType.AttachWithRuntime or FrameType.List or FrameType.AgentList @@ -94,23 +95,24 @@ or FrameType.AdmissionFenceAcquire or FrameType.AdmissionFenceCommit or FrameTyp or FrameType.AdmissionFenceAck or FrameType.Shutdown or FrameType.ShutdownAck or FrameType.AgentResume or FrameType.AgentResumeAck - or FrameType.AgentSession or FrameType.AgentSessionReply => new(t) { Text = Encoding.UTF8.GetString(p) }, + or FrameType.AgentSession or FrameType.AgentSessionReply + or FrameType.AccountsSnapshot => new(t) { Text = Encoding.UTF8.GetString(p) }, FrameType.Attached or FrameType.Spawn or FrameType.StopV2 or FrameType.AttachedReadOnly => new(t) { Bytes = p }, _ => throw new InvalidDataException($"undecodable frame {t}"), }; // --- Spawn structured payload --- - // Layout: [work:1][cols:BE16][rows:BE16][vendor:LP][cwd:LP][argc:BE32][args:LP…][private:1][title:1][title text:LP]. - // Every field after args is appended for wire compatibility: an older daemon ignores trailing - // bytes, and a frame from an older CLI simply ends early. + // Layout: [work:1][cols:BE16][rows:BE16][vendor:LP][cwd:LP][argc:BE32][args:LP…][private:1][title:1][title text:LP][account:LP]. + // Every field after args is trailing: a reader ignores bytes past the fields it knows, and a + // frame may end before any of them. const byte SpawnNoTitle = 0; const byte SpawnExplicitTitle = 1; const byte SpawnDerivedTitle = 2; public static LocalFrame Spawn( string vendor, WorkLocation work, bool isPrivate, string cwd, IReadOnlyList args, ushort cols, ushort rows, - AgentStartTitle? title = null) { + AgentStartTitle? title = null, string? accountId = null) { using var ms = new MemoryStream(); ms.WriteByte((byte)work); WriteBe16(ms, cols); WriteBe16(ms, rows); @@ -124,16 +126,17 @@ public static LocalFrame Spawn( } else { ms.WriteByte(SpawnNoTitle); } + WriteLp(ms, accountId ?? ""); return new(FrameType.Spawn) { Bytes = ms.ToArray(), Text = vendor, Work = work, Cols = cols, Rows = rows }; } public static string SpawnCwd(LocalFrame f) => ParseSpawn(f.Bytes).cwd; public static string[] SpawnArgs(LocalFrame f) => ParseSpawn(f.Bytes).args; - public static (string vendor, WorkLocation work, bool isPrivate, string cwd, string[] args, ushort cols, ushort rows, AgentStartTitle? title) Spawn(LocalFrame f) + public static (string vendor, WorkLocation work, bool isPrivate, string cwd, string[] args, ushort cols, ushort rows, AgentStartTitle? title, string? accountId) Spawn(LocalFrame f) => ParseSpawn(f.Bytes); const int MaxSpawnArgs = 4096; // sane cap; the wire arg-count is untrusted (local 0600 socket, same-user) - static (string vendor, WorkLocation work, bool isPrivate, string cwd, string[] args, ushort cols, ushort rows, AgentStartTitle? title) ParseSpawn(byte[] p) { + static (string vendor, WorkLocation work, bool isPrivate, string cwd, string[] args, ushort cols, ushort rows, AgentStartTitle? title, string? accountId) ParseSpawn(byte[] p) { var o = 0; Require(p, o, 5); // work(1) + cols(2) + rows(2) var work = (WorkLocation)p[o++]; @@ -155,12 +158,27 @@ public static (string vendor, WorkLocation work, bool isPrivate, string cwd, str // A frame without the title byte, or with a kind this build does not know, carries no title. AgentStartTitle? title = null; - if (o < p.Length && p[o] is SpawnExplicitTitle or SpawnDerivedTitle) { - var derived = p[o++] == SpawnDerivedTitle; - title = new AgentStartTitle(ReadLp(p, ref o), derived); + var titleKindKnown = true; + if (o < p.Length) { + var kind = p[o]; + if (kind is SpawnExplicitTitle or SpawnDerivedTitle) { + o++; + title = new AgentStartTitle(ReadLp(p, ref o), kind == SpawnDerivedTitle); + } else if (kind == SpawnNoTitle) { + o++; + } else { + titleKindKnown = false; + } } - return (vendor, work, isPrivate, cwd, args, cols, rows, title); + // A title kind this build does not know may lay out its own bytes, so nothing after it is read. + string? accountId = null; + if (titleKindKnown && o < p.Length) { + var id = ReadLp(p, ref o); + if (id.Length > 0) accountId = id; + } + + return (vendor, work, isPrivate, cwd, args, cols, rows, title, accountId); } /// Throws unless bytes @@ -170,6 +188,9 @@ static void Require(byte[] p, int o, int count) { if (o < 0 || count < 0 || (long)o + count > p.Length) throw new InvalidDataException("Frame payload truncated"); } + // --- AccountsSnapshot: Text = AccountsSnapshotDto JSON (snake_case via AccountsSnapshotJsonContext) --- + public static LocalFrame AccountsSnapshot(string json) => new(FrameType.AccountsSnapshot) { Text = json }; + // --- Attached structured payload: [4B agentIdLen][agentId][snapshot...] --- public static LocalFrame Attached(string agentId, byte[] snapshot) { using var ms = new MemoryStream(); diff --git a/src/Capacitor.Cli.Core/LocalIpc/FrameType.cs b/src/Capacitor.Cli.Core/LocalIpc/FrameType.cs index f1f47e95c4..4c14a83277 100644 --- a/src/Capacitor.Cli.Core/LocalIpc/FrameType.cs +++ b/src/Capacitor.Cli.Core/LocalIpc/FrameType.cs @@ -48,6 +48,7 @@ public enum FrameType : byte { // The session a hosted agent is bound to — one-shot. Sent only to a daemon advertising // AgentSessionIpc.Capability. AgentSession = 31, // Text = agent id + AccountsSubscribe = 32, // long-lived: push an AccountsSnapshot (immediate + on change) // daemon → client Attached = 64, Stdout = 65, @@ -74,4 +75,5 @@ public enum FrameType : byte { AgentResumeAck = 84, // Text = "resuming" | "not_interrupted" | "unknown" RuntimeStarted = 85, // BE64 UTC ticks: the attached runtime's start, as the resume ledger's started_at AgentSessionReply = 86, // Text = the agent's session id; empty when the daemon hosts no such agent or it has none yet + AccountsSnapshot = 87, // Text = AccountsSnapshotDto JSON, pushed on AccountsSubscribe } diff --git a/src/Capacitor.Cli.Core/LocalIpc/LocalFrame.cs b/src/Capacitor.Cli.Core/LocalIpc/LocalFrame.cs index 56fa8f10a8..8414c935ba 100644 --- a/src/Capacitor.Cli.Core/LocalIpc/LocalFrame.cs +++ b/src/Capacitor.Cli.Core/LocalIpc/LocalFrame.cs @@ -20,6 +20,7 @@ public sealed record LocalFrame(FrameType Type) { public static LocalFrame Exited(int code) => new(FrameType.Exited) { ExitCode = code }; public static LocalFrame Error(string m) => new(FrameType.Error) { Text = m }; public static LocalFrame RuntimeStarted(DateTimeOffset at) => new(FrameType.RuntimeStarted) { StartedAt = at }; + public static LocalFrame AccountsSubscribe() => new(FrameType.AccountsSubscribe); /// The refusal for an id the daemon is not running; an attach to a resuming agent retries on /// this text and on no other. diff --git a/src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs b/src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs index 6e93136009..2b059be9f1 100644 --- a/src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs +++ b/src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs @@ -39,7 +39,9 @@ public sealed record DaemonInfoDto( // credential is not the problem, and from a daemon that predates the field. string? Credential = null, // The profile the daemon resolved its server and sign-in from. Null from an older daemon. - string? Profile = null); + string? Profile = null, + // Same shape as VendorModels, keyed by registry id. + Dictionary? VendorModelsByAccount = null); /// /// is the daemon's internal status string VERBATIM (PascalCase, open @@ -111,7 +113,11 @@ public sealed record AgentStatusDto( string? Interruption = null, // UTC instant the daemon last saw the agent stop waiting, while Running and not waiting. Lets a // freshly built view show the turn's real elapsed time. Null from an older daemon. - DateTime? WorkingSince = null); + DateTime? WorkingSince = null, + // The account the agent runs under. Null from an older daemon. + string? AccountDirectory = null, + string? AccountId = null, + string? AccountLabel = null); /// Wire tokens for , compared literally by every /// client, so they never change. diff --git a/src/Capacitor.Cli.Core/Models.cs b/src/Capacitor.Cli.Core/Models.cs index c9a8cd0f12..ff5ff47b99 100644 --- a/src/Capacitor.Cli.Core/Models.cs +++ b/src/Capacitor.Cli.Core/Models.cs @@ -1153,6 +1153,7 @@ public sealed record CurationApplyResponse { [JsonSerializable(typeof(StatusReportRequest))] [JsonSerializable(typeof(ResumingAgentClaim))] [JsonSerializable(typeof(ResumingAgentClaim[]))] +[JsonSerializable(typeof(Capacitor.Cli.Core.Accounts.AccountsSnapshotDto))] // Surface 3 (new-harness detection): per-machine coding-agent inventory carried on the status report. [JsonSerializable(typeof(Capacitor.Cli.Core.Setup.HarnessInventory))] [JsonSerializable(typeof(Capacitor.Cli.Core.Setup.HarnessInventoryEntry))] @@ -1716,7 +1717,9 @@ public readonly record struct LaunchAgentCommand( // The agent's session start title. TitleDerived marks one cut from the prompt, which a // generated title replaces; otherwise it is the caller's and stays. Null from an older server. string? Title = null, - bool TitleDerived = false + bool TitleDerived = false, + // Registry id of the account to run under; null runs under the vendor's default directory. + string? AccountId = null ); /// Caller-selected Codex launch posture. Valid ONLY for interactive, daemon-owned-worktree @@ -1853,7 +1856,10 @@ public readonly record struct LiveAgentInfo( ulong? ActivitySeq = null, ulong? IdleForMs = null, bool? TurnInFlight = null, - string? LaunchStage = null + string? LaunchStage = null, + string? AccountDirectory = null, + string? AccountId = null, + string? AccountLabel = null ); /// Phase B (D4 §6.4(2a)): an agent whose death could NOT be confirmed (record-write @@ -2250,7 +2256,13 @@ public readonly record struct DaemonConnect( long? ActiveCountVersion = null, // The permission-preset tokens this daemon enforces, for every vendor in AcpPresetVendors. // Null from a daemon predating the field, which the server reads as explore and edit only. - string[]? AcpPresets = null + string[]? AcpPresets = null, + // Advertises a launch that names an account. False from a daemon predating it; the server + // then never sends an AccountId. + bool SupportsAccountLaunches = false, + Capacitor.Cli.Core.Accounts.AccountsSnapshotDto? Accounts = null, + // Per-account launchable models, keyed by registry id. + Dictionary? VendorModelsByAccount = null ); public sealed record UnattendedVendorCapability( @@ -2312,7 +2324,10 @@ public readonly record struct AgentRegistered( string? Vendor = null, // The agent this one continues after a daemon restart could not resume it, from the resume ledger; the server // checks the claim where it uses it. Trailing name-bound field — an older server ignores it. - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ContinuesAgentId = null + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ContinuesAgentId = null, + string? AccountDirectory = null, + string? AccountId = null, + string? AccountLabel = null ); public readonly record struct AgentStatusChanged( diff --git a/src/Capacitor.Cli.Core/Resources/help-accounts.txt b/src/Capacitor.Cli.Core/Resources/help-accounts.txt index 30a0d2bc4c..f0111e7ad6 100644 --- a/src/Capacitor.Cli.Core/Resources/help-accounts.txt +++ b/src/Capacitor.Cli.Core/Resources/help-accounts.txt @@ -5,14 +5,21 @@ you use, not only the default one. kcap accounts List accounts, their recording state, and directories found on this machine that are not added yet - kcap accounts add [--skip-codex-network-access] + kcap accounts add [--skip-codex-network-access] [--json] Add a directory and install kcap's recording into it - kcap accounts remove Remove kcap's recording from an account and forget it + kcap accounts remove [--json] + Remove kcap's recording from an account and forget it (the directory and its login are left alone) - kcap accounts rename public Dictionary? VendorModels { get; set; } - /// Fingerprints of each vendor's catalog files, taken before the startup probe; the - /// vendor CLI watcher's starting point for them. + /// Each registered Codex account's launchable models, keyed by registry id. An unregistered + /// default has no id, so its catalog is only in . Replaced by reference, + /// like . + public Dictionary? VendorModelsByAccount { get; set; } + + /// Fingerprints of each vendor's catalog files, taken before the startup probe, and of each + /// account's, taken before its latest probe; the vendor CLI watcher's starting point for an entry + /// it begins watching. Replaced by reference. public IReadOnlyDictionary? VendorCatalogBaselines { get; set; } /// @@ -204,6 +213,12 @@ public UserHome Home { set; } + /// The host's account registry; null leaves the accounts publisher inert. + public AccountStore? Accounts { get; set; } + + /// The config directory each account-bearing vendor uses when no account is chosen. + public IReadOnlyDictionary DefaultDirectories { get; set; } = FrozenDictionary.Empty; + /// The PATH probe resolved at boot, the one the HarnessRegistry is built over. A /// configured vendor path is not a harness question, so its resolvers take this. public BinaryProbe Binaries { diff --git a/src/Capacitor.Cli.Daemon/DaemonRunner.cs b/src/Capacitor.Cli.Daemon/DaemonRunner.cs index abf92187f1..96b7721c8f 100644 --- a/src/Capacitor.Cli.Daemon/DaemonRunner.cs +++ b/src/Capacitor.Cli.Daemon/DaemonRunner.cs @@ -98,6 +98,11 @@ public static async Task RunAsync(string[] args) { // Below the carrier scrub: this reads every vendor's override variable, and the scrub above // must run before anything reads the environment. var harnesses = Core.Harness.HarnessRegistry.FromEnvironment(userHome, binaries); + config.Accounts = Core.Accounts.AccountStore.Beside(paths); + config.DefaultDirectories = new Dictionary { + [HarnessId.Claude] = harnesses.Of().Paths.Home, + [HarnessId.Codex] = harnesses.Of().Paths.Home, + }.ToFrozenDictionary(); // Resolve server URL + active profile. The CLI does this in its own // Program.cs, but the daemon is a separate process so its statics start @@ -463,13 +468,14 @@ await Console.Error.WriteLineAsync( ?? throw new InvalidOperationException("No IHostedAgentLauncher registered for vendor 'claude'"), sp.GetRequiredService(), sp.GetRequiredService>(), - sp.GetRequiredService() + sp.GetRequiredService(), + config.Home ) ); // Codex is the ONE vendor with two transports. The composite factory routes review-flow // launches to codex app-server when this daemon resolved it active (config.CodexAppServerActive), // and delegates everything else — every interactive launch, and all launches under the PTY - // default — to the wrapped PTY factory, byte-identically to before. + // default — to the wrapped PTY factory. builder.Services.AddSingleton(sp => { var codexLauncher = sp.GetServices().SingleOrDefault(l => l.Vendor == "codex") ?? throw new InvalidOperationException("No IHostedAgentLauncher registered for vendor 'codex'"); @@ -477,7 +483,8 @@ await Console.Error.WriteLineAsync( codexLauncher, sp.GetRequiredService(), sp.GetRequiredService>(), - sp.GetRequiredService()); + sp.GetRequiredService(), + config.Home); return new Harness.Codex.CodexHostedAgentRuntimeFactory( (Harness.Codex.CodexLauncher) codexLauncher, pty, sp.GetRequiredService(), @@ -626,6 +633,10 @@ await Console.Error.WriteLineAsync( builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); @@ -687,13 +698,16 @@ await Console.Error.WriteLineAsync( // Every advertised vendor is recorded, since a catalog-only vendor is watched too. config.UnattendedVendorBaselines = FingerprintUnattendedVendors(config.Binaries, runtimeFactories, config.SupportedVendors); - config.VendorCatalogBaselines = - VendorModelCatalogs.FingerprintCatalogPaths(runtimeFactories, config.SupportedVendors); config.UnattendedVendorCapabilities = ComputeUnattendedVendorCapabilities(runtimeFactories, config, config.UnattendedVendors); - config.VendorModels = - await VendorModelCatalogs.ProbeAsync(runtimeFactories, config.SupportedVendors, CancellationToken.None); - foreach (var (vendor, models) in config.VendorModels) LogVendorModels(logger, vendor, models.Length); + + // Built before the host starts, so the per-account probe and the vendor watcher's first watch + // set read the same accounts. + var accounts = host.Services.GetRequiredService(); + await accounts.RebuildAsync(CancellationToken.None); + await ProbeVendorCatalogsAsync(config, runtimeFactories, accounts.Current, CancellationToken.None); + foreach (var (vendor, models) in config.VendorModels!) LogVendorModels(logger, vendor, models.Length); + foreach (var (id, models) in config.VendorModelsByAccount!) LogVendorModels(logger, VendorModelCatalogs.AccountKey(id), models.Length); LogStartupPhase(logger, "vendors probed"); // Which build of each unattended vendor was installed when this daemon started. Recorded at @@ -738,6 +752,9 @@ await Console.Error.WriteLineAsync( var lifetime = host.Services.GetRequiredService(); var connection = host.Services.GetRequiredService(); + connection.GetAccountsSnapshot = () => accounts.Current; + accounts.SendToServer = connection.AccountsChangedAsync; + // Death-rattle instrumentation: without these, a daemon dying mid-run // (signal, OOM, unobserved-task FailFast, native crash) leaves no trace // in the log and we can't tell a clean shutdown from a kill. Each hook @@ -1622,6 +1639,26 @@ internal static IReadOnlyList ComputeUnattendedVendo return capabilities; } + /// Fingerprints, then probes, each advertised vendor's catalog and, for the vendor whose + /// catalog depends on the account, each registered account's catalog under its own directory. + internal static async Task ProbeVendorCatalogsAsync( + DaemonConfig config, IReadOnlyList factories, + Core.Accounts.AccountsSnapshotDto? accounts, CancellationToken ct) { + var supported = config.SupportedVendors ?? []; + var accountFactory = VendorModelCatalogs.AccountFactory(factories, supported); + var perAccount = accountFactory is null ? [] : VendorModelCatalogs.AccountsIn(accounts); + + config.VendorCatalogBaselines = VendorModelCatalogs.FingerprintCatalogPaths(factories, supported, accountFactory, perAccount); + var vendorProbe = VendorModelCatalogs.ProbeAsync(factories, supported, ct); + var accountProbe = accountFactory is null + ? Task.FromResult(new Dictionary(StringComparer.Ordinal)) + : VendorModelCatalogs.ProbeByAccountAsync(accountFactory, perAccount, ct); + await Task.WhenAll(vendorProbe, accountProbe); + + config.VendorModels = await vendorProbe; + config.VendorModelsByAccount = await accountProbe; + } + /// Fingerprints each advertised vendor's binary through the factory that launches it — /// the same path the version probe runs. A vendor with no locatable binary maps to null. internal static IReadOnlyDictionary FingerprintUnattendedVendors( diff --git a/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeCommandProbe.cs b/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeCommandProbe.cs index 975df1a303..9388c7aa51 100644 --- a/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeCommandProbe.cs +++ b/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeCommandProbe.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text.Json; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Daemon.Services; @@ -20,9 +21,12 @@ static class ClaudeCommandProbe { const string InitializeRequest = """{"type":"control_request","request_id":"kcap-command-probe","request":{"subtype":"initialize"}}"""; + /// The agent's account, whose directory the probe reads commands from. public static async Task> ProbeAsync( HarnessRegistry harnesses, string workingDir, + ResolvedAccount? account, + UserHome home, TimeProvider time, TimeSpan timeout, Action log, @@ -32,26 +36,7 @@ public static async Task> ProbeAsync( var exePath = harnesses.ResolveExecutable(HarnessId.Claude); if (exePath is null) return []; - var psi = new ProcessStartInfo { - FileName = exePath, - WorkingDirectory = workingDir, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - Environment = { ["KCAP_SKIP"] = "1" }, - }; - psi.Environment.Remove("CLAUDECODE"); - psi.Environment.Remove("CLAUDE_CODE_ENTRYPOINT"); - - foreach (var arg in new[] { - "-p", "--input-format", "stream-json", "--output-format", "stream-json", - "--verbose", "--strict-mcp-config", "--mcp-config", """{"mcpServers":{}}""", - }) - psi.ArgumentList.Add(arg); - - using var process = CliProcess.TryStart(psi, log); + using var process = CliProcess.TryStart(BuildStartInfo(exePath, workingDir, account, home), log); if (process is null) return []; using var timeoutCts = new CancellationTokenSource(timeout, time); @@ -89,6 +74,34 @@ public static async Task> ProbeAsync( } } + internal static ProcessStartInfo BuildStartInfo(string exePath, string workingDir, ResolvedAccount? account, UserHome home) { + var psi = new ProcessStartInfo { + FileName = exePath, + WorkingDirectory = workingDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + Environment = { ["KCAP_SKIP"] = "1" }, + }; + psi.Environment.Remove("CLAUDECODE"); + psi.Environment.Remove("CLAUDE_CODE_ENTRYPOINT"); + + if (account is not null && VendorHomeVariable.Plan(HarnessId.Claude, account.Directory, home) is { } plan) { + if (plan.Value is null) psi.Environment.Remove(plan.Variable); + else psi.Environment[plan.Variable] = plan.Value; + } + + foreach (var arg in new[] { + "-p", "--input-format", "stream-json", "--output-format", "stream-json", + "--verbose", "--strict-mcp-config", "--mcp-config", """{"mcpServers":{}}""", + }) + psi.ArgumentList.Add(arg); + + return psi; + } + internal static IReadOnlyList ParseCommands(string stdout) { foreach (var line in stdout.Split('\n')) { var trimmed = line.Trim(); diff --git a/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeLauncher.cs b/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeLauncher.cs index 9a7c1472d0..eb93e3e954 100644 --- a/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeLauncher.cs +++ b/src/Capacitor.Cli.Daemon/Harness/Claude/ClaudeLauncher.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Claude; @@ -36,7 +37,8 @@ public bool DisablesApprovalPrompts(LauncherContext ctx) => public bool IsAvailable() => config.Binaries.Finds(CliPath); - readonly ClaudePaths _paths = harnesses.Of().Paths; + ClaudePaths PathsFor(ResolvedAccount? account) => + account is null ? harnesses.Of().Paths : AccountLayouts.Claude(config.Home, account.Directory); static readonly Lock TrustWriteLock = new(); static readonly JsonSerializerOptions IndentedJsonOpts = new() { WriteIndented = true }; @@ -53,9 +55,11 @@ public void Prepare(LauncherContext ctx) { "Claude borrowed review flows are not certified; retry with an owned review worktree."); // A borrowed cwd is the user's own, already-trusted, already-configured repo — do // NOT overlay settings, write .mcp.json, edit settings.local.json, or write - // ~/.claude.json trust into it (local in-place launch). Touch nothing. + // .claude.json trust for it (local in-place launch). Touch nothing. if (ctx.Work == WorkLocation.BorrowedCwd) return; + var paths = PathsFor(ctx.Account); + // Overlay .claude/ local settings from source repo into worktree. try { var sourceClaudeDir = Path.Combine(ctx.SourceRepoPath, ".claude"); @@ -65,33 +69,28 @@ public void Prepare(LauncherContext ctx) { FileSystemOverlay.OverlayDirectory(sourceClaudeDir, destClaudeDir); } - SymlinkClaudeProjectDir(ctx.SourceRepoPath, ctx.Worktree.Path, _paths); + SymlinkClaudeProjectDir(ctx.SourceRepoPath, ctx.Worktree.Path, paths); } catch (Exception ex) { LogOverlayFailed(ex, ctx.AgentId); } try { - WriteMcpConfig(ctx.SourceRepoPath, ctx.Worktree.Path, _paths, config.KcapCliPath); + WriteMcpConfig(ctx.SourceRepoPath, ctx.Worktree.Path, paths, config.KcapCliPath); } catch (Exception ex) { LogMcpConfigFailed(ex, ctx.AgentId); } try { - TrustWorktreeInClaudeConfig(ctx.Worktree.Path, _paths); + TrustWorktreeInClaudeConfig(ctx.Worktree.Path, paths); } catch (Exception ex) { LogTrustWorktreeFailed(ex, ctx.AgentId); } - // Unattended review-flow reviewers launch with `--permission-mode bypassPermissions` - // (see BuildArgs). On a host that hasn't accepted bypass mode, Claude blocks on a - // one-time consent dialog and the launch dies silently at the server's timeout. - // Pre-accept it here in user settings so the reviewer starts cleanly — only for the - // bypass path, since interactive agents never pass it and a human can dismiss any - // prompt. Best-effort: the PTY dialog detector is the fail-fast backstop if this didn't - // take. + // A review-flow reviewer runs under bypassPermissions (see BuildArgs), and until that mode is + // accepted Claude blocks on a consent dialog nobody answers. The PTY dialog detector is the backstop. if (ctx.IsReviewFlow) { try { - AcceptBypassPermissionsMode(_paths.UserSettings); + AcceptBypassPermissionsMode(paths.UserSettings); } catch (Exception ex) { LogBypassAcceptFailed(ex, ctx.AgentId); } @@ -265,7 +264,7 @@ public LaunchArgs BuildPassthrough(LauncherContext ctx, IReadOnlyList us => new([.. userArgs], McpConfigPath: null); public void Cleanup(AgentInstance agent) { - try { RemoveClaudeProjectSymlink(agent.Worktree.Path, _paths); } catch (Exception ex) { + try { RemoveClaudeProjectSymlink(agent.Worktree.Path, PathsFor(agent.Account)); } catch (Exception ex) { LogCleanupSymlinkFailed(ex, agent.Id); } @@ -276,8 +275,6 @@ public void Cleanup(AgentInstance agent) { } } - // === 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; @@ -328,9 +325,8 @@ internal static void MergeToolPermissions(string worktreePath, string[] tools) { } /// - /// 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. + /// Links the layout's projects/ entry for the worktree to the source repository's, so + /// project-level permissions, settings and memory are shared with the hosted agent. /// static void SymlinkClaudeProjectDir(string sourceRepoPath, string worktreePath, ClaudePaths paths) { if (!Directory.Exists(paths.Projects)) { @@ -361,8 +357,7 @@ static void SymlinkClaudeProjectDir(string sourceRepoPath, string worktreePath, internal static void CreateJunction(string link, string target) => WindowsJunction.Create(link, target); /// - /// Removes the ~/.claude/projects/{worktree-path-hash} symlink if it exists. - /// Only removes symlinks, never real directories. + /// Removes the layout's projects/ entry for the worktree when it is a link, never a real directory. /// static void RemoveClaudeProjectSymlink(string worktreePath, ClaudePaths paths) { var info = new DirectoryInfo(paths.ProjectDir(worktreePath)); @@ -384,22 +379,17 @@ internal static string NormalizeClaudeProjectKey(string path) { } internal static void TrustWorktreeInClaudeConfig(string worktreePath, ClaudePaths paths) { - // 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. + // Serialize against concurrent agent launches. paths.UserConfigJson is shared by every + // launch under the layout 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. - // The spawned `claude` inherits CLAUDE_CONFIG_DIR from the daemon's - // environment, so it reads/writes the same file we resolve here — - // $CLAUDE_CONFIG_DIR/.claude.json when set, else ~/.claude.json. + // Workspace trust lives in the layout's .claude.json under projects[path]; the file may + // not exist yet. var claudeJsonPath = paths.UserConfigJson; // The in-process lock above serializes only THIS daemon; every kcap writer of - // ~/.claude.json must also take the shared cross-process lock, or this write can + // paths.UserConfigJson must also take the shared cross-process lock, or this write can // land between another writer's inside-lock re-read and its rename (e.g. doctor // --clean) and be silently overwritten. See ConfigFileLock. using var _ = ConfigFileLock.Acquire(claudeJsonPath); @@ -435,7 +425,7 @@ internal static void TrustWorktreeInClaudeConfig(string worktreePath, ClaudePath // 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 + // .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); @@ -492,13 +482,6 @@ internal static void TrustWorktreeInClaudeConfig(string worktreePath, ClaudePath /// internal const string BypassPermissionsAcceptedKey = "skipDangerousModePermissionPrompt"; - /// - /// Records acceptance of Claude's Bypass-Permissions consent dialog in the user settings file - /// Claude reads ($CLAUDE_CONFIG_DIR/settings.json when set, else ~/.claude/settings.json) — - /// the same effect as accepting the dialog once interactively, so an unattended reviewer never - /// wedges on it. The spawned Claude inherits CLAUDE_CONFIG_DIR from the daemon, so it reads - /// the same file this resolves. - /// /// /// Sets = true in the settings file at /// , merging into any existing settings and preserving every other @@ -674,7 +657,7 @@ internal static void WriteMcpConfig(string sourceRepoPath, string worktreePath, // registration. var pluginInstalled = ClaudePluginInstaller.IsEffectivelyInstalled(paths.UserSettings); - // Add servers from ~/.claude.json (don't overwrite repo-committed ones) + // Add the layout's .claude.json servers; a repo-committed entry of the same name wins. foreach (var (name, value) in servers) { // nativeKcapPath comes from DaemonConfig (the CLI binary sibling of this daemon): // Environment.ProcessPath here would be kcap-daemon, misclassifying a canonical diff --git a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexConfigWriter.cs b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexConfigWriter.cs index 2c923b9376..8bd01e9ad8 100644 --- a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexConfigWriter.cs +++ b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexConfigWriter.cs @@ -5,7 +5,7 @@ namespace Capacitor.Cli.Daemon.Harness.Codex; /// /// Daemon-facing wrapper around that -/// pre-trusts a worktree in ~/.codex/config.toml so Codex doesn't prompt on +/// pre-trusts a worktree in the layout's config.toml so Codex doesn't prompt on /// every hook execution. The read-modify-write plumbing lives in Core; this layer /// only adds the daemon's structured logging on failure. /// diff --git a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHooksNotInstalledException.cs b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHooksNotInstalledException.cs index f51d94bc29..73c1e316d5 100644 --- a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHooksNotInstalledException.cs +++ b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHooksNotInstalledException.cs @@ -3,12 +3,8 @@ namespace Capacitor.Cli.Daemon.Harness.Codex; /// -/// Thrown by 's Prepare preflight when neither the -/// user-scope (~/.codex/hooks.json) nor project-scope -/// (<worktree>/.codex/hooks.json) hooks file has a -/// kcap 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 -/// kcap plugin install --codex. +/// Thrown by 's Prepare preflight when neither the launch account's +/// hooks.json nor the worktree's .codex/hooks.json wires SessionStart / Stop / +/// PermissionRequest to kcap. The orchestrator reports the message as . /// internal sealed class CodexHooksNotInstalledException(string message) : Exception(message); diff --git a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHostedAgentRuntimeFactory.cs b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHostedAgentRuntimeFactory.cs index 565697f1cd..c55bd743d7 100644 --- a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHostedAgentRuntimeFactory.cs +++ b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexHostedAgentRuntimeFactory.cs @@ -1,4 +1,6 @@ +using System.Collections.Frozen; using System.Diagnostics; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Policy; using Capacitor.Cli.Daemon.Acp; @@ -55,7 +57,7 @@ public CodexHostedAgentRuntimeFactory( _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _spawnFactory = spawnFactory - ?? ((cli, args, seed, cwd, env, cfg, lf) => DefaultSpawnFactory(cli, args, seed, cwd, env, cfg, lf, time)); + ?? ((cli, args, seed, cwd, env, removeEnv, cfg, lf) => DefaultSpawnFactory(cli, args, seed, cwd, env, removeEnv, cfg, lf, time)); _connection = connection; _policyJudge = policyJudge; } @@ -87,21 +89,35 @@ public CodexHostedAgentRuntimeFactory( public IReviewerModelResolver? ReviewerModelResolver => _pty.ReviewerModelResolver; public bool SupportsModelSelection => _pty.SupportsModelSelection; - public Task?> ProbeModelsAsync(CancellationToken ct) { - var dir = CodexModelCatalogProbe.DirectoryFor(_config.Store.StateDirectory(_config.Name)); + public Task?> ProbeModelsAsync(CancellationToken ct) => ProbeModelsAsync(null, ct); - return CodexModelCatalogProbe.RunAsync( + /// A null directory probes under the daemon's own environment; an account's probe gets that + /// account's CODEX_HOME, removed for the built-in directory, as its launches do. + public Task?> ProbeModelsAsync(string? configDirectory, CancellationToken ct) => + CodexModelCatalogProbe.RunAsync( () => { + var stateDir = _config.Store.StateDirectory(_config.Name); + var env = new Dictionary(); + var (dir, remove) = configDirectory is null + ? (CodexModelCatalogProbe.DirectoryFor(stateDir), FrozenSet.Empty) + : (CodexModelCatalogProbe.DirectoryFor(stateDir, DirectoryKey.Of(configDirectory)), + VendorHomeEnv.Apply(env, Vendor, configDirectory, _config.Home)); Directory.CreateDirectory(dir); - return _spawnFactory(_pty.CliPath, [], null, dir, new Dictionary(), _config, _loggerFactory); + return _spawnFactory(_pty.CliPath, [], null, dir, env, remove, _config, _loggerFactory); }, string.IsNullOrEmpty(_config.Version) ? "0.0.0" : _config.Version, _time, _logger, ct); - } /// Codex rewrites its cached catalog when it refetches it, and its auth file when the /// account changes; either can change the models this account may launch. public IReadOnlyList CatalogFingerprintPaths => [_launcher.Paths.ModelsCache, _launcher.Paths.AuthJson]; + public IReadOnlyList CatalogFingerprintPathsFor(string? configDirectory) { + if (configDirectory is null) return CatalogFingerprintPaths; + + var paths = AccountLayouts.Codex(_config.Home, configDirectory); + return [paths.ModelsCache, paths.AuthJson]; + } + // A default-kind launch is the only Codex shape that accepts a follow-up while running, so it is // the only one whose fetch happens alongside a live, write-contained process: the store keeps // that write outside every cwd it could steer. Protected kinds fetch before the process exists. @@ -159,7 +175,10 @@ async Task StartAppServerAsync(RuntimeStartContext ctx, Canc // backstops server-side. ONE decision drives all four (marker, emission, deferral, forwarder) so // they can never desync. Rollback is codex.transport=pty — this method is not reached then. const bool envelopeSourced = true; - var env = BuildEnv(ctx, envelopeSourced); + var env = BuildEnv(ctx, envelopeSourced); + var removeEnv = ctx.Account is { } account + ? VendorHomeEnv.Apply(env, Vendor, account, _config.Home) + : FrozenSet.Empty; var launch = new CodexAppServerLaunch( Cwd: ctx.Worktree.Path, @@ -176,7 +195,7 @@ async Task StartAppServerAsync(RuntimeStartContext ctx, Canc ResumeSessionId: ctx.ResumeSessionId); CodexAppServerSpawn spawn = (seed, spawnCt) => - _spawnFactory(_launcher.CliPath, appServerArgs, seed, ctx.Worktree.Path, env, _config, _loggerFactory); + _spawnFactory(_launcher.CliPath, appServerArgs, seed, ctx.Worktree.Path, env, removeEnv, _config, _loggerFactory); ctx.Journal?.Open(ctx.Worktree.Path, ctx.Model); @@ -219,7 +238,8 @@ async Task StartAppServerAsync(RuntimeStartContext ctx, Canc Review: ctx.Review, // App-server review flows carry no PR-review MCP launch (ctx.IsReview is false); the // flow-result + allowlist servers are spliced by BuildAppServerLaunchArgs from IsReviewFlow. - ReviewLaunch: null) { + ReviewLaunch: null, + Account: ctx.Account) { McpAllowlist = ctx.McpAllowlist, Work = ctx.Work, CodexPosture = ctx.CodexPosture, @@ -246,11 +266,13 @@ internal static Dictionary BuildEnv(RuntimeStartContext ctx, boo // Materialize the child's environment over the daemon's own, where nothing inherited may speak for // this launch: an inherited envelope marker would make a non-emitting child's hook watcher stand // down and lose ingestion, and an inherited session or lookup agent names another session. - internal static void ApplyChildEnv(IDictionary childEnv, IReadOnlyDictionary overlay) { + internal static void ApplyChildEnv( + IDictionary childEnv, IReadOnlyDictionary overlay, IReadOnlyCollection remove) { childEnv.Remove(HostedAppServerMarkerEnv); childEnv.Remove(HostedAgent.FlowRoleVar); childEnv.Remove(HostedAgent.DaemonAgentIdVar); InheritedHarnessSessions.Remove(childEnv); + foreach (var key in remove) childEnv.Remove(key); foreach (var (k, v) in overlay) childEnv[k] = v; } @@ -258,8 +280,8 @@ internal static void ApplyChildEnv(IDictionary childEnv, IReadO /// shared (stderr drain + terminate) and the JSON-RPC transport. static Task<(CodexAppServerConnection, IAcpProcess)> DefaultSpawnFactory( string cliPath, IReadOnlyList appServerArgs, string? hookStateSeed, string cwd, - IReadOnlyDictionary env, DaemonConfig config, ILoggerFactory loggerFactory, - TimeProvider time) { + IReadOnlyDictionary env, IReadOnlyCollection removeEnv, DaemonConfig config, + ILoggerFactory loggerFactory, TimeProvider time) { var argv = new List { "app-server" }; argv.AddRange(appServerArgs); if (!string.IsNullOrEmpty(hookStateSeed)) { @@ -274,7 +296,7 @@ internal static void ApplyChildEnv(IDictionary childEnv, IReadO UseShellExecute = false, WorkingDirectory = cwd, }; - ApplyChildEnv(psi.Environment, env); + ApplyChildEnv(psi.Environment, env, removeEnv); WindowsLaunchCommand.Apply(psi, BinaryProbe.FromEnvironment()); var process = Process.Start(psi) @@ -293,4 +315,5 @@ internal static void ApplyChildEnv(IDictionary childEnv, IReadO /// production spawns a real process; a test substitutes an in-process fake peer. internal delegate Task<(CodexAppServerConnection Connection, IAcpProcess Process)> CodexAppServerSpawnFactory( string cliPath, IReadOnlyList appServerArgs, string? hookStateSeed, string cwd, - IReadOnlyDictionary env, DaemonConfig config, ILoggerFactory loggerFactory); + IReadOnlyDictionary env, IReadOnlyCollection removeEnv, DaemonConfig config, + ILoggerFactory loggerFactory); diff --git a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexLauncher.cs b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexLauncher.cs index f57151c550..386d29f4e1 100644 --- a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexLauncher.cs +++ b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexLauncher.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Commands; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Harness; @@ -15,9 +16,10 @@ internal sealed partial class CodexLauncher( HarnessRegistry harnesses, ILogger logger ) : IHostedAgentLauncher { - readonly CodexPaths _paths = harnesses.Of().Paths; + internal CodexPaths Paths => harnesses.Of().Paths; - internal CodexPaths Paths => _paths; + internal CodexPaths PathsFor(ResolvedAccount? account) => + account is null ? Paths : AccountLayouts.Codex(config.Home, account.Directory); public string Vendor => "codex"; public string CliPath => config.CodexPath; @@ -90,9 +92,10 @@ public void Prepare(LauncherContext ctx) { // launch and turns it into an actionable LaunchFailed instead of a spawn error. if (!WindowsVersionSupported) throw new CodexUnsupportedWindowsException(UnsupportedWindowsMessage); - // A borrowed cwd is the user's own repo: skip the repo-mutating steps (overlay, - // ~/.codex trust write). Only the read-only hooks preflight runs for it. + // A borrowed cwd is the user's own repo: skip the repo-mutating steps (overlay, trust + // write). Only the read-only hooks preflight runs for it. var owned = ctx.Work == WorkLocation.OwnedWorktree; + var paths = PathsFor(ctx.Account); if (owned) { // Step 1: overlay source/.codex into worktree FIRST so project-scope @@ -113,7 +116,7 @@ public void Prepare(LauncherContext ctx) { // Re-running the neutralizer is preferred over teaching the overlay an exclusion list: // it reuses the one canonical path list, and the overlay's actual purpose is // `.codex/hooks.json` (project-scope hooks; MCP servers are registered in the - // USER-scope ~/.codex/config.toml), so nothing this launcher needs is lost. + // user-scope config.toml), so nothing this launcher needs is lost. WorktreeManager.NeutralizeWorkspaceMcpConfig(ctx.Worktree.Path); } } catch (Exception ex) { @@ -121,22 +124,24 @@ public void Prepare(LauncherContext ctx) { } } - // Step 2: hook preflight (fail-fast, read-only). Either worktree/cwd-scope OR - // user-scope is sufficient. Runs for borrowed cwd too — it only reads. + // Step 2: hook preflight (fail-fast, read-only). Either worktree/cwd-scope OR the launch + // account's user scope is sufficient. Runs for borrowed cwd too — it only reads. var worktreeHooks = Path.Combine(ctx.Worktree.Path, ".codex", "hooks.json"); - if (!HooksInstalledIn(worktreeHooks) && !HooksInstalledIn(_paths.UserHooksJson)) { + if (!HooksInstalledIn(worktreeHooks) && !HooksInstalledIn(paths.UserHooksJson)) { + // Not `kcap plugin install --codex`: it wires the CLI shell's Codex directory, which + // need not be the one this launch uses. throw new CodexHooksNotInstalledException( - "Codex hooks not installed. Run `kcap plugin install --codex` " + - "(user scope) or `kcap plugin install --codex --project` " + - "(project scope) and try again." + $"Codex hooks not installed in {paths.Home}. Run `kcap accounts add codex {ShellArgument.Quote(paths.Home)}` " + + "to wire that directory, or `kcap plugin install --codex --project` " + + "(project scope), and try again." ); } if (owned) { - // Step 3: pre-trust the worktree in ~/.codex/config.toml. Best-effort. + // Step 3: pre-trust the worktree in the launch account's config.toml. Best-effort. try { - CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, _paths, logger); + CodexConfigWriter.TrustWorktree(ctx.Worktree.Path, paths, logger); } catch (Exception ex) { LogTrustFailed(ex, ctx.AgentId); } @@ -194,7 +199,7 @@ public LaunchArgs BuildArgs(LauncherContext ctx) { // Review-flow reviewers get exactly ONE MCP server: kcap-flow-result (+ any // allowlisted, non-flow-starting server) — it can only submit a result, never start a - // flow. Codex's `-c` overrides deep-merge into ~/.codex/config.toml (no analog of + // flow. Codex's `-c` overrides deep-merge into the layout's config.toml (no analog of // Claude's `--strict-mcp-config`), so we (1) DISABLE every inherited server — from // config.toml AND native plugins, enumerated via `codex mcp list --json` — in one // `-c mcp_servers={ … }` table override that handles dotted/plugin names too, then @@ -345,7 +350,7 @@ void AddFlowResultServer(List args, LauncherContext ctx, bool appServer) const string name = "kcap-flow-result"; // Force-enable: the disable pass above skips this name, but the user's OWN - // ~/.codex/config.toml may already have it (or never had it) set to enabled=false from + // config.toml may already have it (or never had it) set to enabled=false from // a prior manual registration. `-c` deep-merges over that file, so skipping the disable // is not enough on its own — an explicit enabled=true override wins regardless. args.Add("-c"); @@ -421,7 +426,7 @@ static void AddAppServerApprovalMode(List args, string serverId, bool ap /// dispatch; passing it to Codex verbatim — as PTY `-m default`, or as the app-server /// thread/turn `model` param — is rejected on a ChatGPT account ("The 'default' model is not /// supported when using Codex with a ChatGPT account") and yields a failed/empty turn. Omitting it - /// makes Codex resolve the model from ~/.codex/config.toml (mirrors the effort=="auto" case). Shared + /// makes Codex resolve the model from its config.toml (mirrors the effort=="auto" case). Shared /// by the PTY launch args AND CodexAppServerHostedAgentRuntime's thread/turn params so the /// sentinel is honored on every Codex transport. internal static bool IsConcreteModel(string? model) => @@ -438,7 +443,7 @@ void AddModelArg(List args, LauncherContext ctx) { // substituted model would carry an authority it does not have. A reviewer that parks on the // migration dialog instead is reaped by its first-output deadline, which is the visible // failure this trade prefers. - args.Add(ctx.IsReviewFlow || ctx.IsReview ? ctx.Model! : MigratedModel(ctx.Model!, ctx.AgentId)); + args.Add(ctx.IsReviewFlow || ctx.IsReview ? ctx.Model! : MigratedModel(ctx.Model!, ctx)); } /// The slug Codex would end up on anyway, when its own @@ -448,21 +453,21 @@ void AddModelArg(List args, LauncherContext ctx) { /// on "Waiting for session to start…" until something answers it. Read from the operator's own /// acknowledged map, never a table of ours — Codex owns which model replaces which, and an /// unacknowledged migration is not in there to read. - string MigratedModel(string model, string agentId) { - var migrations = CodexConfigToml.ReadModelMigrations(_paths.ConfigToml); + string MigratedModel(string model, LauncherContext ctx) { + var migrations = CodexConfigToml.ReadModelMigrations(PathsFor(ctx.Account).ConfigToml); if (!migrations.TryGetValue(model, out var migrated) || string.IsNullOrWhiteSpace(migrated) || string.Equals(migrated, model, StringComparison.Ordinal)) return model; - LogModelMigrated(agentId, model, migrated); + LogModelMigrated(ctx.AgentId, model, migrated); return migrated; } /// Review launch: inject the same kcap-review MCP server Claude gets, but via - /// ephemeral `-c` overrides (no ~/.codex/config.toml mutation, nothing to clean + /// ephemeral `-c` overrides (no config.toml mutation, nothing to clean /// up), and pass the rendered review prompt as Codex's initial prompt (Codex has /// no --system-prompt equivalent). /// @@ -535,7 +540,7 @@ public LaunchArgs BuildPassthrough(LauncherContext ctx, IReadOnlyList us } public void Cleanup(AgentInstance agent) { - // No-op: ~/.codex/config.toml trust entries are intentionally persistent. + // No-op: config.toml trust entries are intentionally persistent. } static bool HooksInstalledIn(string hooksPath) { diff --git a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexModelCatalogProbe.cs b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexModelCatalogProbe.cs index 7875d972f4..a6aa15c51a 100644 --- a/src/Capacitor.Cli.Daemon/Harness/Codex/CodexModelCatalogProbe.cs +++ b/src/Capacitor.Cli.Daemon/Harness/Codex/CodexModelCatalogProbe.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Capacitor.Cli.Core; @@ -17,6 +19,12 @@ internal static partial class CodexModelCatalogProbe { internal static string DirectoryFor(string stateDir) => Path.Combine(stateDir, "codex-probe"); + /// One working directory per account, named by a hash of its directory key: the key is a + /// whole path, so it cannot be a file name itself. + internal static string DirectoryFor(string stateDir, string directoryKey) => + Path.Combine(DirectoryFor(stateDir), + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(directoryKey)))[..16].ToLowerInvariant()); + /// Null on every failure, never an empty catalog: an empty one would suppress the /// launcher's other sources. internal static async Task?> RunAsync( diff --git a/src/Capacitor.Cli.Daemon/Pty/IPtyProcessFactory.cs b/src/Capacitor.Cli.Daemon/Pty/IPtyProcessFactory.cs index 2518f41caf..f68b415086 100644 --- a/src/Capacitor.Cli.Daemon/Pty/IPtyProcessFactory.cs +++ b/src/Capacitor.Cli.Daemon/Pty/IPtyProcessFactory.cs @@ -15,6 +15,9 @@ public static class PtyDefaults public interface IPtyProcessFactory { + /// Inherited variables the child must not see; is + /// applied after them. IPtyProcess Spawn(string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = PtyDefaults.Cols, ushort rows = PtyDefaults.Rows); + Dictionary? extraEnv = null, ushort cols = PtyDefaults.Cols, ushort rows = PtyDefaults.Rows, + IReadOnlyCollection? removeEnv = null); } diff --git a/src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs b/src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs index 7e91016fb4..eabc0aad76 100644 --- a/src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs +++ b/src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs @@ -81,7 +81,8 @@ static bool IsExecutableRegularFile(string path) { return UnixPtyInterop.access(path, UnixPtyInterop.X_OK) == 0; } - internal static IReadOnlyDictionary BuildChildEnv(Dictionary? extraEnv, ushort cols, ushort rows) { + internal static IReadOnlyDictionary BuildChildEnv( + Dictionary? extraEnv, IReadOnlyCollection? removeEnv, ushort cols, ushort rows) { var env = new Dictionary(StringComparer.Ordinal); foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) if (entry is { Key: string key, Value: string value }) env[key] = value; @@ -102,6 +103,9 @@ internal static IReadOnlyDictionary BuildChildEnv(Dictionary? extraEnv = null, ushort cols = 120, - ushort rows = 40 + ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) { - var childEnv = BuildChildEnv(extraEnv, cols, rows); + var childEnv = BuildChildEnv(extraEnv, removeEnv, cols, rows); var envpArr = ToEnvpArray(childEnv); var resolvedPath = ResolveExecutableAbsolutePath(command, cwd, childEnv); @@ -397,7 +402,8 @@ public IPtyProcess Spawn( string cwd, Dictionary? extraEnv = null, ushort cols = 120, - ushort rows = 40 + ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) - => UnixPtyProcess.Spawn(spawner, command, args, cwd, time, extraEnv, cols, rows); + => UnixPtyProcess.Spawn(spawner, command, args, cwd, time, extraEnv, cols, rows, removeEnv); } diff --git a/src/Capacitor.Cli.Daemon/Pty/Windows/ConPtyProcess.cs b/src/Capacitor.Cli.Daemon/Pty/Windows/ConPtyProcess.cs index cab5115206..9eb57924af 100644 --- a/src/Capacitor.Cli.Daemon/Pty/Windows/ConPtyProcess.cs +++ b/src/Capacitor.Cli.Daemon/Pty/Windows/ConPtyProcess.cs @@ -92,7 +92,7 @@ internal static (string command, bool isCmd) ResolveCommand(string command) { return (command, false); } - static IntPtr BuildEnvironmentBlock(Dictionary? extraEnv) { + static IntPtr BuildEnvironmentBlock(Dictionary? extraEnv, IReadOnlyCollection? removeEnv) { var env = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) { @@ -132,6 +132,12 @@ static IntPtr BuildEnvironmentBlock(Dictionary? extraEnv) { env.Remove(key); } + if (removeEnv is not null) { + foreach (var key in removeEnv) { + env.Remove(key); + } + } + if (extraEnv is not null) { foreach (var (key, value) in extraEnv) { env[key] = value; @@ -164,7 +170,8 @@ public static ConPtyProcess Spawn( TimeProvider time, Dictionary? extraEnv = null, ushort cols = 120, - ushort rows = 40 + ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) { var cmdLine = BuildCommandLine(command, args, ResolveCommand, NpmCmdShim.TryRead); @@ -304,7 +311,7 @@ public static ConPtyProcess Spawn( si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; si.lpAttributeList = attrList; - var envBlock = BuildEnvironmentBlock(extraEnv); + var envBlock = BuildEnvironmentBlock(extraEnv, removeEnv); try { const uint creationFlags = EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT; @@ -655,7 +662,8 @@ public IPtyProcess Spawn( string cwd, Dictionary? extraEnv = null, ushort cols = 120, - ushort rows = 40 + ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) - => ConPtyProcess.Spawn(command, args, cwd, time, extraEnv, cols, rows); + => ConPtyProcess.Spawn(command, args, cwd, time, extraEnv, cols, rows, removeEnv); } diff --git a/src/Capacitor.Cli.Daemon/Services/AccountsIpc.cs b/src/Capacitor.Cli.Daemon/Services/AccountsIpc.cs new file mode 100644 index 0000000000..ba898bb645 --- /dev/null +++ b/src/Capacitor.Cli.Daemon/Services/AccountsIpc.cs @@ -0,0 +1,46 @@ +using System.Net.Sockets; +using System.Text.Json; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.LocalIpc; + +namespace Capacitor.Cli.Daemon.Services; + +/// Local-socket handler for AccountsSubscribe: the current snapshot at once, then each new +/// one, until the client goes away. Before the first snapshot exists it writes nothing and waits. +internal sealed class AccountsIpc(AccountsPublisher publisher) { + int _subscribers; + internal int ActiveSubscribersForTest => Volatile.Read(ref _subscribers); + + public async Task HandleSubscribeAsync(Stream stream, CancellationToken ct) { + Interlocked.Increment(ref _subscribers); + try { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + // Without this a vanished subscriber would park the handler on the publisher forever. + _ = Task.Run(async () => { + try { while (await FrameCodec.ReadAsync(stream, cts.Token) is not null) { } } + catch { } + try { cts.Cancel(); } catch (ObjectDisposedException) { } + }, cts.Token); + + AccountsSnapshotDto? sent = null; + while (true) { + var seen = publisher.Version; + if (publisher.Current is { } current && !ReferenceEquals(current, sent)) { + var json = JsonSerializer.Serialize(current, AccountsSnapshotJsonContext.Default.AccountsSnapshotDto); + await FrameCodec.WriteAsync(stream, FrameCodec.AccountsSnapshot(json), cts.Token); + sent = current; + } + + await publisher.WaitBeyondAsync(seen, cts.Token); + } + } catch (OperationCanceledException) { + // subscriber EOF or daemon shutdown + } catch (IOException) { + // the subscriber disconnected mid-write + } catch (SocketException) { + // the same, signalled by the transport + } finally { + Interlocked.Decrement(ref _subscribers); + } + } +} diff --git a/src/Capacitor.Cli.Daemon/Services/AccountsPublisher.cs b/src/Capacitor.Cli.Daemon/Services/AccountsPublisher.cs new file mode 100644 index 0000000000..7e46149cef --- /dev/null +++ b/src/Capacitor.Cli.Daemon/Services/AccountsPublisher.cs @@ -0,0 +1,226 @@ +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Capacitor.Cli.Daemon.Services; + +/// +/// The host's accounts snapshot, kept current by polling accounts.json, which the CLI and the +/// desktop app write from other processes. A new snapshot is published only for a new revision or +/// host id, so a recording-state change reaches readers with the next registry revision. +/// +internal sealed partial class AccountsPublisher( + DaemonConfig config, UserHome home, TimeProvider time, ILogger logger) + : BackgroundService { + static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); + static readonly TimeSpan ServerSendInterval = TimeSpan.FromSeconds(30); + + readonly SemaphoreSlim _gate = new(1, 1); + readonly DaemonStatusNotifier _notifier = new(); + + AccountsSnapshotDto? _current; + PeriodicTimer? _timer; + string? _hostId; + (long Length, long Ticks)? _stat; + bool _settled; + bool _loadWarned; + bool _hostWarned; + bool _pollWarned; + + readonly Lock _sendLock = new(); + ITimer? _trailingSend; + long? _lastSentAt; + bool _trailingDue; + bool _sendStopped; + + public AccountsSnapshotDto? Current => Volatile.Read(ref _current); + + /// Raised under the rebuild gate so handlers see revisions in order. A handler must not + /// throw, block, or wait on ; one that throws is logged and the rest still + /// run. Every new snapshot is announced, the one start builds included; a poll that finds the + /// revision unchanged announces nothing. + public event Action? Changed; + + // Seam: tests substitute a failing read. + internal Func LoadRegistry { get; set; } = store => store.Load(); + + /// Takes a new snapshot to the server, at most once per : a change + /// inside the interval waits for its end and sends whatever snapshot is current then. + public Func? SendToServer { get; set; } + + public long Version => _notifier.Version; + + public Task WaitBeyondAsync(long seen, CancellationToken ct) => _notifier.WaitBeyondAsync(seen, ct); + + /// Reads the registry now, whether or not its file looks changed. + public Task RebuildAsync(CancellationToken ct) => PollAsync(force: true, ct); + + public override async Task StartAsync(CancellationToken cancellationToken) { + if (config.Accounts is not null) { + await PollAsync(force: true, cancellationToken); + _timer = new PeriodicTimer(PollInterval, time); + } + + await base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + if (_timer is not { } timer) return; + + using (timer) { + try { + while (await timer.WaitForNextTickAsync(stoppingToken)) { + // The host stops the daemon when this method throws. + try { + await PollAsync(force: false, stoppingToken); + _pollWarned = false; + } catch (Exception ex) when (ex is not OperationCanceledException) { + if (!_pollWarned) LogPollFailed(logger, ex); + _pollWarned = true; + } + } + } catch (OperationCanceledException) { /* shutdown */ } + } + } + + async Task PollAsync(bool force, CancellationToken ct) { + if (config.Accounts is not { } store) return; + + await _gate.WaitAsync(ct); + try { + if ((_hostId ??= TryHostId(store)) is not { } hostId) return; + + var stat = Stat(store.RegistryPath); + var unchanged = stat == _stat; + if (unchanged && _settled && !force) return; + + // Two same-length writes inside one filesystem timestamp tick stat alike, so a stat is + // trusted only once a later tick has read the file under it successfully. + _settled = unchanged && !force; + if (!unchanged) _loadWarned = false; + _stat = stat; + + var read = false; + try { + read = Publish(store, hostId); + } finally { + if (!read) _settled = false; + } + } finally { + _gate.Release(); + } + } + + /// False when the registry could not be read. + bool Publish(AccountStore store, string hostId) { + AccountRegistry registry; + try { + registry = LoadRegistry(store); + } catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) { + if (!_loadWarned) LogRegistryUnreadable(logger, store.RegistryPath, ex); + _loadWarned = true; + return false; + } + + if (Current is { } current && current.Revision == registry.Revision && current.HostId == hostId) return true; + + var next = AccountSnapshotBuilder.Build(registry, hostId, config.DefaultDirectories, home); + Volatile.Write(ref _current, next); + _notifier.Pulse(); + Announce(next); + QueueServerSend(next); + return true; + } + + void Announce(AccountsSnapshotDto snapshot) { + if (Changed is not { } changed) return; + + foreach (var handler in changed.GetInvocationList()) { + try { + ((Action) handler)(snapshot); + } catch (Exception ex) { + LogHandlerFailed(logger, ex); + } + } + } + + // Runs under the rebuild gate, so it never waits on the send. + void QueueServerSend(AccountsSnapshotDto snapshot) { + lock (_sendLock) { + if (_sendStopped || SendToServer is null || _trailingDue) return; + + var wait = _lastSentAt is { } last ? ServerSendInterval - time.GetElapsedTime(last) : TimeSpan.Zero; + if (wait > TimeSpan.Zero) { + _trailingDue = true; + if (_trailingSend is { } timer) timer.Change(wait, Timeout.InfiniteTimeSpan); + else _trailingSend = time.CreateTimer(_ => SendTrailing(), null, wait, Timeout.InfiniteTimeSpan); + return; + } + + _lastSentAt = time.GetTimestamp(); + } + + _ = SendAsync(snapshot); + } + + void SendTrailing() { + lock (_sendLock) { + if (_sendStopped) return; + _trailingDue = false; + _lastSentAt = time.GetTimestamp(); + } + + if (Current is { } snapshot) _ = SendAsync(snapshot); + } + + async Task SendAsync(AccountsSnapshotDto snapshot) { + if (SendToServer is not { } send) return; + + try { + await send(snapshot); + } catch (Exception ex) { + LogServerSendFailed(logger, ex); + } + } + + public override void Dispose() { + lock (_sendLock) { + _sendStopped = true; + _trailingSend?.Dispose(); + } + + base.Dispose(); + } + + // Any failure here leaves the publisher waiting for a host id rather than stopping the daemon. + string? TryHostId(AccountStore store) { + try { + return store.HostId(); + } catch (Exception ex) when (ex is not OperationCanceledException) { + if (!_hostWarned) LogHostIdUnavailable(logger, ex); + _hostWarned = true; + return null; + } + } + + static (long Length, long Ticks)? Stat(string path) { + var info = new FileInfo(path); + return info.Exists ? (info.Length, info.LastWriteTimeUtc.Ticks) : null; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "{Path} could not be read; keeping the last accounts snapshot")] + static partial void LogRegistryUnreadable(ILogger logger, string path, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Polling the accounts registry failed; retrying on the next tick")] + static partial void LogPollFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "The host id could not be read; accounts are not published until it can")] + static partial void LogHostIdUnavailable(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "An accounts snapshot subscriber threw; the others were still notified")] + static partial void LogHandlerFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Sending the accounts snapshot to the server failed; the next connect carries it")] + static partial void LogServerSendFailed(ILogger logger, Exception ex); +} diff --git a/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs b/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs index 80fcf3a52b..6535b65996 100644 --- a/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs +++ b/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.LocalIpc.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Text; using System.Text.Json; using Capacitor.Cli.Core; @@ -165,7 +166,8 @@ [.. _agents.Values UsageLimit: a.Status == "Running" ? a.UsageLimit : null, TerminalDialog: a.Status == "Running" ? a.TerminalDialog : null, WorkingSince: a.Status == "Running" && a.ActivityClock.WorkingFor is { } working - ? (_time.GetUtcNow() - working).UtcDateTime : null))]; + ? (_time.GetUtcNow() - working).UtcDateTime : null, + AccountDirectory: a.Account?.Directory, AccountId: a.Account?.AccountId, AccountLabel: a.Account?.Label))]; /// The checkout is resolved as the live agent's is, so a row keeps its rail group across the handoff. static AgentStatusDto LedgerStatusRow(ResumeLedgerEntry entry, string status) { @@ -182,7 +184,8 @@ static AgentStatusDto LedgerStatusRow(ResumeLedgerEntry entry, string status) { SessionId: entry.SessionId, Branch: string.IsNullOrWhiteSpace(worktree.Branch) ? null : worktree.Branch, TranscriptFormat: entry.TranscriptFormat, - Interruption: entry.State == ResumeState.Interrupted ? entry.LastError : null); + Interruption: entry.State == ResumeState.Interrupted ? entry.LastError : null, + AccountDirectory: entry.Launch.AccountDirectory, AccountId: entry.Launch.AccountId, AccountLabel: entry.Launch.AccountLabel); } /// @@ -484,7 +487,7 @@ public async Task HandleLocalSpawnAsync(LocalFrame spawn, Stream stream, Cancell } async Task HandleAdmittedLocalSpawnAsync(LocalFrame spawn, Stream stream, CancellationToken ct) { - var (vendor, work, isPrivate, cwd, args, cols, rows, startTitle) = FrameCodec.Spawn(spawn); + var (vendor, work, isPrivate, cwd, args, cols, rows, startTitle, accountId) = FrameCodec.Spawn(spawn); if (!_launchers.TryGetValue(vendor, out var launcher)) { await FrameCodec.WriteAsync(stream, LocalFrame.Error($"Unknown vendor: {vendor}"), ct); @@ -496,6 +499,13 @@ async Task HandleAdmittedLocalSpawnAsync(LocalFrame spawn, Stream stream, Cancel return; } + var resolution = ResolveAccount(vendor, accountId); + + if (resolution.Refusal is { } refusal) { + await FrameCodec.WriteAsync(stream, LocalFrame.Error(refusal), ct); + return; + } + var agentId = Guid.NewGuid().ToString("N"); AgentInstance agent; DateTime spawnedAtUtc; @@ -508,7 +518,8 @@ async Task HandleAdmittedLocalSpawnAsync(LocalFrame spawn, Stream stream, Cancel var ctx = new LauncherContext( agentId, cwd, worktree, Prompt: null, Model: "", Effort: null, - Tools: null, IsReview: false, IsReviewFlow: false, Review: null, ReviewLaunch: null + Tools: null, IsReview: false, IsReviewFlow: false, Review: null, ReviewLaunch: null, + Account: resolution.Account ) { Work = work }; @@ -536,13 +547,17 @@ async Task HandleAdmittedLocalSpawnAsync(LocalFrame spawn, Stream stream, Cancel if (_permissionBridge.BaseUrl is { } bridgeUrl) env[HostedAgent.BridgeUrlVar] = bridgeUrl; } + var removeEnv = resolution.Account is { } account + ? VendorHomeEnv.Apply(env, vendor, account, _config.Home) + : FrozenSet.Empty; + spawnedAtUtc = _time.GetUtcNow().UtcDateTime; - var pty = _ptyFactory.Spawn(launcher.CliPath, built.Args, worktree.Path, env, cols, rows); + var pty = _ptyFactory.Spawn(launcher.CliPath, built.Args, worktree.Path, env, cols, rows, removeEnv); var runtime = new PtyHostedAgentRuntime(vendor, pty, _time, logger: _logger); var startedAtUtc = _time.GetUtcNow().UtcDateTime; - agent = new AgentInstance(agentId, null, "", null, cwd, vendor, runtime, worktree, new CancellationTokenSource()) { + agent = new AgentInstance(agentId, null, "", null, cwd, vendor, runtime, worktree, new CancellationTokenSource(), resolution.Account) { CreatedAt = startedAtUtc, LastOutputAt = startedAtUtc, // Every launch path must go through CreateActivityClock() so the stage-advance report // wiring is attached by construction. Inert here today (a local spawn is PTY-only and diff --git a/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.Resume.cs b/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.Resume.cs index 47cb32e454..c294c156f1 100644 --- a/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.Resume.cs +++ b/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.Resume.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text.Json; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Continuation; using Capacitor.Cli.Core.LocalIpc; using Capacitor.Cli.Core.Resume; @@ -316,7 +317,10 @@ internal ResumeLedgerEntry BuildLedgerEntry( WorktreeIsStandalone: agent.Worktree.IsStandalone, WorktreeSourceRepo: agent.Worktree.SourceRepo, PermissionMode: cmd?.PermissionMode ?? prior?.Launch.PermissionMode, - InactivityBoundSeconds: agent.InactivityBoundSeconds); + InactivityBoundSeconds: agent.InactivityBoundSeconds, + AccountDirectory: agent.Account?.Directory, + AccountId: agent.Account?.AccountId, + AccountLabel: agent.Account?.Label); return NewRunningEntry(agent.Id, agent.Vendor, agent.RuntimeTransport, agent.SessionId, agent.VendorSession, launch, prior) with { HasTerminal = agent.Runtime.EmitsTerminalOutput, TranscriptFormat = TranscriptFormatOf(agent), @@ -328,7 +332,7 @@ internal ResumeLedgerEntry BuildLedgerEntry( /// the resolved worktree; no exists yet. ResumeLedgerEntry BuildPreStartLedgerEntry( LaunchAgentCommand cmd, string? prompt, string? model, string? effort, WorktreeInfo worktree, - WorkLocation work, LaunchConsentOutcome consent, string? pinnedSessionId) { + WorkLocation work, LaunchConsentOutcome consent, string? pinnedSessionId, ResolvedAccount? account) { var launch = new ResumeLaunchDescriptor( Prompt: prompt, Model: model, @@ -354,7 +358,10 @@ ResumeLedgerEntry BuildPreStartLedgerEntry( WorktreeIsStandalone: worktree.IsStandalone, WorktreeSourceRepo: worktree.SourceRepo, PermissionMode: cmd.PermissionMode, - InactivityBoundSeconds: cmd.InactivityBoundSeconds); + InactivityBoundSeconds: cmd.InactivityBoundSeconds, + AccountDirectory: account?.Directory, + AccountId: account?.AccountId, + AccountLabel: account?.Label); return NewRunningEntry(cmd.AgentId, cmd.Vendor, PreStartTransport, pinnedSessionId, pinnedSessionId, launch, prior: null); } @@ -401,13 +408,13 @@ internal void WriteLedger(AgentInstance agent, LaunchAgentCommand? cmd, string o void WritePreStartLedger( LaunchAgentCommand cmd, string? prompt, string? model, string? effort, WorktreeInfo worktree, - WorkLocation work, LaunchConsentOutcome consent, string? pinnedSessionId) { + WorkLocation work, LaunchConsentOutcome consent, string? pinnedSessionId, ResolvedAccount? account) { if (ResumeLedger is null || cmd.Kind != LaunchKind.Default) return; lock (_ledgerLock) { // A live agent under this id owns the entry; a colliding launch must not overwrite it. if (_agents.ContainsKey(cmd.AgentId)) return; - TryWriteLedger(cmd.AgentId, () => BuildPreStartLedgerEntry(cmd, prompt, model, effort, worktree, work, consent, pinnedSessionId)); + TryWriteLedger(cmd.AgentId, () => BuildPreStartLedgerEntry(cmd, prompt, model, effort, worktree, work, consent, pinnedSessionId, account)); } } @@ -477,6 +484,9 @@ internal sealed record ResumeLaunch(ResumeLedgerEntry Entry, WorktreeInfo Worktr /// The server's reason, empty when it gave none, once it refused the registration. public string? RefusedRegistration { get; set; } + /// Why the account the entry recorded can no longer be used, once the launch refused it. + public string? AccountRefusal { get; set; } + public WorkLocation Work => Entry.Launch.Work; public bool IsPrivate => Entry.Launch.IsPrivate; public string Origin => Entry.Launch.Origin; @@ -837,14 +847,23 @@ internal async Task ResumeEntryAsync(ResumeLedgerEntry entry, Can if (RecordFailedAttempt(entry, LastErrorOf(reason, exitCode)) is not { } failed) return ResumeOutcome.Stopped; + entry = failed; + } else if (ResolveAccount(entry.Vendor, accountId: null, entry.Launch).Refusal is { } refusal) { + reason = refusal; + + if (RecordFailedAttempt(entry, reason) is not { } failed) return ResumeOutcome.Stopped; + entry = failed; } else { reason = NativeResumeUnsupported; } - var outcome = await ContinueEntry(entry, reason, ct); - if (outcome != ResumeOutcome.Interrupted) return outcome; - if (IsWithdrawn(entry.AgentId)) return ResumeOutcome.Stopped; + // A continuation resolves the same recorded account, so it would be refused too. + if (!reason.StartsWith(AccountResolution.Unavailable, StringComparison.Ordinal)) { + var outcome = await ContinueEntry(entry, reason, ct); + if (outcome != ResumeOutcome.Interrupted) return outcome; + if (IsWithdrawn(entry.AgentId)) return ResumeOutcome.Stopped; + } InterruptEntry(entry.AgentId, LastErrorOf(reason, exitCode)); if (!entry.Launch.IsPrivate && reason != RegistrationRefusedReason) await ReportResumeFailedAsync(entry, reason, exitCode, ct); @@ -875,6 +894,8 @@ static string LastErrorOf(string reason, int? exitCode) => if (resume.Agent is null && IsWithdrawn(entry.AgentId)) return (StoppedDuringResume, null); + if (resume.AccountRefusal is { } refusal) return (refusal, null); + // The server holds no live entry under this id, so the resume goes on under a new one, unless a stop // came first: it gave up the worktree, and the teardown has removed it. if (resume.RefusedRegistration is not null) @@ -1112,7 +1133,8 @@ static LaunchAgentCommand BuildResumeCommand(ResumeLedgerEntry entry, DateTimeOf ResumeSessionId: entry.VendorSession, InactivityBoundSeconds: launch.InactivityBoundSeconds, PermissionMode: launch.PermissionMode, - Title: launch.Title); + Title: launch.Title, + AccountId: launch.AccountId); } /// The ledger's worktree as the launch path names it. diff --git a/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs b/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs index a9967a8946..2d221d7e9f 100644 --- a/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs +++ b/src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs @@ -6,6 +6,7 @@ using Capacitor.Cli.Daemon.Pty; using Capacitor.Cli.Daemon.Acp; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.LocalIpc; using Capacitor.Cli.Core.Auth; using Capacitor.Cli.Core.Config; @@ -60,7 +61,8 @@ internal record AgentInstance( string Vendor, IHostedAgentRuntime Runtime, WorktreeInfo Worktree, - CancellationTokenSource ReadCts + CancellationTokenSource ReadCts, + ResolvedAccount? Account = null ) { public string? SessionId { get; set; } @@ -829,8 +831,11 @@ public AgentOrchestrator( PolicySnapshotProvider? policySnapshots = null, // Null outside DaemonRunner: a private fence over this daemon's own marker path. DaemonRunner // passes the singleton the control socket fences. - AdmissionFence? admissionFence = null + AdmissionFence? admissionFence = null, + // Null outside DaemonRunner: no accounts, so no catalog is probed per account. + AccountsPublisher? accounts = null ) { + AccountsSnapshot = accounts is null ? static () => null : () => accounts.Current; _admission = admissionFence ?? new AdmissionFence( config.Store.RetiringMarkerPath(config.Name), config.InstanceId, time, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); @@ -1599,18 +1604,38 @@ internal void RefreshAdvertisedCapabilities(string reason, bool republishUnchang var fresh = DaemonRunner.RetainAdvertisedVersions(current, DaemonRunner.ComputeUnattendedVendorCapabilities(_runtimeFactories.Values, _config, _config.UnattendedVendors)); - var previousCatalog = _config.VendorModels; - var supported = _config.SupportedVendors ?? []; - var catalogVendors = !probeCatalog ? [] : _runtimeFactories.Values + var previousCatalog = _config.VendorModels; + var previousByAccount = _config.VendorModelsByAccount; + var supported = _config.SupportedVendors ?? []; + var catalogVendors = !probeCatalog ? [] : _runtimeFactories.Values .Where(f => supported.Contains(f.Vendor, StringComparer.Ordinal)) .Where(f => f.CatalogFingerprintPaths.Count > 0 || (previousCatalog?.ContainsKey(f.Vendor) ?? false)) .Select(f => f.Vendor) .ToArray(); - var mergedCatalog = catalogVendors.Length == 0 + var accountFactory = probeCatalog ? VendorModelCatalogs.AccountFactory(_runtimeFactories.Values, supported) : null; + var accounts = accountFactory is null ? [] : VendorModelCatalogs.AccountsIn(AccountsSnapshot()); + // Fingerprinted before the probe, as at startup: the watcher starts watching an added + // account from this, so a write between the probe and that start still reads as a change. + if (accountFactory is not null) + _config.VendorCatalogBaselines = VendorModelCatalogs.WithAccountBaselines(_config.VendorCatalogBaselines, accountFactory, accounts); + + var vendorProbe = catalogVendors.Length == 0 + ? null + : VendorModelCatalogs.ProbeAsync(_runtimeFactories.Values, catalogVendors, _shutdownCts.Token); + var accountProbe = accounts.Count == 0 + ? null + : VendorModelCatalogs.ProbeByAccountAsync(accountFactory!, accounts, _shutdownCts.Token); + await Task.WhenAll(vendorProbe ?? Task.CompletedTask, accountProbe ?? Task.CompletedTask); + + var mergedCatalog = vendorProbe is null ? previousCatalog - : VendorModelCatalogs.Merge(previousCatalog, - await VendorModelCatalogs.ProbeAsync(_runtimeFactories.Values, catalogVendors, _shutdownCts.Token)); - var catalogChanged = !VendorModelCatalogs.Equal(previousCatalog, mergedCatalog); + : VendorModelCatalogs.Merge(previousCatalog, await vendorProbe); + // Read after the probes: an account removed while its probe ran is not advertised. + var live = VendorModelCatalogs.AccountsIn(AccountsSnapshot()).Select(a => a.AccountId).ToHashSet(StringComparer.Ordinal); + var mergedByAccount = VendorModelCatalogs.MergeByAccount( + previousByAccount, accountProbe is null ? new Dictionary() : await accountProbe, live); + var catalogChanged = !VendorModelCatalogs.Equal(previousCatalog, mergedCatalog) + || !VendorModelCatalogs.Equal(previousByAccount, mergedByAccount); if (!republish && !catalogChanged && current is not null && current.SequenceEqual(fresh)) return; @@ -1618,7 +1643,8 @@ internal void RefreshAdvertisedCapabilities(string reason, bool republishUnchang // One reference swap: status serializers on other threads may be enumerating the old one. // Pulsed before the re-register so a failed one cannot keep the change from local subscribers. if (catalogChanged) { - _config.VendorModels = mergedCatalog; + _config.VendorModels = mergedCatalog; + _config.VendorModelsByAccount = mergedByAccount; _statusNotifier.Pulse(); } LogReAdvertising(_logger, reason, @@ -1632,6 +1658,9 @@ internal void RefreshAdvertisedCapabilities(string reason, bool republishUnchang /// Test seam: the most recently scheduled capability refresh, for awaiting quiescence. internal Task CapabilityRefreshForTest => _capabilityRefresh.Current; + /// The host's accounts as last published; the per-account catalogs follow it. + internal Func AccountsSnapshot { get; set; } + /// /// Re-sends the registration on the current connection so the server's copy of a connect /// field that changed at runtime (capacity) is overwritten. Rides the capability refresh so a @@ -2042,7 +2071,10 @@ internal IReadOnlyList BuildLiveAgents() { ActivitySeq: a.ActivityClock.ActivitySeq, IdleForMs: a.ActivityClock.IdleForMs, TurnInFlight: a.ActivityClock.TurnInFlight, - LaunchStage: a.Status == "Starting" ? a.ActivityClock.LaunchStage : null)) + LaunchStage: a.Status == "Starting" ? a.ActivityClock.LaunchStage : null, + AccountDirectory: a.Account?.Directory, + AccountId: a.Account?.AccountId, + AccountLabel: a.Account?.Label)) .ToList(); foreach (var p in _pendingLaunches.Values) { @@ -2055,7 +2087,10 @@ internal IReadOnlyList BuildLiveAgents() { TurnInFlight: p.ActivityClock.TurnInFlight, // A pending launch IS "Starting" by definition — there is no other status it can hold — // so the Status gate above is satisfied unconditionally here. - LaunchStage: p.ActivityClock.LaunchStage)); + LaunchStage: p.ActivityClock.LaunchStage, + AccountDirectory: p.Account?.Directory, + AccountId: p.Account?.AccountId, + AccountLabel: p.Account?.Label)); } return live; @@ -2074,7 +2109,8 @@ internal sealed record PendingLaunch( AgentActivityClock ActivityClock, string? Vendor, string? RepoPath, - string? Title); + string? Title, + ResolvedAccount? Account); readonly ConcurrentDictionary _pendingLaunches = new(StringComparer.Ordinal); @@ -2163,10 +2199,11 @@ CancellableLaunch[] CancellableLaunchesFor(string agentId) { /// _agents. internal IDisposable TrackPendingLaunch( string agentId, LaunchKind kind, string? flowRunId, string? flowRole, AgentActivityClock clock, - string? vendor = null, string? repoPath = null, string? title = null, DateTime? createdAt = null) { + string? vendor = null, string? repoPath = null, string? title = null, DateTime? createdAt = null, + ResolvedAccount? account = null) { lock (_membership) _pendingLaunches[agentId] = new PendingLaunch( - agentId, kind, createdAt ?? _time.GetUtcNow().UtcDateTime, flowRunId, flowRole, clock, vendor, repoPath, title); + agentId, kind, createdAt ?? _time.GetUtcNow().UtcDateTime, flowRunId, flowRole, clock, vendor, repoPath, title, account); _statusNotifier.Pulse(); return new PendingLaunchScope(this, agentId); @@ -2370,6 +2407,27 @@ async Task HandleLaunchAgentCore(LaunchAgentCommand cmd, Cancell return new CommandOutcome(CommandOutcomeKind.LaunchRejected, cmd.AgentId, RejectReason: CommandRejectedReason.Semantic); } + /// The account a launch runs under. A vendor without a default directory has no accounts: + /// a launch without an id carries none, and one naming an id is refused. + internal AccountResolutionOutcome ResolveAccount(string vendorToken, string? accountId) => + ResolveAccount(vendorToken, accountId, resumed: null); + + /// A resume resolves the account its ledger entry recorded, and + /// ignores . + AccountResolutionOutcome ResolveAccount(string vendorToken, string? accountId, ResumeLaunchDescriptor? resumed) { + if (HarnessId.From(vendorToken) is not { } vendor || !_config.DefaultDirectories.TryGetValue(vendor, out var defaultDirectory)) { + return resumed is null && !string.IsNullOrEmpty(accountId) + ? new AccountResolutionOutcome(null, $"{AccountResolution.Unavailable}: {vendorToken} has no accounts") + : new AccountResolutionOutcome(null, null); + } + + var registry = _config.Accounts?.TryLoad(); + + return resumed is null + ? AccountResolution.Resolve(registry, vendor, accountId, defaultDirectory) + : AccountResolution.ResolveForResume(registry, vendor, resumed.AccountId, resumed.AccountDirectory, defaultDirectory); + } + /// The server's start title, or — from a server that sends none — one derived from the prompt. internal static AgentStartTitle? StartTitleOf(LaunchAgentCommand cmd) => !string.IsNullOrWhiteSpace(cmd.Title) @@ -2378,8 +2436,8 @@ async Task HandleLaunchAgentCore(LaunchAgentCommand cmd, Cancell /// The launch body. Its outcome drives the sequenced lane: a pre-flight rejection is /// LaunchRejected (capacity as daemon_capacity, every other validation as - /// semantic) alongside its LaunchFailed; a spawn or registration failure that was cleaned up is - /// launch_failed_cleaned; a registered agent is launch_executed. + /// semantic) alongside its LaunchFailed; an account refusal, or a spawn or registration failure + /// that was cleaned up, is launch_failed_cleaned; a registered agent is launch_executed. async Task HandleAdmittedLaunchAsync( LaunchAgentCommand cmd, LaunchHold hold, CancellationToken cancel = default, Action? onPublished = null) { var agentId = cmd.AgentId; @@ -2551,8 +2609,9 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, cmd.AgentId, } } - WorktreeInfo? worktree = null; - string? mcpConfigPath = null; + WorktreeInfo? worktree = null; + string? mcpConfigPath = null; + ResolvedAccount? account = null; // Declared OUTSIDE the try so it is in scope in the catch blocks below: the failed-launch // cleanup must consult it to decide whether the worktree is ours to remove. A borrowed cwd @@ -2656,6 +2715,21 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, cmd.AgentId, return new CommandOutcome(CommandOutcomeKind.LaunchRejected, agentId, RejectReason: CommandRejectedReason.Semantic); } + // A reviewer runs on the daemon's default account, where its MCP inventory and hooks are read. + var resolution = cmd.Kind != LaunchKind.Default && !string.IsNullOrEmpty(cmd.AccountId) + ? new AccountResolutionOutcome(null, $"{AccountResolution.Unavailable}: a {(isReview ? "review" : "review-flow")} launch runs on the default account") + : ResolveAccount(cmd.Vendor, cmd.AccountId, resume?.Entry.Launch); + + // A failure, not a rejection: the refusal reaches the caller by name, never as a generic rejection. + if (resolution.Refusal is { } refusal) { + if (resume is not null) resume.AccountRefusal = refusal; + await LaunchFailedUnlessPrivateAsync(isPrivate, agentId, refusal); + + return new CommandOutcome(CommandOutcomeKind.LaunchFailedCleaned, agentId); + } + + account = resolution.Account; + LogLaunching(agentId, repoPath, cmd.Vendor, effort ?? "default", effectiveModel); // Review launches base the worktree on the PR head ref so the agent @@ -2900,7 +2974,8 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, agentId, IsPrivate: isPrivate, PinnedSessionId: pinnedSessionId, RequestedTransport: resume?.Entry.Transport, - PersistenceProbe: () => _agents.TryGetValue(agentId, out var bound) ? bound.AcpForwarder?.Forwarder : null + PersistenceProbe: () => _agents.TryGetValue(agentId, out var bound) ? bound.AcpForwarder?.Forwarder : null, + Account: account ); HostedRuntimeStart start; @@ -2910,14 +2985,14 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, agentId, // written by THIS agent's process, not the user's earlier sessions. var spawnedAtUtc = _time.GetUtcNow().UtcDateTime; - if (resume is null) WritePreStartLedger(cmd, prompt, effectiveModel, effort, worktree, work, consent, pinnedSessionId); + if (resume is null) WritePreStartLedger(cmd, prompt, effectiveModel, effort, worktree, work, consent, pinnedSessionId, account); // Make this launch describable for the duration of the handshake below: the AgentInstance // does not exist until StartAsync returns, so without this the out-of-cycle report each // SetLaunchStage fires would omit the very agent it is reporting a stage for. using var pendingLaunch = isPrivate ? null : TrackPendingLaunch( agentId, cmd.Kind, cmd.FlowRunId, cmd.FlowRole, activityClock, - vendor: cmd.Vendor, repoPath: repoPath, title: startTitle?.Text ?? AgentStartTitle.Shorten(prompt)); + vendor: cmd.Vendor, repoPath: repoPath, title: startTitle?.Text ?? AgentStartTitle.Shorten(prompt), account: account); try { // A resume's start also ends with its attempt, so a stop during the handshake does not wait for the vendor. @@ -3010,7 +3085,7 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, agentId, // server and the rail report. var startedAtUtc = _time.GetUtcNow().UtcDateTime; - var agent = new AgentInstance(agentId, prompt, registeredModel, effort, repoPath, cmd.Vendor, runtime, worktree, cts) { + var agent = new AgentInstance(agentId, prompt, registeredModel, effort, repoPath, cmd.Vendor, runtime, worktree, cts, account) { CreatedAt = startedAtUtc, LastOutputAt = startedAtUtc, // Set in the initializer, not after: PublishAgent below is the first snapshot anyone // reads, and RegisterAgentAsync after it can stall on a server outage. @@ -3093,7 +3168,7 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, agentId, if (!agent.IsServerSilent) runtime.OnCommandsAvailable = commands => ReportCommands(agentId, commands); if (!agent.IsServerSilent && string.Equals(cmd.Vendor, "claude", StringComparison.OrdinalIgnoreCase) && cmd.Kind == LaunchKind.Default) - _ = ProbeClaudeCommandsAsync(agentId, worktree.Path); + _ = ProbeClaudeCommandsAsync(agentId, worktree.Path, agent.Account); // A runtime with no terminal output (ACP/cursor) has no output-chunk signal to flip // Starting→Running on — ReadAgentOutputAsync's read loop never yields a byte for such @@ -3196,17 +3271,13 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, agentId, // Report the resolved model so the server can display / validate the real model the agent // is running. Best-effort: never let a report failure break the launch. if (!agent.IsServerSilent && cmd.ExplicitReviewerModel is { } explicitReviewerModel) { - // Task 8: a protocol-v3 explicit-model reviewer launch reports the CONCRETE - // resolved model via the dedicated ReportExplicitReviewerModelResolved channel, keyed by - // the durable LaunchAttemptId, so the server's one-shot waiter can validate + re-price it. + // An explicit-model reviewer reports the concrete model on its own channel, keyed by the + // LaunchAttemptId, so the server's one-shot waiter can validate and re-price it. ReportExplicitReviewerModel(agentId, cmd.Vendor, explicitReviewerModel, runtimeFactory); } else if (!agent.IsServerSilent && string.Equals(cmd.Vendor, "codex", StringComparison.OrdinalIgnoreCase)) { - // Legacy path (name/arity/behavior UNCHANGED): the dispatched `model` may be the "default" - // no-override sentinel, in which case Codex resolves the model from ~/.codex/config.toml. - // Codex-only — Claude/other agents never call the ReportAgentResolvedModel hub. // updateLocal only on the PTY path: an app-server Codex (Transcript present) already // registered its confirmed handshake model, which must not be overwritten locally. - ReportResolvedModel(agentId, cmd.Vendor, model, updateLocal: start.Transcript is null); + ReportResolvedModel(agentId, cmd.Vendor, model, agent.Account, updateLocal: start.Transcript is null); } // Phase B2-b (sequenced-settlement design §4.2.2): the launch executed — the agent is registered. @@ -3259,7 +3330,8 @@ await LaunchFailedUnlessPrivateAsync(isPrivate, agentId, cmd.Vendor, new PtyHostedAgentRuntime(cmd.Vendor, NoopPtyProcess.Instance, _time), worktree, - new CancellationTokenSource() + new CancellationTokenSource(), + account ) { CreatedAt = _time.GetUtcNow().UtcDateTime, LastOutputAt = _time.GetUtcNow().UtcDateTime, @@ -3331,15 +3403,15 @@ async Task DiscardFailedLaunchJournalAsync(TranscriptJournal? journal, string ag /// (ReportAgentResolvedModel) and the config resolution are Codex-specific, so the caller /// gates this on vendor == "codex". The dispatched may be /// the "default" no-override sentinel (or empty), in which case Codex resolves the model from - /// ~/.codex/config.toml — we resolve the same value here. Never throws: a resolve/report + /// the launch's config.toml — we resolve the same value here. Never throws: a resolve/report /// failure must not break launch. /// - void ReportResolvedModel(string agentId, string vendor, string model, bool updateLocal) { + void ReportResolvedModel(string agentId, string vendor, string model, ResolvedAccount? account, bool updateLocal) { try { var isDefault = string.IsNullOrEmpty(model) || string.Equals(model, "default", StringComparison.OrdinalIgnoreCase); var resolved = isDefault && string.Equals(vendor, "codex", StringComparison.OrdinalIgnoreCase) - ? CodexResolvedModel(model) + ? CodexResolvedModel(model, account) : model; if (string.IsNullOrEmpty(resolved)) return; @@ -3378,10 +3450,10 @@ void ReportCommands(string agentId, IReadOnlyList commands) } } - async Task ProbeClaudeCommandsAsync(string agentId, string worktreePath) { + async Task ProbeClaudeCommandsAsync(string agentId, string worktreePath, ResolvedAccount? account) { try { var commands = await Harness.Claude.ClaudeCommandProbe.ProbeAsync( - _harnesses, worktreePath, _time, TimeSpan.FromSeconds(30), LogClaudeCommandProbe, _shutdownCts.Token); + _harnesses, worktreePath, account, _config.Home, _time, TimeSpan.FromSeconds(30), LogClaudeCommandProbe, _shutdownCts.Token); if (commands.Count > 0) ReportCommands(agentId, commands); } catch (Exception ex) { @@ -3417,16 +3489,21 @@ void ReportExplicitReviewerModel( } /// - /// Reads the top-level model = "…" from ~/.codex/config.toml (honouring - /// CODEX_HOME via ); falls back to - /// when the file is missing/unreadable or has no top-level model key. + /// Reads the top-level model = "…" from the launch layout's config.toml; falls back + /// to when the file is missing/unreadable or has no top-level model key. /// - string CodexResolvedModel(string fallback) { - var fromConfig = CodexConfigToml.ReadTopLevelModel(_harnesses.Of().Paths.ConfigToml); + string CodexResolvedModel(string fallback, ResolvedAccount? account) { + var fromConfig = CodexConfigToml.ReadTopLevelModel(CodexPathsFor(account).ConfigToml); return string.IsNullOrWhiteSpace(fromConfig) ? fallback : fromConfig; } + ClaudePaths ClaudePathsFor(ResolvedAccount? account) => + account is null ? _harnesses.Of().Paths : AccountLayouts.Claude(_config.Home, account.Directory); + + CodexPaths CodexPathsFor(ResolvedAccount? account) => + account is null ? _harnesses.Of().Paths : AccountLayouts.Codex(_config.Home, account.Directory); + static readonly TimeSpan GitGuardTimeout = TimeSpan.FromSeconds(10); /// @@ -5119,7 +5196,8 @@ ReviewerModelResolveResponseV1 HandleResolveReviewerModel(ReviewerModelResolveRe async Task RegisterAgentAsync(AgentInstance agent) { if (agent.IsPrivate) return AgentRegisteredResponse.Accepted; - var registration = await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath, agent.SandboxPolicy, agent.ApprovalPolicy, agent.PermissionPreset, agent.RuntimeTransport, agent.Vendor, agent.StartTitle, agent.ContinuesAgentId); + var registration = await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath, agent.SandboxPolicy, agent.ApprovalPolicy, agent.PermissionPreset, agent.RuntimeTransport, agent.Vendor, agent.StartTitle, agent.ContinuesAgentId, + agent.Account?.Directory, agent.Account?.AccountId, agent.Account?.Label); if (!registration.Registered) { agent.RegistrationRefused = true; @@ -5616,7 +5694,8 @@ internal async Task ReRegisterAgentsAsync() { for (var attempt = 1; ; attempt++) { try { - var registration = await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath, agent.SandboxPolicy, agent.ApprovalPolicy, agent.PermissionPreset, agent.RuntimeTransport, agent.Vendor, agent.StartTitle, agent.ContinuesAgentId); + var registration = await _server.AgentRegisteredAsync(agent.Id, agent.Prompt, agent.Model, agent.Effort, agent.RepoPath, agent.SandboxPolicy, agent.ApprovalPolicy, agent.PermissionPreset, agent.RuntimeTransport, agent.Vendor, agent.StartTitle, agent.ContinuesAgentId, + agent.Account?.Directory, agent.Account?.AccountId, agent.Account?.Label); // A refusal is the server's answer, not a transient failure: it is not retried, and the agent is // left out of later re-registrations too. A status sent for the refused id could clear the @@ -5762,9 +5841,9 @@ internal static string MapLaunchFailureReason(string? reason, string fallbackSou Task DetectSessionIdAsync(AgentInstance agent, string vendor, DateTime spawnedAtUtc) { Func, (string SessionId, string Path)?>? locate = vendor.ToLowerInvariant() switch { "claude" => ruledOut => SessionTranscriptLocator.TryLocateWinner( - _harnesses.Of().Paths.ProjectDir(agent.Worktree.Path), agent.Worktree.Path, spawnedAtUtc, ruledOut), + ClaudePathsFor(agent.Account).ProjectDir(agent.Worktree.Path), agent.Worktree.Path, spawnedAtUtc, ruledOut), "codex" => ruledOut => CodexSessionRolloutLocator.TryLocateWinner( - _harnesses.Of().Paths.Sessions, agent.Worktree.Path, spawnedAtUtc, ruledOut), + CodexPathsFor(agent.Account).Sessions, agent.Worktree.Path, spawnedAtUtc, ruledOut), "muse" => _ => MuseHostedSessionLocator.TryLocate( _config.Home, _harnesses.Of().Paths, agent.Id, DateOnly.FromDateTime(_time.GetLocalNow().DateTime), (agent.Runtime as MuseMspHostedAgentRuntime)?.Session), diff --git a/src/Capacitor.Cli.Daemon/Services/DaemonStatusIpc.cs b/src/Capacitor.Cli.Daemon/Services/DaemonStatusIpc.cs index 99f43a0ede..23dc39be5b 100644 --- a/src/Capacitor.Cli.Daemon/Services/DaemonStatusIpc.cs +++ b/src/Capacitor.Cli.Daemon/Services/DaemonStatusIpc.cs @@ -71,7 +71,8 @@ internal string Snapshot() { ConnectionText(connection.HubState), config.MaxConcurrentAgents, active, Environment.ProcessId, config.InstanceId, config.SupportedVendors, config.VendorModels, Credential: connection.HubState == HubConnectionState.Connected ? null : connection.CredentialHealth.Problem, - Profile: config.ProfileNameOrNull), + Profile: config.ProfileNameOrNull, + VendorModelsByAccount: config.VendorModelsByAccount), agents, orchestrator.SnapshotPendingForStatus(), orchestrator.BusyForStatus()); diff --git a/src/Capacitor.Cli.Daemon/Services/IHostedAgentLauncher.cs b/src/Capacitor.Cli.Daemon/Services/IHostedAgentLauncher.cs index e211fa4b06..a22d978fe5 100644 --- a/src/Capacitor.Cli.Daemon/Services/IHostedAgentLauncher.cs +++ b/src/Capacitor.Cli.Daemon/Services/IHostedAgentLauncher.cs @@ -1,4 +1,5 @@ using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Commands; using Capacitor.Cli.Core.LocalIpc; @@ -111,7 +112,8 @@ internal sealed record LauncherContext( bool IsReview, bool IsReviewFlow, ReviewLaunchInfo? Review, - ReviewLaunchBuilder.ReviewLaunch? ReviewLaunch + ReviewLaunchBuilder.ReviewLaunch? ReviewLaunch, + ResolvedAccount? Account = null ) { /// Owned worktree (daemon-created) vs borrowed cwd (the user's own checkout). /// Borrowed-cwd launches skip repo-mutating Prepare() steps. diff --git a/src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs b/src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs index 2f7eda0435..9ce898a211 100644 --- a/src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs +++ b/src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntimeFactory.cs @@ -1,5 +1,6 @@ using Capacitor.Cli.Core; using Capacitor.Cli.Core.Acp; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Daemon.Acp; using Capacitor.Cli.Core.Commands; using Capacitor.Cli.Core.LocalIpc; @@ -128,9 +129,19 @@ internal interface IHostedAgentRuntimeFactory { Task?> ProbeModelsAsync(CancellationToken ct) => Task.FromResult?>(null); + /// The catalog of the account whose config directory is ; + /// null is the daemon's own default. A vendor whose catalog does not depend on the account answers + /// its one catalog. + Task?> ProbeModelsAsync(string? configDirectory, CancellationToken ct) => + ProbeModelsAsync(ct); + /// Files whose fingerprint change means the catalog may have changed. Statted, never read. IReadOnlyList CatalogFingerprintPaths => []; + /// for the account whose config directory is + /// ; null is the daemon's own default. + IReadOnlyList CatalogFingerprintPathsFor(string? configDirectory) => CatalogFingerprintPaths; + /// Where this runtime's attachments land for a launch of . The /// question is whether the process can be running, write-contained, while a fetch for it happens: /// protected kinds refuse follow-ups, so their only fetch precedes the process and the worktree is @@ -292,5 +303,7 @@ internal sealed record RuntimeStartContext( string? RequestedTransport = null, // Which of the agent's user messages the server has stored, answered by its transcript forwarder once // one is bound. Null for constructions predating this field. - Func? PersistenceProbe = null + Func? PersistenceProbe = null, + // Null for a vendor without accounts. + ResolvedAccount? Account = null ); diff --git a/src/Capacitor.Cli.Daemon/Services/LocalControlCapabilities.cs b/src/Capacitor.Cli.Daemon/Services/LocalControlCapabilities.cs index bd12e14a60..011de803fe 100644 --- a/src/Capacitor.Cli.Daemon/Services/LocalControlCapabilities.cs +++ b/src/Capacitor.Cli.Daemon/Services/LocalControlCapabilities.cs @@ -8,5 +8,5 @@ namespace Capacitor.Cli.Daemon.Services; /// gates on the entry and then sends frames an older daemon would fail to decode. /// internal static class LocalControlCapabilities { - public static readonly IReadOnlyList Current = ["consent/1", "consent/2", "consent/3", "status/1", "permission/1", "input/1", "input/2", "settings/1", "fence/1", "shutdown/1", "resume/1", AgentAttachClient.RuntimeCapability, AgentSessionIpc.Capability]; + public static readonly IReadOnlyList Current = ["consent/1", "consent/2", "consent/3", "status/1", "permission/1", "input/1", "input/2", "settings/1", "fence/1", "shutdown/1", "resume/1", AgentAttachClient.RuntimeCapability, AgentSessionIpc.Capability, "accounts/1"]; } diff --git a/src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs b/src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs index c6b78c2846..bae814d310 100644 --- a/src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs +++ b/src/Capacitor.Cli.Daemon/Services/LocalControlServer.cs @@ -14,7 +14,7 @@ internal sealed partial class LocalControlServer( DaemonConfig config, AgentOrchestrator orchestrator, RestartCoordinator restart, LaunchConsentIpc consentIpc, PermissionIpc permissionIpc, DaemonStatusIpc statusIpc, DaemonSettingsIpc settingsIpc, AdmissionFenceIpc fenceIpc, - DaemonShutdownIpc shutdownIpc, ILogger logger + DaemonShutdownIpc shutdownIpc, AccountsIpc accountsIpc, ILogger logger ) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) { var path = config.Store.SocketPath(config.Name); @@ -85,7 +85,8 @@ await orchestrator.HandleLocalListAsync( case FrameType.Shutdown: await shutdownIpc.HandleAsync(first.Text, stream, ct); break; case FrameType.AgentResume: await HandleAgentResumeAsync(first.Text, stream, ct); break; case FrameType.AgentSession: await orchestrator.HandleLocalAgentSessionAsync(first.Text, stream, ct); break; - default: await FrameCodec.WriteAsync(stream, LocalFrame.Error($"expected Spawn/Attach/AttachWithRuntime/List/Stop/StopV2/Restart/ConsentSubscribe/ConsentResolve/ConsentRulesGet/ConsentRulesPut/ConsentSubscribeV2/ConsentResolveV2/ConsentRulesPutV2/PermissionSubscribe/PermissionResolve/Hello/StatusSubscribe/SendText/SendTextWithAttachments/DaemonSettingsPut/AdmissionFenceAcquire/Shutdown/AgentResume/AgentSession, got {first.Type}"), ct); break; + case FrameType.AccountsSubscribe: await accountsIpc.HandleSubscribeAsync(stream, ct); break; + default: await FrameCodec.WriteAsync(stream, LocalFrame.Error($"expected Spawn/Attach/AttachWithRuntime/List/Stop/StopV2/Restart/ConsentSubscribe/ConsentResolve/ConsentRulesGet/ConsentRulesPut/ConsentSubscribeV2/ConsentResolveV2/ConsentRulesPutV2/PermissionSubscribe/PermissionResolve/Hello/StatusSubscribe/SendText/SendTextWithAttachments/DaemonSettingsPut/AdmissionFenceAcquire/Shutdown/AgentResume/AgentSession/AccountsSubscribe, got {first.Type}"), ct); break; } } catch (Exception ex) when (ex is not OperationCanceledException) { LogConnectionError(ex); diff --git a/src/Capacitor.Cli.Daemon/Services/PtyHostedAgentRuntimeFactory.cs b/src/Capacitor.Cli.Daemon/Services/PtyHostedAgentRuntimeFactory.cs index 43001eec6b..8a0bf4d36f 100644 --- a/src/Capacitor.Cli.Daemon/Services/PtyHostedAgentRuntimeFactory.cs +++ b/src/Capacitor.Cli.Daemon/Services/PtyHostedAgentRuntimeFactory.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using Capacitor.Cli.Core.Commands; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Daemon.Harness.Codex; @@ -18,11 +19,14 @@ namespace Capacitor.Cli.Daemon.Services; /// which maps it to LaunchFailed and worktree cleanup. Any other Prepare failure is /// logged and swallowed so a settings-overlay glitch never blocks a launch. /// +/// The daemon's home, against which an account directory is told apart from the +/// vendor's built-in one. internal sealed partial class PtyHostedAgentRuntimeFactory( IHostedAgentLauncher launcher, IPtyProcessFactory ptyFactory, ILogger logger, - TimeProvider time + TimeProvider time, + UserHome home ) : IHostedAgentRuntimeFactory { public string Vendor => launcher.Vendor; public bool SupportsUnattended => launcher.SupportsUnattended; @@ -63,7 +67,8 @@ public async Task StartAsync(RuntimeStartContext ctx, Cancel // talk to the review MCP tools. ReviewLaunch: ctx.IsReview && ctx.Review is { } reviewArgs ? await ReviewLaunchBuilder.BuildAsync(ctx.Vendor, ctx.CapacitorPath, ctx.ServerUrl ?? "", reviewArgs.Owner, reviewArgs.Repo, reviewArgs.PrNumber) - : null + : null, + Account: ctx.Account ) { McpAllowlist = ctx.McpAllowlist, Work = ctx.Work, @@ -136,11 +141,15 @@ public async Task StartAsync(RuntimeStartContext ctx, Cancel env["KCAP_REVIEW_PR"] = reviewEnv.PrNumber.ToString(); } + var removeEnv = ctx.Account is { } account + ? VendorHomeEnv.Apply(env, ctx.Vendor, account, home) + : FrozenSet.Empty; + // Read before the spawn so a throw cannot orphan the PTY. Gates the multi-CR submit spray; see // PtyHostedAgentRuntime.SubmitAsync. var disablesApprovalPrompts = launcher.DisablesApprovalPrompts(launcherCtx); - var pty = ptyFactory.Spawn(launcher.CliPath, args, ctx.Worktree.Path, env, ctx.Cols, ctx.Rows); + var pty = ptyFactory.Spawn(launcher.CliPath, args, ctx.Worktree.Path, env, ctx.Cols, ctx.Rows, removeEnv); var runtime = new PtyHostedAgentRuntime(ctx.Vendor, pty, time, disablesApprovalPrompts, logger); if (launchPrompt is not null) { diff --git a/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs b/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs index f233952dec..0276a42aa7 100644 --- a/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs +++ b/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Text.Json; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Auth; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Eval.Contracts; @@ -123,21 +124,25 @@ internal partial class ServerConnection : IAsyncDisposable, IDaemonHeartbeatPort /// public Func? GetLiveAgentIds { get; set; } - /// Phase B (D2): richer live-agent metadata (kind + flow identity) sent alongside + /// Richer live-agent metadata (kind + flow identity) sent alongside /// on DaemonConnect. Optional — null when not wired (tests). public Func? GetLiveAgents { get; set; } /// The agents this daemon is about to resume, claimed on DaemonConnect. public Func? GetResumingAgents { get; set; } + /// The host's accounts snapshot, carried on DaemonConnect; between connects a change + /// goes out through . + public Func? GetAccountsSnapshot { get; set; } + public Func<(int Count, long Version)>? GetActiveCount { get; set; } - /// Phase B2-b (sequenced-settlement design §4.2.4): the server prunes the daemon's durable - /// resolved-candidates ledger per-entry (sparse, deliver-once). One-way server→daemon receive; the - /// handler () is SYNCHRONOUS (the ledger Ack is void). + /// The server prunes the daemon's durable resolved-candidates ledger per-entry (sparse, + /// deliver-once). One-way server→daemon receive; the handler () is + /// SYNCHRONOUS (the ledger Ack is void). public event Action? OnAckResolvedCandidates; - /// Phase B2-b (sequenced-settlement design §4.2.2/§5.5): the sequenced-command receive seams. + /// The sequenced-command receive seams. /// is the Seq'd stop primitive a capability daemon receives instead of the /// legacy StopAgent; is the server's identity-cache retirement /// proof (SYNCHRONOUS — the processor's AckPrefix is void); is @@ -203,6 +208,18 @@ public virtual async Task DaemonStatusReportAsync(DaemonStatusReport report) { catch (Exception ex) { _logger.LogDebug(ex, "DaemonStatusReport send failed (old server or transient)"); } } + /// One-way, like , so a server without the handler only + /// logs it. A dropped or failed send is not retried: every registration brings the server up to date, + /// on DaemonConnect or right after it. + public virtual async Task AccountsChangedAsync(AccountsSnapshotDto snapshot) { + if (!IsReady) return; + try { await SendAccountsChangedRawAsync(snapshot); } + catch (Exception ex) { _logger.LogDebug(ex, "AccountsChanged send failed (old server or transient)"); } + } + + internal virtual Task SendAccountsChangedRawAsync(AccountsSnapshotDto snapshot) => + _hub.SendAsync("AccountsChanged", snapshot, cancellationToken: _ct); + /// Phase B2-b (sequenced-settlement design §4.2.2): answer a sequenced command ONE-WAY (never /// InvokeAsync) — an old server without the CommandAck/CommandRejected handler /// produces only a server-side log line, and any send exception is swallowed so the agent loops are @@ -677,6 +694,8 @@ internal virtual Task RegisterDaemonAsync() => try { _logger.LogDebug(ex, "post-registration hook failed — ignoring"); } catch { /* logger provider threw — swallow so post-register containment holds */ } } + + await SendAccountsChangedSinceConnectAsync(); } ); @@ -739,11 +758,14 @@ internal DaemonConnect BuildDaemonConnect(string platform, string[] repoPaths, s SupportsCorrelatedStatusReports: AdvertisesCorrelatedStatusReports, EvalProtocolVersion: 2, VendorModels: _config.VendorModels, + VendorModelsByAccount: _config.VendorModelsByAccount, SupportsRawInput: AdvertisesRawInput, SupportsBranchBaseRef: true, ResumingAgents: GetResumingAgents?.Invoke(), ActiveCount: activeCount?.Count, - ActiveCountVersion: activeCount?.Version); + ActiveCountVersion: activeCount?.Version, + SupportsAccountLaunches: true, + Accounts: GetAccountsSnapshot?.Invoke()); } async Task DaemonConnectCoreAsync() { @@ -751,15 +773,13 @@ async Task DaemonConnectCoreAsync() { var repoStore = FingerprintRepoStore(); var repoPaths = await MergeRepoPathsAsync(); var liveIds = GetLiveAgentIds?.Invoke() ?? []; - var liveAgents = GetLiveAgents?.Invoke(); // Phase B (D2): additive; null on an unwired/old path + var liveAgents = GetLiveAgents?.Invoke(); + var connect = BuildDaemonConnect(platform, repoPaths, liveIds, liveAgents); try { - await _hub.InvokeAsync( - "DaemonConnect", - BuildDaemonConnect(platform, repoPaths, liveIds, liveAgents), - cancellationToken: _ct - ); + await InvokeDaemonConnectRawAsync(connect); _advertisedRepoStore = repoStore; + _connectAccounts = connect.Accounts; } catch (Exception ex) when (IsNameInUse(ex)) { // server refused our (owner, name) slot because another // live daemon owns it. Surface to DaemonRunner before re-throwing @@ -774,6 +794,9 @@ await _hub.InvokeAsync( } } + internal virtual Task InvokeDaemonConnectRawAsync(DaemonConnect connect) => + _hub.InvokeAsync("DaemonConnect", connect, cancellationToken: _ct); + /// /// Set by : re-registers this daemon's live agents with the /// server (AgentRegistered + AgentStatusChanged) so per-session ownership is restored after a @@ -797,6 +820,18 @@ await _hub.InvokeAsync( volatile RepoStoreFingerprint? _advertisedRepoStore; + volatile AccountsSnapshotDto? _connectAccounts; + + // A change after the connect payload was built had its own send dropped by the IsReady guard. + Task SendAccountsChangedSinceConnectAsync() { + if (GetAccountsSnapshot?.Invoke() is not { } current) return Task.CompletedTask; + + var sent = _connectAccounts; + return sent is not null && sent.HostId == current.HostId && sent.Revision == current.Revision + ? Task.CompletedTask + : AccountsChangedAsync(current); + } + // Taken before the read it pairs with, so a write landing during the read still reads as a // change afterwards. RepoStoreFingerprint? FingerprintRepoStore() => new RepoPathStore(_config.ConfigRoot, _time).Fingerprint(); @@ -826,7 +861,7 @@ Task OnReconnecting(Exception? error) { /// virtual so unit tests can control readiness directly without a live SignalR transport /// (see the ACP hub-method tests). /// - internal virtual bool IsReady => _gate.IsReady(_hub.State); + internal virtual bool IsReady => _gate.IsReady(HubState); /// The current SignalR connection incarnation. Review-flow launch commands pin this /// value server-side and the daemon rechecks it immediately before any spawn side effects. @@ -964,11 +999,12 @@ internal static void ConfigureHubPayload(JsonSerializerOptions options) { public virtual async Task AgentRegisteredAsync( string agentId, string? prompt, string? model, string? effort, string? repoPath, string? sandboxPolicy = null, string? approvalPolicy = null, string? permissionPreset = null, - string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null) { + string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null, + string? accountDirectory = null, string? accountId = null, string? accountLabel = null) { var activeCount = GetActiveCount?.Invoke(); var registration = new AgentRegistered( agentId, prompt, model, effort, repoPath, sandboxPolicy, approvalPolicy, permissionPreset, runtimeTransport, - title?.Text, title?.Derived ?? false, activeCount?.Count, activeCount?.Version, vendor, continuesAgentId); + title?.Text, title?.Derived ?? false, activeCount?.Count, activeCount?.Version, vendor, continuesAgentId, accountDirectory, accountId, accountLabel); var connection = CurrentConnectionId ?? ""; if (!string.Equals(Volatile.Read(ref _registrationAckMissingOn), connection, StringComparison.Ordinal)) { diff --git a/src/Capacitor.Cli.Daemon/Services/VendorCliWatcher.cs b/src/Capacitor.Cli.Daemon/Services/VendorCliWatcher.cs index 2854c1576e..e473bd7060 100644 --- a/src/Capacitor.Cli.Daemon/Services/VendorCliWatcher.cs +++ b/src/Capacitor.Cli.Daemon/Services/VendorCliWatcher.cs @@ -1,3 +1,4 @@ +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Setup; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -19,9 +20,12 @@ namespace Capacitor.Cli.Daemon.Services; internal sealed partial class VendorCliWatcher : BackgroundService { readonly DaemonConfig? _config; readonly IReadOnlyDictionary? _factories; + readonly Func _accounts = static () => null; readonly ILogger _logger; readonly TimeProvider _time; + int _rebuildRequested; + // Seams (assigned from DI in the production ctor; overridden directly in tests). internal Func StatBinary; internal Func StatCatalog = CatalogPathStat.Of; @@ -30,26 +34,32 @@ internal sealed partial class VendorCliWatcher : BackgroundService { readonly Dictionary _baselines = new(StringComparer.Ordinal); readonly Dictionary _catalogBaselines = new(StringComparer.Ordinal); + // The poll loop and a caller forcing a tick can overlap, and a Dictionary corrupts under two writers. + readonly Lock _gate = new(); /// Fingerprints recorded when the advertisement was probed. The advertisement /// describes the files as they were then, so a vendor that updates before this service starts - /// must read as a change on the first tick rather than become the baseline. - IReadOnlyDictionary? _recorded; - IReadOnlyDictionary? _recordedCatalogs; + /// must read as a change on the first tick rather than become the baseline. The catalog record is + /// read live: a refresh records an added account's files before probing it. + IReadOnlyDictionary? _recorded; + readonly Func?> _recordedCatalogs; static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); public VendorCliWatcher( DaemonConfig config, AgentOrchestrator orchestrator, - IReadOnlyDictionary factories, + IReadOnlyDictionary factories, AccountsPublisher accounts, ILogger logger, TimeProvider time) { _config = config; _time = time; _factories = factories; + _accounts = () => accounts.Current; _logger = logger; + _recordedCatalogs = () => config.VendorCatalogBaselines; Refresh = reason => orchestrator.RefreshAdvertisedCapabilities(reason); StatBinary = path => StatCliBinary(config.Binaries, path); Watched = []; + accounts.Changed += _ => OnAccountsChanged(); } VendorCliWatcher(IReadOnlyList<(string Vendor, string CliPath, IReadOnlyList CatalogPaths)> watched, @@ -60,7 +70,7 @@ public VendorCliWatcher( _logger = NullLogger.Instance; _time = time; _recorded = baselines; - _recordedCatalogs = catalogBaselines; + _recordedCatalogs = () => catalogBaselines; Watched = watched; Refresh = refresh; StatBinary = stat; @@ -76,35 +86,83 @@ internal static VendorCliWatcher ForTest( new(watched, refresh, stat, time, baselines, statCatalog, catalogBaselines); /// Unattended vendors, plus every vendor that publishes a model catalog: one with - /// catalog files to fingerprint, or one that answered the startup probe. + /// catalog files to fingerprint, or one that answered the startup probe. Each account a refresh + /// probes on its own is an entry of its own, keyed by , + /// with no binary. internal static IReadOnlyList<(string Vendor, string CliPath, IReadOnlyList CatalogPaths)> WatchSet( - DaemonConfig config, IReadOnlyDictionary factories) { + DaemonConfig config, IReadOnlyDictionary factories, + AccountsSnapshotDto? accounts = null) { var vendors = new HashSet(config.UnattendedVendors ?? [], StringComparer.Ordinal); foreach (var f in factories.Values) if (f.CatalogFingerprintPaths.Count > 0 || (config.VendorModels?.ContainsKey(f.Vendor) ?? false)) vendors.Add(f.Vendor); + var perAccount = VendorModelCatalogs.AccountFactory(factories.Values, config.SupportedVendors ?? []) is { } accountFactory + ? VendorModelCatalogs.AccountsIn(accounts).Select(a => + (Vendor: VendorModelCatalogs.AccountKey(a.AccountId), CliPath: "", CatalogPaths: accountFactory.CatalogFingerprintPathsFor(a.Directory))) + : []; + return vendors.Where(factories.ContainsKey) .Select(v => (Vendor: v, factories[v].CliPath, CatalogPaths: factories[v].CatalogFingerprintPaths)) + .Concat(perAccount) .Where(w => !string.IsNullOrEmpty(w.CliPath) || w.CatalogPaths.Count > 0) .OrderBy(w => w.Vendor, StringComparer.Ordinal) .ToArray(); } + /// Gives each watched entry without a baseline its first one, the recorded fingerprint when + /// there is one, and forgets entries no longer watched. An entry that already has one keeps it, so + /// a rebuild cannot absorb a change the next tick has yet to see; the recorded binary fingerprints + /// serve only the first call. internal void PrimeBaselines() { + lock (_gate) PrimeBaselinesUnlocked(); + } + + void PrimeBaselinesUnlocked() { + var watched = new HashSet(StringComparer.Ordinal); + var recordedCatalogs = _recordedCatalogs(); foreach (var (vendor, cliPath, catalogPaths) in Watched) { - _baselines[vendor] = _recorded is { } recorded && recorded.TryGetValue(vendor, out var baseline) - ? baseline - : StatCli(cliPath); - _catalogBaselines[vendor] = _recordedCatalogs is { } recordedCatalogs && recordedCatalogs.TryGetValue(vendor, out var catalog) - ? catalog - : StatCatalogs(catalogPaths); + watched.Add(vendor); + if (!_baselines.ContainsKey(vendor)) + _baselines[vendor] = _recorded is { } recorded && recorded.TryGetValue(vendor, out var baseline) + ? baseline + : StatCli(cliPath); + if (!_catalogBaselines.ContainsKey(vendor)) + _catalogBaselines[vendor] = recordedCatalogs is not null && recordedCatalogs.TryGetValue(vendor, out var catalog) + ? catalog + : StatCatalogs(catalogPaths); } + + foreach (var gone in _baselines.Keys.Where(k => !watched.Contains(k)).ToArray()) _baselines.Remove(gone); + foreach (var gone in _catalogBaselines.Keys.Where(k => !watched.Contains(k)).ToArray()) _catalogBaselines.Remove(gone); + _recorded = null; + } + + /// The publisher's handler, which must not block: the watch set is rebuilt by the next tick, + /// and the refresh is a trigger. + internal void OnAccountsChanged() { + Interlocked.Exchange(ref _rebuildRequested, 1); + Refresh("accounts changed"); } /// One poll iteration (timer-driven; also the unit-test entry point). All vendors /// that changed since the last tick share one refresh, since a refresh re-probes them all. internal void Tick() { + string? reason; + lock (_gate) reason = TickUnlocked(); + if (reason is null) return; + + LogChanged(_logger, reason); + Refresh(reason); + } + + // The refresh is raised outside the gate: it is a trigger into the orchestrator, not state of this class. + string? TickUnlocked() { + if (Interlocked.Exchange(ref _rebuildRequested, 0) == 1 && _config is not null && _factories is not null) { + Watched = WatchSet(_config, _factories, _accounts()); + PrimeBaselinesUnlocked(); + } + List? changed = null; foreach (var (vendor, cliPath, catalogPaths) in Watched) { var current = StatCli(cliPath); @@ -118,10 +176,7 @@ internal void Tick() { (changed ??= []).Add(vendor); } - if (changed is null) return; - var reason = $"{string.Join(", ", changed)} CLI binary or catalog files changed on disk"; - LogChanged(_logger, reason); - Refresh(reason); + return changed is null ? null : $"{string.Join(", ", changed)} CLI binary or catalog files changed on disk"; } CliBinaryStat? StatCli(string cliPath) => string.IsNullOrEmpty(cliPath) ? null : StatBinary(cliPath); @@ -149,9 +204,8 @@ internal static bool Changed(CliBinaryStat? baseline, CliBinaryStat? current) => protected override async Task ExecuteAsync(CancellationToken ct) { if (_config is not null && _factories is not null) { - _recorded = _config.UnattendedVendorBaselines; - _recordedCatalogs = _config.VendorCatalogBaselines; - Watched = WatchSet(_config, _factories); + _recorded = _config.UnattendedVendorBaselines; + Watched = WatchSet(_config, _factories, _accounts()); } PrimeBaselines(); diff --git a/src/Capacitor.Cli.Daemon/Services/VendorHomeEnv.cs b/src/Capacitor.Cli.Daemon/Services/VendorHomeEnv.cs new file mode 100644 index 0000000000..a37b268622 --- /dev/null +++ b/src/Capacitor.Cli.Daemon/Services/VendorHomeEnv.cs @@ -0,0 +1,33 @@ +using System.Collections.Frozen; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; + +namespace Capacitor.Cli.Daemon.Services; + +/// Points a launch's child at its account's directory. A child that inherits the daemon's own +/// vendor home variable reads that directory, not the one its launcher prepared. +internal static class VendorHomeEnv { + /// Sets the account's variable in , or returns it as the inherited + /// variable the child must not see. Never touches the daemon's own environment. + public static IReadOnlyCollection Apply( + IDictionary overlay, string vendor, ResolvedAccount account, UserHome home) => + Apply(overlay, vendor, account.Directory, home); + + /// + public static IReadOnlyCollection Apply( + IDictionary overlay, string vendor, string directory, UserHome home) { + if (HarnessId.From(vendor) is not { } id || VendorHomeVariable.Plan(id, directory, home) is not { } plan) + return FrozenSet.Empty; + + if (plan.Value is null) { + overlay.Remove(plan.Variable); + + return [plan.Variable]; + } + + overlay[plan.Variable] = plan.Value; + + return FrozenSet.Empty; + } +} diff --git a/src/Capacitor.Cli.Daemon/Services/VendorModelCatalogs.cs b/src/Capacitor.Cli.Daemon/Services/VendorModelCatalogs.cs index 37051b53af..99ce6bc636 100644 --- a/src/Capacitor.Cli.Daemon/Services/VendorModelCatalogs.cs +++ b/src/Capacitor.Cli.Daemon/Services/VendorModelCatalogs.cs @@ -1,4 +1,7 @@ +using System.Collections.Frozen; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; namespace Capacitor.Cli.Daemon.Services; @@ -30,6 +33,54 @@ internal static Dictionary Merge( return merged; } + /// The one vendor whose catalog depends on the account: a ChatGPT-plan and an API account can + /// launch different models. + internal static readonly string AccountVendor = HarnessId.Codex.VendorId; + + static readonly string AccountKeyPrefix = AccountKey(""); + + /// The runtime factory that probes per account, when its vendor is advertised. + internal static IHostedAgentRuntimeFactory? AccountFactory( + IEnumerable factories, IEnumerable supported) => + supported.Contains(AccountVendor, StringComparer.Ordinal) + ? factories.FirstOrDefault(f => string.Equals(f.Vendor, AccountVendor, StringComparison.Ordinal)) + : null; + + /// The registered accounts of in . + internal static IReadOnlyList<(string AccountId, string Directory)> AccountsIn(AccountsSnapshotDto? snapshot) => + snapshot is null + ? [] + : [.. snapshot.Accounts.Where(a => string.Equals(a.Vendor, AccountVendor, StringComparison.Ordinal)).Select(a => (a.Id, a.Directory))]; + + /// The key an account's catalog files are fingerprinted and watched under. + internal static string AccountKey(string accountId) => $"{AccountVendor} account {accountId}"; + + /// Probes each account concurrently under its own directory; an account whose probe answers null + /// is not a key. + internal static async Task> ProbeByAccountAsync( + IHostedAgentRuntimeFactory factory, IReadOnlyList<(string AccountId, string Directory)> accounts, CancellationToken ct) { + var probes = accounts + .Select(async a => (a.AccountId, Models: await factory.ProbeModelsAsync(a.Directory, ct).ConfigureAwait(false))) + .ToArray(); + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (id, models) in await Task.WhenAll(probes).ConfigureAwait(false)) + if (models is not null) result[id] = [.. models]; + return result; + } + + /// Like , except that an account not in is dropped + /// from both sides: a probe that outlived its account publishes nothing. Null stays null while + /// there is nothing to hold. + internal static Dictionary? MergeByAccount( + Dictionary? previous, Dictionary fresh, + IReadOnlySet live) { + var merged = new Dictionary(StringComparer.Ordinal); + foreach (var (id, models) in previous ?? []) if (live.Contains(id)) merged[id] = models; + foreach (var (id, models) in fresh) if (live.Contains(id)) merged[id] = models; + return previous is null && merged.Count == 0 ? null : merged; + } + /// Same keys and, per key, the same ordered (Value, Label) pairs. internal static bool Equal(Dictionary? a, Dictionary? b) { if (a is null || b is null) return a is null && b is null; @@ -39,12 +90,29 @@ internal static bool Equal(Dictionary? a, Dictionar return true; } + /// Keyed by vendor, plus for each of . internal static IReadOnlyDictionary FingerprintCatalogPaths( - IEnumerable factories, IEnumerable vendors) { + IEnumerable factories, IEnumerable vendors, + IHostedAgentRuntimeFactory? accountFactory = null, IReadOnlyList<(string AccountId, string Directory)>? accounts = null) { var byVendor = factories.ToDictionary(f => f.Vendor, StringComparer.Ordinal); - return vendors.ToDictionary( + var result = vendors.ToDictionary( v => v, v => byVendor.TryGetValue(v, out var f) ? f.CatalogFingerprintPaths.Select(CatalogPathStat.Of).ToArray() : [], StringComparer.Ordinal); + return accountFactory is null ? result : WithAccountBaselines(result, accountFactory, accounts ?? []); + } + + /// with its account entries replaced by fingerprints of + /// ' catalog files taken now. A new dictionary, since the watcher may be + /// reading the old one. + internal static IReadOnlyDictionary WithAccountBaselines( + IReadOnlyDictionary? baselines, IHostedAgentRuntimeFactory accountFactory, + IReadOnlyList<(string AccountId, string Directory)> accounts) { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (key, stats) in baselines ?? FrozenDictionary.Empty) + if (!key.StartsWith(AccountKeyPrefix, StringComparison.Ordinal)) result[key] = stats; + foreach (var (id, directory) in accounts) + result[AccountKey(id)] = [.. accountFactory.CatalogFingerprintPathsFor(directory).Select(CatalogPathStat.Of)]; + return result; } } diff --git a/src/Capacitor.Cli/Commands/AccountSetupStep.cs b/src/Capacitor.Cli/Commands/AccountSetupStep.cs index cb93e84d8c..45362ba258 100644 --- a/src/Capacitor.Cli/Commands/AccountSetupStep.cs +++ b/src/Capacitor.Cli/Commands/AccountSetupStep.cs @@ -1,3 +1,4 @@ +using Capacitor.Cli.Core; using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Harness.Claude; diff --git a/src/Capacitor.Cli/Commands/AccountsCommand.cs b/src/Capacitor.Cli/Commands/AccountsCommand.cs index 307063fd55..44aba71aba 100644 --- a/src/Capacitor.Cli/Commands/AccountsCommand.cs +++ b/src/Capacitor.Cli/Commands/AccountsCommand.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Harness; @@ -8,25 +9,34 @@ public sealed class AccountsCommand(AccountStore accounts, PluginEnvironment env const int MinIdPrefix = 4; const int IdDisplayChars = 8; const string SkipNetworkFlag = "--skip-codex-network-access"; + const string JsonFlag = "--json"; + const string ListAction = "list"; + const string UsageLine = "Usage: kcap accounts [list|add|remove|rename|rewire]"; bool _skipNetwork; + bool _json; + + // Seam: a test removes the account here, where another command can. + internal Action? BetweenWiringAndStamp { get; set; } public async Task HandleAsync(string[] args) { _skipNetwork = args.Contains(SkipNetworkFlag); - args = [.. args.Where(a => a != SkipNetworkFlag)]; + _json = args.Contains(JsonFlag); + args = [.. args.Where(a => a is not (SkipNetworkFlag or JsonFlag))]; + + var action = args.Length < 2 ? ListAction : args[1]; try { return args.Length < 2 ? await List() : args[1] switch { - "list" => await List(), + ListAction => await List(), "add" when args.Length == 4 => await Add(args[2], args[3]), "remove" when args.Length == 3 => await Remove(args[2]), "rename" when args.Length == 4 => await Rename(args[2], args[3]), "rewire" when args.Length <= 3 => await Rewire(args.Length == 3 ? args[2] : null), - _ => await Usage(), + _ => await Usage(action), }; } catch (RegistryUnavailable ex) { - await Console.Error.WriteLineAsync($"Could not use the kcap account registry in {accounts.Directory}: {ex.Message}"); - return 1; + return await Refuse(action, $"Could not use the kcap account registry in {accounts.Directory}: {ex.Message}"); } } @@ -71,74 +81,131 @@ async Task List() { } async Task Add(string vendorName, string directory) { - if (!TryVendor(vendorName, out var vendor)) return await Usage(); + if (!TryVendor(vendorName, out var vendor)) return await Usage("add"); - if (TryNormalize(directory) is not { } normalized || !Directory.Exists(normalized)) { - await Console.Error.WriteLineAsync($"{directory} does not exist."); - return 1; - } + if (TryNormalize(directory) is not { } normalized || !Directory.Exists(normalized)) + return await Refuse("add", $"{directory} does not exist."); var account = Registry(() => AccountAdoption.EnsureDefault(accounts, vendor, normalized, time)); - return await WireAndReport(account) ? 0 : 1; + var wired = await WireAndReport(account); + return await Finish("add", account.Id, wired.Error is null, wired.Steps, wired.Revision, wired.Error); } async Task Remove(string idOrDir) { - if (Resolve(idOrDir) is not { } account) return await NotFound(idOrDir); + if (Resolve(idOrDir) is not { } account) return await NotFound("remove", idOrDir); var steps = Guarded(() => AccountWiring.Unwire(account, env.Home)); - if (!AccountWiring.HasFatalFailure(steps)) { - Registry(() => accounts.Mutate(r => (r with { Accounts = [.. r.Accounts.Where(a => a.Id != account.Id)] }, 0))); - await Console.Out.WriteLineAsync($"Removed {account.Vendor} account {account.Label}. {account.Directory} was left in place."); - return 0; + if (AccountWiring.HasFatalFailure(steps)) { + // A partial unwire changed the account on disk, so the failure is committed like a failed wiring. + var summary = AccountWiring.FailureSummary(steps); + var error = $"Could not unwire {account.Label}: {summary}. It stays registered."; + var committed = StampWiring(account, summary).Revision; + await Console.Error.WriteLineAsync(error); + return await Finish("remove", account.Id, false, steps, committed, error); } - await Console.Error.WriteLineAsync($"Could not unwire {account.Label}: {AccountWiring.FailureSummary(steps)}. It stays registered."); - return 1; + var (_, revision) = Registry(() => accounts.MutateWithRevision(r => (r with { Accounts = [.. r.Accounts.Where(a => a.Id != account.Id)] }, 0))); + await Say($"Removed {account.Vendor} account {account.Label}. {account.Directory} was left in place."); + return await Finish("remove", account.Id, true, steps, revision); } async Task Rename(string idOrDir, string label) { - if (string.IsNullOrWhiteSpace(label)) return await Usage(); - if (Resolve(idOrDir) is not { } account) return await NotFound(idOrDir); + if (string.IsNullOrWhiteSpace(label)) return await Usage("rename"); + if (Resolve(idOrDir) is not { } account) return await NotFound("rename", idOrDir); - Registry(() => accounts.Mutate(r => (r with { Accounts = [.. r.Accounts.Select(a => a.Id == account.Id ? a with { Label = label } : a)] }, 0))); - await Console.Out.WriteLineAsync($"Renamed to {label}."); - return 0; + var (_, revision) = Registry(() => accounts.MutateWithRevision(r => (r with { Accounts = [.. r.Accounts.Select(a => a.Id == account.Id ? a with { Label = label } : a)] }, 0))); + await Say($"Renamed to {label}."); + return await Finish("rename", account.Id, true, [], revision); } async Task Rewire(string? idOrDir) { IReadOnlyList targets; if (idOrDir is null) targets = Registry(accounts.Load).Accounts; else if (Resolve(idOrDir) is { } one) targets = [one]; - else return await NotFound(idOrDir); + else return await NotFound("rewire", idOrDir); if (targets.Count == 0) { - await Console.Out.WriteLineAsync("No accounts registered. Add one with: kcap accounts add "); - return 0; + await Say("No accounts registered. Add one with: kcap accounts add "); + return await Finish("rewire", null, true, [], null); + } + + var steps = new List(); + var errors = new List(); + long? revision = null; + foreach (var account in targets) { + var wired = await WireAndReport(account); + steps.AddRange(targets.Count == 1 ? wired.Steps : wired.Steps.Select(s => s with { Detail = $"{account.Label}: {s.Detail}" })); + if (wired.Error is not null) errors.Add(wired.Error); + revision = wired.Revision; } - var ok = true; - foreach (var account in targets) ok &= await WireAndReport(account); - return ok ? 0 : 1; + return await Finish("rewire", targets.Count == 1 ? targets[0].Id : null, errors.Count == 0, steps, revision, + errors.Count == 0 ? null : string.Join(" ", errors)); } - async Task WireAndReport(VendorAccount account) { + async Task<(IReadOnlyList Steps, long Revision, string? Error)> WireAndReport(VendorAccount account) { IReadOnlyList steps; using (Registry(accounts.Lock)) steps = Guarded(() => AccountWiring.Wire(account, env.Home, Options)); + BetweenWiringAndStamp?.Invoke(); - var fatal = AccountWiring.HasFatalFailure(steps); + // The stamp's Mutate takes the lock held above, so it must come after that block. Committing after the + // wiring is what lets a snapshot at this revision vouch for it. + var fatal = AccountWiring.HasFatalFailure(steps); + var (revision, found) = StampWiring(account, fatal ? AccountWiring.FailureSummary(steps) : null); + + if (!found) { + var removed = $"Account {account.Label} was removed while it was being wired."; + await Console.Error.WriteLineAsync(removed); + + return (steps, revision, removed); + } + + string? error = null; if (!fatal) { - await Console.Out.WriteLineAsync($"Wired {account.Vendor} account {account.Label} ({account.Directory})."); + await Say($"Wired {account.Vendor} account {account.Label} ({account.Directory})."); if (account.Vendor is HarnessId.Codex) - await Console.Out.WriteLineAsync($" Trust the kcap hooks the next time you start Codex with CODEX_HOME={account.Directory}."); + await Say($" Trust the kcap hooks the next time you start Codex with CODEX_HOME={account.Directory}."); } if (steps.Any(s => !s.Succeeded)) { - await Console.Error.WriteLineAsync(fatal + var message = fatal ? $"Could not wire {account.Label}: {AccountWiring.FailureSummary(steps)}. Run `kcap accounts rewire` to retry." - : $"Warning: {account.Label}: {AccountWiring.FailureSummary(steps)}"); + : $"Warning: {account.Label}: {AccountWiring.FailureSummary(steps)}"; + await Console.Error.WriteLineAsync(message); + if (fatal) error = message; } - return !fatal; + return (steps, revision, error); + } + + // Rebuilds the list even when the stamp changes nothing, so the revision still moves for a rewire that found nothing to fix. + // Found is false when a concurrent remove took the account between the wiring and this stamp. + (long Revision, bool Found) StampWiring(VendorAccount account, string? failure) { + var now = time.GetUtcNow(); + var (found, revision) = Registry(() => accounts.MutateWithRevision(r => (r with { + Accounts = [.. r.Accounts.Select(a => a.Id != account.Id ? a + : failure is null ? a with { WiredAt = now, WiringFailure = null } + : a with { WiringFailure = failure })], + }, r.Accounts.Any(a => a.Id == account.Id)))); + + return (revision, found); + } + + Task Say(string line) => _json ? Task.CompletedTask : Console.Out.WriteLineAsync(line); + + // list keeps its text listing under --json: its output is the data, not a verdict on an action. + async Task Finish(string action, string? accountId, bool succeeded, IReadOnlyList steps, long? revision, string? error = null) { + if (_json && action != ListAction) { + var result = new AccountActionResultDto(action, accountId, succeeded, [.. steps.Select(s => new WiringStepDto(s.Name, s.Succeeded, s.Detail))], revision, error); + await Console.Out.WriteLineAsync(JsonSerializer.Serialize(result, AccountActionResultJsonContext.Default.AccountActionResultDto)); + } + + return succeeded ? 0 : 1; + } + + async Task Refuse(string action, string error) { + await Console.Error.WriteLineAsync(error); + return await Finish(action, null, false, [], null, error); } VendorAccount? Resolve(string idOrDir) { @@ -153,11 +220,7 @@ await Console.Error.WriteLineAsync(fatal string? TryNormalize(string path) { try { - var expanded = path == "~" ? env.Home.Path - : path.StartsWith("~/", StringComparison.Ordinal) ? Path.Combine(env.Home.Path, path[2..]) - : path; - - return AccountDirectory.Normalize(expanded); + return AccountDirectory.Normalize(AccountDirectory.ExpandHome(path, env.Home)); } catch (ArgumentException) { return null; } catch (NotSupportedException) { @@ -172,13 +235,10 @@ static bool TryVendor(string name, out HarnessId vendor) { return name.ToLowerInvariant() is "claude" or "codex"; } - static async Task NotFound(string idOrDir) { - await Console.Error.WriteLineAsync($"No account matches {idOrDir}. Run `kcap accounts` to list them."); - return 1; - } + Task NotFound(string action, string idOrDir) => Refuse(action, $"No account matches {idOrDir}. Run `kcap accounts` to list them."); - static async Task Usage() { - await Console.Error.WriteLineAsync(EmbeddedResources.TryLoad("help-accounts.txt") ?? "Usage: kcap accounts [list|add|remove|rename|rewire]"); - return 1; + async Task Usage(string action) { + await Console.Error.WriteLineAsync(EmbeddedResources.TryLoad("help-accounts.txt") ?? UsageLine); + return await Finish(action, null, false, [], null, UsageLine); } } diff --git a/src/Capacitor.Cli/Commands/AgentCommand.cs b/src/Capacitor.Cli/Commands/AgentCommand.cs index 1ee53ddd25..b5e67e0cbf 100644 --- a/src/Capacitor.Cli/Commands/AgentCommand.cs +++ b/src/Capacitor.Cli/Commands/AgentCommand.cs @@ -1,11 +1,13 @@ using System.Net.Sockets; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.LocalIpc; using Capacitor.Cli.Core.Resume; using Capacitor.Cli.Core.Setup; using Capacitor.Cli.Local; +using Capacitor.Cli.Services; namespace Capacitor.Cli.Commands; @@ -24,8 +26,12 @@ internal readonly record struct AgentRow( /// terminal over the daemon's local control socket. /// internal sealed class AgentCommand( - DaemonStore store, ConfigRoot config, ProfileContext profiles, UserHome home, + DaemonStore store, AccountStore accounts, ConfigRoot config, ProfileContext profiles, UserHome home, HarnessRegistry harnesses, BinaryProbe binaries, WorkingDirectory workdir, TimeProvider time, StandardStreams console) { + const string AccountsCapability = "accounts/1"; + + static readonly TimeSpan HelloTimeout = TimeSpan.FromSeconds(5); + internal static readonly string[] KnownSubcommands = ["start", "ls", "stop", "attach", "resume"]; /// Verbs that only ever belonged to the pre-rename `agent` daemon group, minus the @@ -88,19 +94,61 @@ async Task RunAsync(string[] args, string? baseUrl) { return 1; } + ResolvedAccount? account = null; + if (parsed.Account is not null) { + account = await ResolveAccountAsync(parsed); + if (account is null) return 1; + } + var name = ResolveName(parsed.DaemonName); if (!await EnsureDaemonAsync(name)) return 1; + // A daemon without accounts/1 never reads the account id and would start the agent under its default account. + if (account is not null && !await DaemonChoosesAccountsAsync(name)) { + await Console.Error.WriteLineAsync("kcap agent start: this daemon does not support choosing an account; update kcap on this machine"); + + return 1; + } + var sock = store.SocketPath(name); var work = parsed.Worktree ? WorkLocation.OwnedWorktree : WorkLocation.BorrowedCwd; var (cols, rows) = TermSize(); - var spawn = FrameCodec.Spawn(parsed.Vendor, work, parsed.Private, workdir.Path, parsed.Passthrough, cols, rows, parsed.StartTitle); + var spawn = FrameCodec.Spawn( + parsed.Vendor, work, parsed.Private, workdir.Path, parsed.Passthrough, cols, rows, parsed.StartTitle, account?.AccountId); return parsed.Detached ? await SpawnDetachedAsync(sock, spawn) : await LocalAgentClient.RunAsync(sock, spawn, time, CancellationToken.None); } + /// Null after printing why the --account value cannot be used. + async Task ResolveAccountAsync(AgentStartArgs parsed) { + AccountRegistry registry; + try { + registry = accounts.Load(); + } catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException) { + await Console.Error.WriteLineAsync($"kcap agent start: could not read the kcap account registry in {accounts.Directory}: {ex.Message}"); + + return null; + } + + if (HarnessNames.From(parsed.Vendor) is not ({ } vendor and (HarnessId.Claude or HarnessId.Codex))) { + await Console.Error.WriteLineAsync($"kcap agent start: cannot use --account with {parsed.Vendor}: accounts apply to claude and codex only"); + + return null; + } + + var outcome = AccountResolution.ResolveArgument(registry, vendor, AccountDirectory.ExpandHome(parsed.Account!, home)); + if (outcome.Account is not null) return outcome.Account; + + await Console.Error.WriteLineAsync($"kcap agent start: {outcome.Refusal}"); + + return null; + } + + async Task DaemonChoosesAccountsAsync(string name) => + (await HelloProbe.RunAsync(store, name, time, HelloTimeout)).Capabilities.Contains(AccountsCapability); + async Task AttachAsync(string[] args) { if (args.Length == 0 || args[0].StartsWith('-')) { await console.Error.WriteLineAsync("usage: kcap agent attach [--daemon ]"); diff --git a/src/Capacitor.Cli/Services/HelloProbe.cs b/src/Capacitor.Cli/Services/HelloProbe.cs index 8c3d3c0a8b..6dda46c4ff 100644 --- a/src/Capacitor.Cli/Services/HelloProbe.cs +++ b/src/Capacitor.Cli/Services/HelloProbe.cs @@ -15,6 +15,9 @@ public sealed record HelloProbeResult(bool WellFormed, int? ProtocolVersion, str /// still starting. Defaults false so a well-formed test fake that never dialed a socket does not /// falsely read as reachable; is the only place it is set true. public bool Reachable { get; init; } + + /// What the daemon advertised; empty when the reply carried no capabilities. + public IReadOnlyList Capabilities { get; init; } = []; } /// @@ -52,7 +55,10 @@ public static async Task RunAsync( var dto = JsonSerializer.Deserialize(reply.Text, HelloIpcJsonContext.Default.HelloReplyDto); if (dto is null) return ReachableNotWellFormed; - return new HelloProbeResult(true, dto.ProtocolVersion, dto.DaemonVersion, dto.DaemonName) { Reachable = true }; + return new HelloProbeResult(true, dto.ProtocolVersion, dto.DaemonVersion, dto.DaemonName) { + Reachable = true, + Capabilities = dto.Capabilities ?? [], + }; } catch { return ReachableNotWellFormed; } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountActionResultDtoTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountActionResultDtoTests.cs new file mode 100644 index 0000000000..0465ed1f52 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountActionResultDtoTests.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using Capacitor.Cli.Core.Accounts; + +namespace Capacitor.Cli.Core.Tests.Unit.Accounts; + +public class AccountActionResultDtoTests { + [Test] + public async Task Result_serializes_on_one_line_in_snake_case_with_every_field_present() { + var result = new AccountActionResultDto("remove", null, false, [new WiringStepDto("hooks", false, "could not write the settings file")], null, "It stays registered."); + + var json = JsonSerializer.Serialize(result, AccountActionResultJsonContext.Default.AccountActionResultDto); + + await Assert.That(json).IsEqualTo( + """{"action":"remove","account_id":null,"succeeded":false,"steps":[{"name":"hooks","succeeded":false,"detail":"could not write the settings file"}],"revision":null,"error":"It stays registered."}"""); + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountResolutionTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountResolutionTests.cs new file mode 100644 index 0000000000..25c719b8f8 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountResolutionTests.cs @@ -0,0 +1,278 @@ +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; + +namespace Capacitor.Cli.Core.Tests.Unit.Accounts; + +public class AccountResolutionTests { + const string WorkId = "c1a0de01-work"; + const string PersId = "c1a0de02-personal"; + const string CodexId = "c0dec001-codex"; + + [TempDir] public required TempDir Tmp { get; init; } + + string Work => Tmp.CreateDir(".claude-work"); + string Personal => Tmp.CreateDir(".claude"); + string CodexDir => Tmp.CreateDir(".codex"); + + AccountRegistry Registry() => new() { + Accounts = [ + Entry(WorkId, HarnessId.Claude, Work, "Work"), + Entry(PersId, HarnessId.Claude, Personal, "Personal"), + Entry(CodexId, HarnessId.Codex, CodexDir, "Codex") + ] + }; + + static VendorAccount Entry(string id, HarnessId vendor, string dir, string label) => + new(id, vendor, AccountDirectory.Normalize(dir), label, DateTimeOffset.UnixEpoch); + + [Test] + public async Task Null_id_resolves_to_the_default_directory_with_its_registered_id() { + var outcome = AccountResolution.Resolve(Registry(), HarnessId.Claude, null, Personal); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(Personal)); + await Assert.That(outcome.Account.AccountId).IsEqualTo(PersId); + await Assert.That(outcome.Account.Label).IsEqualTo("Personal"); + } + + [Test] + public async Task Empty_id_is_the_default_and_a_null_registry_is_an_empty_one() { + var empty = AccountResolution.Resolve(Registry(), HarnessId.Claude, "", Work); + var none = AccountResolution.Resolve(null, HarnessId.Claude, null, Work); + + await Assert.That(empty.Account!.AccountId).IsEqualTo(WorkId); + await Assert.That(none.Refused).IsFalse(); + await Assert.That(none.Account!.AccountId).IsNull(); + } + + [Test] + public async Task Null_id_with_an_unregistered_default_has_no_id_and_the_directory_name_as_label() { + var other = Tmp.CreateDir("elsewhere"); + var outcome = AccountResolution.Resolve(Registry(), HarnessId.Claude, null, other); + + await Assert.That(outcome.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(other)); + await Assert.That(outcome.Account.AccountId).IsNull(); + await Assert.That(outcome.Account.Label).IsEqualTo("elsewhere"); + } + + /// The recorded directory is the one behind the link, so a retargeted link cannot move a + /// resume to another account. + [Test] + public async Task Default_behind_a_symlink_records_the_physical_directory_and_a_retarget_cannot_move_a_resume() { + Skip.When(OperatingSystem.IsWindows(), "symlink creation needs elevated privilege on Windows CI"); + var link = Tmp.PathTo("current"); + Directory.CreateSymbolicLink(link, Work); + + var launched = AccountResolution.Resolve(Registry(), HarnessId.Claude, null, link); + + await Assert.That(launched.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(Work)); + await Assert.That(launched.Account.AccountId).IsEqualTo(WorkId); + + Directory.Delete(link); + Directory.CreateSymbolicLink(link, Personal); + var resumed = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, launched.Account.AccountId, launched.Account.Directory, link); + + await Assert.That(resumed.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(Work)); + await Assert.That(resumed.Account.AccountId).IsEqualTo(WorkId); + } + + [Test] + public async Task A_default_registered_only_for_another_vendor_carries_no_id() { + var outcome = AccountResolution.Resolve(Registry(), HarnessId.Claude, null, CodexDir); + + await Assert.That(outcome.Account!.AccountId).IsNull(); + await Assert.That(outcome.Account.Label).IsEqualTo(".codex"); + } + + /// The bad entries come first, so a resolution that stopped at them could not reach the default's. + [Test] + public async Task Default_lookup_skips_a_hand_edited_entry_that_is_not_a_path() { + var registry = new AccountRegistry { + Accounts = [ + new VendorAccount("bad-empty", HarnessId.Claude, "", "Empty", DateTimeOffset.UnixEpoch), + new VendorAccount("bad-nul", HarnessId.Claude, "a\0b", "Nul", DateTimeOffset.UnixEpoch), + .. Registry().Accounts + ] + }; + + var outcome = AccountResolution.Resolve(registry, HarnessId.Claude, null, Personal); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.AccountId).IsEqualTo(PersId); + } + + [Test] + public async Task Registered_id_resolves_to_its_own_directory_and_label() { + var outcome = AccountResolution.Resolve(Registry(), HarnessId.Claude, WorkId, Personal); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(Work)); + await Assert.That(outcome.Account.AccountId).IsEqualTo(WorkId); + await Assert.That(outcome.Account.Label).IsEqualTo("Work"); + } + + [Test] + public async Task Unknown_id_is_refused_with_account_unavailable() { + var outcome = AccountResolution.Resolve(Registry(), HarnessId.Claude, "nope", Personal); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + await Assert.That(outcome.Refusal!).IsEqualTo($"{AccountResolution.Unavailable}: no Claude account nope"); + } + + [Test] + public async Task Id_registered_for_another_vendor_is_refused() { + var outcome = AccountResolution.Resolve(Registry(), HarnessId.Claude, CodexId, Personal); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + } + + [Test] + public async Task Registered_id_whose_directory_is_gone_is_refused_naming_the_directory() { + var gone = Tmp.PathTo(".claude-gone"); + var registry = new AccountRegistry { Accounts = [Entry("c1a0de03-gone", HarnessId.Claude, gone, "Gone")] }; + + var outcome = AccountResolution.Resolve(registry, HarnessId.Claude, "c1a0de03-gone", Personal); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + await Assert.That(outcome.Refusal!).Contains(AccountDirectory.Normalize(gone)); + } + + [Test] + public async Task Resume_with_null_directory_resolves_as_a_fresh_launch() { + var outcome = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, null, null, Personal); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(Personal)); + await Assert.That(outcome.Account.AccountId).IsEqualTo(PersId); + } + + [Test] + public async Task Resume_with_an_empty_directory_and_a_null_id_resolves_as_the_default() { + var outcome = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, null, "", Personal); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(Personal)); + await Assert.That(outcome.Account.AccountId).IsEqualTo(PersId); + } + + [Test] + public async Task Resume_with_an_id_but_no_directory_resolves_the_id_against_the_registry() { + var registered = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, WorkId, null, Personal); + + await Assert.That(registered.Refused).IsFalse(); + await Assert.That(registered.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(Work)); + await Assert.That(registered.Account.AccountId).IsEqualTo(WorkId); + await Assert.That(registered.Account.Label).IsEqualTo("Work"); + + var emptyDirectory = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, WorkId, "", Personal); + + await Assert.That(emptyDirectory.Account!.AccountId).IsEqualTo(WorkId); + + var removed = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, "removed", null, Personal); + + await Assert.That(removed.Account).IsNull(); + await Assert.That(removed.Refusal!).StartsWith(AccountResolution.Unavailable); + } + + [Test] + public async Task Resume_with_a_registered_id_resumes_under_the_stored_directory() { + var stored = Tmp.CreateDir("moved"); + var outcome = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, WorkId, stored, Personal); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(stored)); + await Assert.That(outcome.Account.AccountId).IsEqualTo(WorkId); + await Assert.That(outcome.Account.Label).IsEqualTo("Work"); + } + + [Test] + public async Task Resume_with_a_registered_id_now_removed_is_refused() { + var outcome = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, "removed", Work, Personal); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + await Assert.That(AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, CodexId, Work, Personal).Refused).IsTrue(); + } + + [Test] + public async Task Resume_with_a_registered_id_whose_directory_is_gone_is_refused() { + var gone = Tmp.PathTo("vanished"); + var outcome = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, WorkId, gone, Personal); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + await Assert.That(outcome.Refusal!).Contains(gone); + } + + [Test] + public async Task Resume_with_null_id_resumes_under_the_stored_directory_while_it_exists() { + var stored = Tmp.CreateDir("adhoc"); + var outcome = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, null, stored, Personal); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.Directory).IsEqualTo(AccountDirectory.Normalize(stored)); + await Assert.That(outcome.Account.AccountId).IsNull(); + await Assert.That(outcome.Account.Label).IsEqualTo("adhoc"); + + var gone = AccountResolution.ResolveForResume(Registry(), HarnessId.Claude, null, Tmp.PathTo("vanished"), Personal); + + await Assert.That(gone.Refusal!).StartsWith(AccountResolution.Unavailable); + } + + [Test] + public async Task Argument_prefix_matching_one_id_resolves() { + var outcome = AccountResolution.ResolveArgument(Registry(), HarnessId.Claude, "C1A0DE01"); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.AccountId).IsEqualTo(WorkId); + await Assert.That(outcome.Account.Directory).IsEqualTo(AccountDirectory.Normalize(Work)); + await Assert.That(outcome.Account.Label).IsEqualTo("Work"); + } + + [Test] + public async Task Argument_prefix_matching_two_ids_is_refused_listing_both() { + var outcome = AccountResolution.ResolveArgument(Registry(), HarnessId.Claude, "c1a0"); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + await Assert.That(outcome.Refusal!).Contains(WorkId); + await Assert.That(outcome.Refusal!).Contains(PersId); + } + + [Test] + public async Task Argument_prefix_only_matches_ids_of_the_requested_vendor() { + var outcome = AccountResolution.ResolveArgument(Registry(), HarnessId.Claude, "c0de"); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + } + + [Test] + public async Task Argument_directory_resolves_by_same_directory() { + var outcome = AccountResolution.ResolveArgument(Registry(), HarnessId.Claude, Work + Path.DirectorySeparatorChar); + + await Assert.That(outcome.Refused).IsFalse(); + await Assert.That(outcome.Account!.AccountId).IsEqualTo(WorkId); + await Assert.That(outcome.Account.Directory).IsEqualTo(AccountDirectory.Normalize(Work)); + } + + [Test] + public async Task Argument_unregistered_directory_is_refused_with_the_add_remedy() { + var other = Tmp.CreateDir("unregistered"); + var outcome = AccountResolution.ResolveArgument(Registry(), HarnessId.Claude, other); + + await Assert.That(outcome.Account).IsNull(); + await Assert.That(outcome.Refusal!).IsEqualTo( + $"{AccountResolution.Unavailable}: {other} is not a registered Claude account; run kcap accounts add"); + } + + [Test] + public async Task Argument_that_is_not_a_path_is_refused_rather_than_thrown() { + var outcome = AccountResolution.ResolveArgument(Registry(), HarnessId.Claude, "a\0b"); + + await Assert.That(outcome.Refusal!).StartsWith(AccountResolution.Unavailable); + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountSnapshotBuilderTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountSnapshotBuilderTests.cs new file mode 100644 index 0000000000..db6cba635b --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountSnapshotBuilderTests.cs @@ -0,0 +1,99 @@ +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; + +namespace Capacitor.Cli.Core.Tests.Unit.Accounts; + +public class AccountSnapshotBuilderTests { + [TempHome] public required TempHome Home { get; init; } + + static VendorAccount Account(string id, HarnessId vendor, string directory, string label) => + new(id, vendor, AccountDirectory.Normalize(directory), label, DateTimeOffset.UnixEpoch); + + static AccountRegistry Registry(long revision, params VendorAccount[] accounts) => new() { Revision = revision, Accounts = accounts }; + + string WiredClaude() { + var dir = Home.CreateDir(".claude-work"); + Home.CreateFile(".claude-work/settings.json", """{ "enabledPlugins": { "kcap@kcap": true } }"""); + return dir; + } + + [Test] + public async Task Builds_one_view_per_account_with_key_label_and_recording_state() { + var claude = WiredClaude(); + var codex = Home.CreateDir(".codex-b"); + var registry = Registry(7, + Account("c1", HarnessId.Claude, claude, "Work"), + Account("x1", HarnessId.Codex, codex, "Spare")); + + var snapshot = AccountSnapshotBuilder.Build(registry, "host-1", new Dictionary(), Home); + + await Assert.That(snapshot.HostId).IsEqualTo("host-1"); + await Assert.That(snapshot.Revision).IsEqualTo(7L); + await Assert.That(snapshot.Accounts.Length).IsEqualTo(2); + await Assert.That(snapshot.Accounts[0]).IsEqualTo( + new AccountViewDto("c1", "claude", AccountDirectory.Normalize(claude), DirectoryKey.Of(claude), "Work", "installed")); + await Assert.That(snapshot.Accounts[1]).IsEqualTo( + new AccountViewDto("x1", "codex", AccountDirectory.Normalize(codex), DirectoryKey.Of(codex), "Spare", "not_wired")); + } + + [Test] + public async Task Defaults_carry_directory_and_key_only() { + var claude = WiredClaude(); + var codex = Home.PathTo(".codex"); + var registry = Registry(1, Account("c1", HarnessId.Claude, claude, "Work")); + + var snapshot = AccountSnapshotBuilder.Build(registry, "host-1", + new Dictionary { [HarnessId.Codex] = codex, [HarnessId.Claude] = claude }, Home); + + await Assert.That(snapshot.Defaults.Length).IsEqualTo(2); + await Assert.That(snapshot.Defaults[0]).IsEqualTo(new DefaultDirectoryDto("claude", claude, DirectoryKey.Of(claude))); + await Assert.That(snapshot.Defaults[1]).IsEqualTo(new DefaultDirectoryDto("codex", codex, DirectoryKey.Of(codex))); + await Assert.That(snapshot.Defaults[0].DirectoryKey).IsEqualTo(snapshot.Accounts[0].DirectoryKey); + } + + [Test] + public async Task Unregistered_default_matches_no_view_by_key() { + var claude = WiredClaude(); + var codex = Home.CreateDir(".codex-b"); + var registry = Registry(1, Account("c1", HarnessId.Claude, claude, "Work")); + + var snapshot = AccountSnapshotBuilder.Build(registry, "host-1", + new Dictionary { [HarnessId.Codex] = codex }, Home); + + await Assert.That(snapshot.Accounts.Any(a => a.DirectoryKey == snapshot.Defaults[0].DirectoryKey)).IsFalse(); + } + + [Test] + public async Task Keys_equal_across_casing_off_linux() { + var work = Home.CreateDir("Work"); + var registry = Registry(1, Account("c1", HarnessId.Claude, work, "Work")); + + var snapshot = AccountSnapshotBuilder.Build(registry, "host-1", + new Dictionary { [HarnessId.Claude] = Home.PathTo("work") }, Home); + + if (OperatingSystem.IsLinux()) + await Assert.That(snapshot.Defaults[0].DirectoryKey).IsNotEqualTo(snapshot.Accounts[0].DirectoryKey); + else + await Assert.That(snapshot.Defaults[0].DirectoryKey).IsEqualTo(snapshot.Accounts[0].DirectoryKey); + } + + [Test] + public async Task A_registry_entry_whose_directory_cannot_be_keyed_is_skipped() { + var claude = WiredClaude(); + var registry = Registry(1, + Account("c1", HarnessId.Claude, claude, "Work"), + new VendorAccount("bad", HarnessId.Codex, "/nul\0dir", "Bad", DateTimeOffset.UnixEpoch)); + + var snapshot = AccountSnapshotBuilder.Build(registry, "host-1", new Dictionary(), Home); + + await Assert.That(snapshot.Accounts.Select(a => a.Id).ToArray()).IsEquivalentTo(new[] { "c1" }); + } + + [Test] + public async Task A_default_whose_directory_cannot_be_keyed_carries_the_raw_directory_as_its_key() { + var snapshot = AccountSnapshotBuilder.Build(Registry(1), "host-1", + new Dictionary { [HarnessId.Codex] = "/nul\0dir" }, Home); + + await Assert.That(snapshot.Defaults[0]).IsEqualTo(new DefaultDirectoryDto("codex", "/nul\0dir", "/nul\0dir")); + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountStoreTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountStoreTests.cs index 6a8d814be1..f3398aa643 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountStoreTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountStoreTests.cs @@ -40,6 +40,20 @@ public async Task Mutate_without_a_change_keeps_the_revision() { await Assert.That(store.Load().Revision).IsEqualTo(1L); } + [Test] + public async Task MutateWithRevision_returns_the_revision_it_wrote() { + var store = Store(); + + var (_, first) = store.MutateWithRevision(r => (r with { Accounts = [Claude("/h/.claude-a")] }, 0)); + var (_, second) = store.MutateWithRevision(r => (r with { Accounts = [.. r.Accounts, Claude("/h/.claude-b")] }, 0)); + var (_, none) = store.MutateWithRevision(r => (r, 0)); + + await Assert.That(first).IsEqualTo(1L); + await Assert.That(second).IsEqualTo(2L); + await Assert.That(none).IsEqualTo(2L); + await Assert.That(store.Load().Revision).IsEqualTo(2L); + } + [Test] public async Task Concurrent_mutations_lose_no_account() { var store = Store(); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountsSnapshotDtoTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountsSnapshotDtoTests.cs new file mode 100644 index 0000000000..7a779d92f4 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/AccountsSnapshotDtoTests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Capacitor.Cli.Core.Accounts; + +namespace Capacitor.Cli.Core.Tests.Unit.Accounts; + +public class AccountsSnapshotDtoTests { + static AccountsSnapshotDto Snapshot() => new( + "host-1", 7, + [new AccountViewDto("a1b2", "codex", "/Users/x/.codex-work", "/users/x/.codex-work", "Work", "not_wired")], + [new DefaultDirectoryDto("claude", "/Users/x/.claude", "/users/x/.claude")]); + + [Test] + public async Task Snapshot_serializes_snake_case_with_every_field_present() { + var json = JsonSerializer.Serialize(Snapshot(), AccountsSnapshotJsonContext.Default.AccountsSnapshotDto); + + await Assert.That(json).IsEqualTo( + """{"host_id":"host-1","revision":7,"accounts":[{"id":"a1b2","vendor":"codex","directory":"/Users/x/.codex-work","directory_key":"/users/x/.codex-work","label":"Work","recording_state":"not_wired"}],"defaults":[{"vendor":"claude","directory":"/Users/x/.claude","directory_key":"/users/x/.claude"}]}"""); + } + + [Test] + public async Task Snapshot_round_trips() { + var json = JsonSerializer.Serialize(Snapshot(), AccountsSnapshotJsonContext.Default.AccountsSnapshotDto); + + var back = JsonSerializer.Deserialize(json, AccountsSnapshotJsonContext.Default.AccountsSnapshotDto)!; + + await Assert.That(back.HostId).IsEqualTo("host-1"); + await Assert.That(back.Revision).IsEqualTo(7L); + await Assert.That(back.Accounts[0]).IsEqualTo(Snapshot().Accounts[0]); + await Assert.That(back.Defaults[0]).IsEqualTo(Snapshot().Defaults[0]); + } + + [Test] + public async Task Hub_context_serializes_the_snapshot_the_same_way() { + var viaHub = JsonSerializer.Serialize(Snapshot(), CapacitorJsonContext.Default.AccountsSnapshotDto); + var viaOwnCtx = JsonSerializer.Serialize(Snapshot(), AccountsSnapshotJsonContext.Default.AccountsSnapshotDto); + + await Assert.That(viaHub).IsEqualTo(viaOwnCtx); + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/DirectoryKeyTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/DirectoryKeyTests.cs new file mode 100644 index 0000000000..12370fdc32 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/DirectoryKeyTests.cs @@ -0,0 +1,42 @@ +using Capacitor.Cli.Core.Accounts; + +namespace Capacitor.Cli.Core.Tests.Unit.Accounts; + +public class DirectoryKeyTests { + [TempDir] public required TempDir Tmp { get; init; } + + [Test] + public async Task Same_directory_spelled_with_a_trailing_separator_has_one_key() { + var dir = Tmp.CreateDir("work"); + + await Assert.That(DirectoryKey.Of(dir + Path.DirectorySeparatorChar)).IsEqualTo(DirectoryKey.Of(dir)); + } + + [Test] + public async Task Symlinked_spelling_keys_to_the_physical_directory() { + var real = Tmp.CreateDir("real"); + var link = Tmp.PathTo("link"); + + try { + Directory.CreateSymbolicLink(link, real); + } catch (Exception ex) when (OperatingSystem.IsWindows() && ex is UnauthorizedAccessException or IOException) { + Skip.Test("creating a symlink needs a privilege this Windows account lacks"); + } + + await Assert.That(DirectoryKey.Of(link)).IsEqualTo(DirectoryKey.Of(real)); + } + + [Test] + public async Task Case_differs_keys_equal_except_on_linux() { + var work = Tmp.CreateDir("Work"); + var key = DirectoryKey.Of(work); + + if (OperatingSystem.IsLinux()) { + await Assert.That(key).IsEqualTo(AccountDirectory.Normalize(work)); + await Assert.That(DirectoryKey.Of(Tmp.PathTo("WORK"))).IsNotEqualTo(key); + } else { + await Assert.That(key).IsEqualTo(key.ToLowerInvariant()); + await Assert.That(DirectoryKey.Of(Tmp.PathTo("WORK"))).IsEqualTo(key); + } + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/RecordingStatesTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/RecordingStatesTests.cs new file mode 100644 index 0000000000..a9e457d636 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/RecordingStatesTests.cs @@ -0,0 +1,13 @@ +using Capacitor.Cli.Core.Accounts; + +namespace Capacitor.Cli.Core.Tests.Unit.Accounts; + +public class RecordingStatesTests { + [Test] + [Arguments(RecordingState.Recording, "recording")] + [Arguments(RecordingState.Installed, "installed")] + [Arguments(RecordingState.Broken, "broken")] + [Arguments(RecordingState.NotWired, "not_wired")] + public async Task Each_state_has_its_snake_case_token(RecordingState state, string token) => + await Assert.That(RecordingStates.Token(state)).IsEqualTo(token); +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Accounts/VendorHomeVariableTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/VendorHomeVariableTests.cs new file mode 100644 index 0000000000..19d5ba76a5 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Accounts/VendorHomeVariableTests.cs @@ -0,0 +1,35 @@ +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; + +namespace Capacitor.Cli.Core.Tests.Unit.Accounts; + +public class VendorHomeVariableTests { + [TempHome] public required TempHome Home { get; init; } + + /// The trailing separator spelling still names the built-in directory. + [Test] + [Arguments(HarnessId.Claude, ".claude", "CLAUDE_CONFIG_DIR")] + [Arguments(HarnessId.Codex, ".codex", "CODEX_HOME")] + public async Task Built_in_directory_plans_a_removal(HarnessId vendor, string builtIn, string variable) { + await Assert.That(VendorHomeVariable.Plan(vendor, Home.PathTo(builtIn), Home)).IsEqualTo((variable, (string?)null)); + await Assert.That(VendorHomeVariable.Plan(vendor, Home.PathTo(builtIn) + Path.DirectorySeparatorChar, Home)) + .IsEqualTo((variable, (string?)null)); + } + + [Test] + [Arguments(HarnessId.Claude, ".claude-work", "CLAUDE_CONFIG_DIR")] + [Arguments(HarnessId.Codex, ".codex-work", "CODEX_HOME")] + public async Task Custom_directory_plans_a_set(HarnessId vendor, string custom, string variable) { + var directory = Home.PathTo(custom); + + await Assert.That(VendorHomeVariable.Plan(vendor, directory, Home)).IsEqualTo((variable, (string?)directory)); + } + + /// The built-in directory is defined for Claude and Codex only, so this also pins that the + /// plan never asks for one it does not need. + [Test] + public async Task Vendor_without_a_home_variable_plans_nothing() { + await Assert.That(VendorHomeVariable.NameOf(HarnessId.Cursor)).IsNull(); + await Assert.That(VendorHomeVariable.Plan(HarnessId.Cursor, Home.PathTo(".cursor"), Home)).IsNull(); + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/AgentStartArgsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/AgentStartArgsTests.cs index a945c9a7ed..fb1cdec129 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/AgentStartArgsTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/AgentStartArgsTests.cs @@ -117,11 +117,14 @@ public async Task Title_flag_requires_a_value() { } [Test] - public async Task Title_flag_followed_by_another_flag_is_an_error() { - var a = AgentStartArgs.Parse(["claude", "--title", "--private"]); + [Arguments("--private")] + [Arguments("--account=a1b2")] + public async Task Title_flag_followed_by_another_flag_is_an_error(string flag) { + var a = AgentStartArgs.Parse(["claude", "--title", flag]); await Assert.That(a.Error).IsEqualTo("--title requires a value"); await Assert.That(a.Title).IsNull(); + await Assert.That(a.Account).IsNull(); } [Test] @@ -158,4 +161,37 @@ public async Task Empty_equals_form_title_is_an_error() { await Assert.That(a.Error).IsEqualTo("--title must not be blank"); await Assert.That(a.Title).IsNull(); } + + [Test] + public async Task Account_flag_with_a_value_parses() { + var spaced = AgentStartArgs.Parse(["codex", "--account", "ab12cd", "--private", "--", "fix"]); + var joined = AgentStartArgs.Parse(["claude", "--account=~/.claude-work", "-d"]); + + await Assert.That(spaced.Error).IsNull(); + await Assert.That(spaced.Account).IsEqualTo("ab12cd"); + await Assert.That(spaced.Private).IsTrue(); + await Assert.That(spaced.Passthrough).IsEquivalentTo(new[] { "fix" }); + await Assert.That(joined.Error).IsNull(); + await Assert.That(joined.Account).IsEqualTo("~/.claude-work"); + await Assert.That(joined.Detached).IsTrue(); + await Assert.That(AgentStartArgs.Parse(["claude"]).Account).IsNull(); + } + + [Test] + public async Task Account_flag_without_a_value_is_an_error() { + string[][] cases = [ + ["claude", "--account"], + ["claude", "--account", ""], + ["claude", "--account", "--private"], + ["claude", "--account", "--title=t"], + ["claude", "--account="], + ]; + + foreach (var args in cases) { + var a = AgentStartArgs.Parse(args); + + await Assert.That(a.Error).IsEqualTo("--account requires a value").Because(string.Join(' ', args)); + await Assert.That(a.Account).IsNull(); + } + } } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/LaunchAgentCommandWireFormatTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/LaunchAgentCommandWireFormatTests.cs index 5cc447736e..ff65514a62 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/LaunchAgentCommandWireFormatTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/LaunchAgentCommandWireFormatTests.cs @@ -309,6 +309,21 @@ public async Task InactivityBoundSeconds_roundtrips_and_defaults_null_when_absen await Assert.That(back.InactivityBoundSeconds).IsEqualTo(180); } + [Test] + public async Task AccountId_roundtrips_and_defaults_null_when_absent() { + var legacyJson = """{"agent_id":"a1","model":"m","repo_path":"/r","vendor":"claude"}"""; + var legacy = JsonSerializer.Deserialize(legacyJson, CapacitorJsonContext.Default.LaunchAgentCommand); + await Assert.That(legacy.AccountId).IsNull(); + + var cmd = new LaunchAgentCommand( + AgentId: "a1", Prompt: null, Model: "m", Effort: null, RepoPath: "/r", + Tools: null, AttachmentIds: null, Vendor: "claude", AccountId: "a1b2"); + var json = JsonSerializer.Serialize(cmd, ServerWireOptions); + await Assert.That(json).Contains("\"account_id\":\"a1b2\""); + var back = JsonSerializer.Deserialize(json, CapacitorJsonContext.Default.LaunchAgentCommand); + await Assert.That(back.AccountId).IsEqualTo("a1b2"); + } + [Test] public async Task RequesterDisplay_roundtrips_and_defaults_null_when_absent() { // An old server never sends this field (issue #481) — the daemon must default to null, diff --git a/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/DaemonStatusDtoTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/DaemonStatusDtoTests.cs index e459923929..3a23e63be0 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/DaemonStatusDtoTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/DaemonStatusDtoTests.cs @@ -1,12 +1,13 @@ using System.Text.Json; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.LocalIpc; namespace Capacitor.Cli.Core.Tests.Unit.LocalIpc; /// -/// Phase B (D2): the additive daemon self-report DTOs round-trip through the source-gen -/// , and the new trailing fields on existing wire types stay -/// backward-compatible (an old server's JSON without them still deserializes). +/// The additive daemon self-report DTOs round-trip through the source-gen +/// , and trailing fields on existing wire types stay +/// backward-compatible (JSON without them still deserializes). /// public class DaemonStatusDtoTests { [Test] @@ -125,4 +126,74 @@ public async Task DaemonInfoDto_from_an_older_daemon_has_null_vendor_models() { var back = JsonSerializer.Deserialize(json, StatusIpcJsonContext.Default.DaemonStatusDto)!; await Assert.That(back.Daemon.VendorModels).IsNull(); } + + static AccountsSnapshotDto Snapshot() => new( + "host-1", 3, + [new AccountViewDto("a1b2", "claude", "/Users/x/.claude-work", "/users/x/.claude-work", "Work", "recording")], + [new DefaultDirectoryDto("claude", "/Users/x/.claude", "/users/x/.claude")]); + + [Test] + public async Task DaemonConnect_carries_account_launch_support_the_snapshot_and_per_account_models() { + var connect = new DaemonConnect("d", "mac", [], 1, [], + SupportsAccountLaunches: true, + Accounts: Snapshot(), + VendorModelsByAccount: new Dictionary(StringComparer.Ordinal) { + ["a1b2"] = [new("opus", "Opus")], + }); + + var json = JsonSerializer.Serialize(connect, CapacitorJsonContext.Default.DaemonConnect); + var back = JsonSerializer.Deserialize(json, CapacitorJsonContext.Default.DaemonConnect); + + await Assert.That(json).Contains("\"supports_account_launches\":true"); + await Assert.That(json).Contains("\"vendor_models_by_account\":{\"a1b2\":["); + await Assert.That(back.SupportsAccountLaunches).IsTrue(); + await Assert.That(back.Accounts!.HostId).IsEqualTo("host-1"); + await Assert.That(back.Accounts.Accounts[0].Id).IsEqualTo("a1b2"); + await Assert.That(back.Accounts.Defaults[0].Vendor).IsEqualTo("claude"); + await Assert.That(back.VendorModelsByAccount!["a1b2"][0].Value).IsEqualTo("opus"); + } + + [Test] + public async Task DaemonConnect_from_an_older_daemon_reads_the_account_members_as_absent() { + var json = JsonSerializer.Serialize(new DaemonConnect("d", "mac", [], 1, []), CapacitorJsonContext.Default.DaemonConnect); + var stripped = System.Text.RegularExpressions.Regex.Replace( + json, ",\"(supports_account_launches|accounts|vendor_models_by_account)\":(true|false|null)", ""); + + var back = JsonSerializer.Deserialize(stripped, CapacitorJsonContext.Default.DaemonConnect); + + await Assert.That(stripped).DoesNotContain("supports_account_launches"); + await Assert.That(back.SupportsAccountLaunches).IsFalse(); + await Assert.That(back.Accounts).IsNull(); + await Assert.That(back.VendorModelsByAccount).IsNull(); + } + + [Test] + public async Task DaemonInfoDto_vendor_models_by_account_round_trips_through_status_context() { + var dto = new DaemonStatusDto( + new DaemonInfoDto("d", "1", "http://s", "connected", 1, 0, + VendorModelsByAccount: new Dictionary(StringComparer.Ordinal) { ["a1b2"] = [] }), + []); + + var json = JsonSerializer.Serialize(dto, StatusIpcJsonContext.Default.DaemonStatusDto); + var back = JsonSerializer.Deserialize(json, StatusIpcJsonContext.Default.DaemonStatusDto)!; + + await Assert.That(json).Contains("\"vendor_models_by_account\":{\"a1b2\":[]}"); + await Assert.That(back.Daemon.VendorModelsByAccount!["a1b2"]).IsEmpty(); + } + + [Test] + public async Task Registration_and_live_agent_entries_carry_the_account_members() { + var registered = new AgentRegistered("a1", null, null, null, null, + AccountDirectory: "/Users/x/.claude-work", AccountId: "a1b2", AccountLabel: "Work"); + var live = new LiveAgentInfo("a1", "Default", DateTimeOffset.UnixEpoch, + AccountDirectory: "/Users/x/.claude-work", AccountId: "a1b2", AccountLabel: "Work"); + + var registeredBack = JsonSerializer.Deserialize( + JsonSerializer.Serialize(registered, CapacitorJsonContext.Default.AgentRegistered), CapacitorJsonContext.Default.AgentRegistered); + var liveBack = JsonSerializer.Deserialize( + JsonSerializer.Serialize(live, CapacitorJsonContext.Default.LiveAgentInfo), CapacitorJsonContext.Default.LiveAgentInfo); + + await Assert.That(registeredBack).IsEqualTo(registered); + await Assert.That(liveBack).IsEqualTo(live); + } } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecAccountsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecAccountsTests.cs new file mode 100644 index 0000000000..0e844dc24b --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecAccountsTests.cs @@ -0,0 +1,104 @@ +using Capacitor.Cli.Core.LocalIpc; + +namespace Capacitor.Cli.Core.Tests.Unit.LocalIpc; + +public class FrameCodecAccountsTests { + const int LengthPrefix = 4; + + static async Task RoundTrip(LocalFrame f) { + using var ms = new MemoryStream(); + await FrameCodec.WriteAsync(ms, f, default); + ms.Position = 0; + return (await FrameCodec.ReadAsync(ms, default))!; + } + + static LocalFrame SpawnFrame(AgentStartTitle? title = null, string? accountId = null) + => FrameCodec.Spawn("claude", WorkLocation.BorrowedCwd, false, "/repo", ["fix it"], 80, 24, title, accountId); + + [Test] + public async Task Spawn_round_trips_an_account_id() { + var read = await RoundTrip(SpawnFrame(new AgentStartTitle("Fix it", Derived: false), accountId: "abc")); + + var (_, _, _, _, args, _, _, title, accountId) = FrameCodec.Spawn(read); + + await Assert.That(accountId).IsEqualTo("abc"); + await Assert.That(args).IsEquivalentTo(new[] { "fix it" }); + await Assert.That(title).IsEqualTo(new AgentStartTitle("Fix it", Derived: false)); + } + + [Test] + public async Task Spawn_with_account_but_no_title_round_trips() { + var (_, _, _, _, _, _, _, title, accountId) = FrameCodec.Spawn(await RoundTrip(SpawnFrame(accountId: "abc"))); + + await Assert.That(title).IsNull(); + await Assert.That(accountId).IsEqualTo("abc"); + } + + [Test] + public async Task Spawn_without_an_account_parses_null() { + var (_, _, _, _, _, _, _, _, accountId) = FrameCodec.Spawn(await RoundTrip(SpawnFrame())); + + await Assert.That(accountId).IsNull(); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Spawn_without_account_bytes_parses_null(bool withTitle) { + // A frame from a CLI that predates the account ends right after the title. + var title = withTitle ? new AgentStartTitle("Fix it", Derived: true) : (AgentStartTitle?)null; + var complete = SpawnFrame(title).Bytes; + var older = new LocalFrame(FrameType.Spawn) { Bytes = complete[..^LengthPrefix] }; + + var (_, _, _, _, args, _, _, parsedTitle, accountId) = FrameCodec.Spawn(older); + + await Assert.That(accountId).IsNull(); + await Assert.That(parsedTitle).IsEqualTo(title); + await Assert.That(args).IsEquivalentTo(new[] { "fix it" }); + } + + [Test] + public async Task Spawn_account_rides_after_every_field_an_older_daemon_reads() { + var plain = SpawnFrame(accountId: null).Bytes; + var withAccount = SpawnFrame(accountId: "abc").Bytes; + + var olderLayout = plain[..^LengthPrefix]; + await Assert.That(withAccount.Length).IsGreaterThan(plain.Length); + await Assert.That(withAccount[..olderLayout.Length]).IsEquivalentTo(olderLayout); + } + + [Test] + public async Task Spawn_with_a_truncated_account_is_a_protocol_error() { + var bytes = SpawnFrame(accountId: "abc").Bytes; + var frame = new LocalFrame(FrameType.Spawn) { Bytes = bytes[..^1] }; + + await Assert.That(() => FrameCodec.Spawn(frame)).Throws(); + } + + [Test] + public async Task AccountsSnapshot_frame_carries_the_json_as_text() { + const string json = """{"host_id":"h","revision":3,"accounts":[],"defaults":[]}"""; + + var read = await RoundTrip(FrameCodec.AccountsSnapshot(json)); + + await Assert.That(read.Type).IsEqualTo(FrameType.AccountsSnapshot); + await Assert.That(read.Text).IsEqualTo(json); + } + + [Test] + public async Task AccountsSubscribe_round_trips_with_an_empty_payload() { + var read = await RoundTrip(LocalFrame.AccountsSubscribe()); + + await Assert.That(read.Type).IsEqualTo(FrameType.AccountsSubscribe); + await Assert.That(read.Text).IsEmpty(); + await Assert.That(read.Bytes).IsEmpty(); + } + + [Test] + public async Task Frame_values_are_pinned_32_and_87() { +#pragma warning disable TUnitAssertions0005 + await Assert.That((byte)FrameType.AccountsSubscribe).IsEqualTo((byte)32); + await Assert.That((byte)FrameType.AccountsSnapshot).IsEqualTo((byte)87); +#pragma warning restore TUnitAssertions0005 + } +} diff --git a/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecTests.cs index acd74845d8..25d3b930e0 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/FrameCodecTests.cs @@ -3,6 +3,9 @@ namespace Capacitor.Cli.Core.Tests.Unit.LocalIpc; public class FrameCodecTests { + // A frame without a title or an account ends with the title byte and the empty account's 4-byte length prefix. + const int TitleByteAndEmptyAccount = 5; + static async Task RoundTrip(LocalFrame f) { using var ms = new MemoryStream(); await FrameCodec.WriteAsync(ms, f, default); @@ -48,7 +51,7 @@ public async Task Spawn_round_trips_vendor_cwd_args_worklocation_and_private() { var built = FrameCodec.Spawn("codex", WorkLocation.OwnedWorktree, priv, "/repo", ["--model", "opus", "fix it"], 100, 30); var r = await RoundTrip(built); await Assert.That(r.Type).IsEqualTo(FrameType.Spawn); - var (vendor, work, isPrivate, cwd, args, cols, rows, title) = FrameCodec.Spawn(r); + var (vendor, work, isPrivate, cwd, args, cols, rows, title, _) = FrameCodec.Spawn(r); await Assert.That(vendor).IsEqualTo("codex"); await Assert.That(work).IsEqualTo(WorkLocation.OwnedWorktree); await Assert.That(isPrivate).IsEqualTo(priv); @@ -67,7 +70,7 @@ public async Task Spawn_round_trips_the_start_title_and_whether_it_was_derived(b var built = FrameCodec.Spawn( "claude", WorkLocation.BorrowedCwd, false, "/repo", ["fix it"], 80, 24, new AgentStartTitle("Fix it ✓", derived)); - var (_, _, isPrivate, _, args, _, _, title) = FrameCodec.Spawn(await RoundTrip(built)); + var (_, _, isPrivate, _, args, _, _, title, _) = FrameCodec.Spawn(await RoundTrip(built)); await Assert.That(isPrivate).IsFalse(); await Assert.That(args).IsEquivalentTo(new[] { "fix it" }); @@ -82,7 +85,7 @@ public async Task Spawn_title_rides_after_every_field_an_older_daemon_reads() { var titled = FrameCodec.Spawn("claude", WorkLocation.BorrowedCwd, false, "/repo", ["fix it"], 80, 24, new AgentStartTitle("Fix it", Derived: false)).Bytes; - var olderLayout = plain[..^1]; + var olderLayout = plain[..^TitleByteAndEmptyAccount]; await Assert.That(titled.Length).IsGreaterThan(plain.Length); await Assert.That(titled[..olderLayout.Length]).IsEquivalentTo(olderLayout); } @@ -90,9 +93,9 @@ public async Task Spawn_title_rides_after_every_field_an_older_daemon_reads() { [Test] public async Task Spawn_frame_ending_at_the_private_byte_carries_no_title() { var plain = FrameCodec.Spawn("claude", WorkLocation.BorrowedCwd, false, "/repo", [], 80, 24).Bytes; - var frame = new LocalFrame(FrameType.Spawn) { Bytes = plain[..^1] }; + var frame = new LocalFrame(FrameType.Spawn) { Bytes = plain[..^TitleByteAndEmptyAccount] }; - var (_, _, isPrivate, _, _, _, _, title) = FrameCodec.Spawn(frame); + var (_, _, isPrivate, _, _, _, _, title, _) = FrameCodec.Spawn(frame); await Assert.That(isPrivate).IsFalse(); await Assert.That(title).IsNull(); @@ -101,9 +104,9 @@ public async Task Spawn_frame_ending_at_the_private_byte_carries_no_title() { [Test] public async Task Spawn_with_an_unknown_title_kind_carries_no_title() { var plain = FrameCodec.Spawn("claude", WorkLocation.BorrowedCwd, false, "/repo", [], 80, 24).Bytes; - plain[^1] = 0x7f; + plain[^TitleByteAndEmptyAccount] = 0x7f; - var (_, _, _, _, _, _, _, title) = FrameCodec.Spawn(new LocalFrame(FrameType.Spawn) { Bytes = plain }); + var (_, _, _, _, _, _, _, title, _) = FrameCodec.Spawn(new LocalFrame(FrameType.Spawn) { Bytes = plain }); await Assert.That(title).IsNull(); } @@ -119,7 +122,7 @@ public async Task Spawn_without_trailing_flag_defaults_to_private() { ms.Write([0, 0, 0, 0]); // argCount=0 (no trailing private byte) var frame = new LocalFrame(FrameType.Spawn) { Bytes = ms.ToArray() }; - var (vendor, _, isPrivate, _, _, _, _, title) = FrameCodec.Spawn(frame); + var (vendor, _, isPrivate, _, _, _, _, title, _) = FrameCodec.Spawn(frame); await Assert.That(vendor).IsEqualTo("claude"); await Assert.That(isPrivate).IsTrue(); await Assert.That(title).IsNull(); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/StatusIpcJsonTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/StatusIpcJsonTests.cs index 2c16c06393..460bccab7e 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/StatusIpcJsonTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/LocalIpc/StatusIpcJsonTests.cs @@ -4,9 +4,9 @@ namespace Capacitor.Cli.Core.Tests.Unit.LocalIpc; /// -/// Exact-JSON pins for the DaemonStatus payload (spec §4.1): snake_case, every field always -/// emitted (absent = null, never omitted), field order as declared, ISO-8601 UTC created_at. -/// Plus the §5 forward-compat pin: unknown members deserialize without error. +/// Exact-JSON pins for the DaemonStatus payload: snake_case, every field always emitted +/// (absent = null, never omitted), field order as declared, ISO-8601 UTC created_at. +/// Plus the forward-compat pin: unknown members deserialize without error. /// public class StatusIpcJsonTests { [Test] @@ -29,12 +29,12 @@ public async Task DaemonStatus_serializes_exactly_with_nulls_present_and_pinned_ var json = JsonSerializer.Serialize(dto, StatusIpcJsonContext.Default.DaemonStatusDto); await Assert.That(json).IsEqualTo( - """{"daemon":{"name":"main","version":"0.12.3","server_url":"https://tenant.example.com","connection":"connected","max_agents":5,"active_agents":1,"pid":4242,"instance_id":"inst-abc","supported_vendors":null,"vendor_models":null,"credential":null,"profile":null},"agents":[{"id":"agent-abc123","kind":"review-flow","vendor":"codex","repo_path":"/Users/x/dev/repo","status":"Live","flow_run_id":"flow_1","flow_role":"reviewer","requester":"github:12345","created_at":"2026-08-01T12:34:56.789Z","model":"gpt-5-codex","requester_display":"Ada Lovelace","has_terminal":null,"title":"Fix the flaky test","transcript_path":null,"worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null},{"id":"agent-b","kind":"agent","vendor":"claude","repo_path":null,"status":"Starting","flow_run_id":null,"flow_role":null,"requester":null,"created_at":"2026-08-01T12:35:00Z","model":null,"requester_display":null,"has_terminal":null,"title":null,"transcript_path":null,"worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}],"pending":null,"busy":null}"""); + """{"daemon":{"name":"main","version":"0.12.3","server_url":"https://tenant.example.com","connection":"connected","max_agents":5,"active_agents":1,"pid":4242,"instance_id":"inst-abc","supported_vendors":null,"vendor_models":null,"credential":null,"profile":null,"vendor_models_by_account":null},"agents":[{"id":"agent-abc123","kind":"review-flow","vendor":"codex","repo_path":"/Users/x/dev/repo","status":"Live","flow_run_id":"flow_1","flow_role":"reviewer","requester":"github:12345","created_at":"2026-08-01T12:34:56.789Z","model":"gpt-5-codex","requester_display":"Ada Lovelace","has_terminal":null,"title":"Fix the flaky test","transcript_path":null,"worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null},{"id":"agent-b","kind":"agent","vendor":"claude","repo_path":null,"status":"Starting","flow_run_id":null,"flow_role":null,"requester":null,"created_at":"2026-08-01T12:35:00Z","model":null,"requester_display":null,"has_terminal":null,"title":null,"transcript_path":null,"worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}],"pending":null,"busy":null}"""); } [Test] public async Task Unknown_members_in_a_payload_deserialize_without_error() { - // §5 forward compat: an older client must survive a future additive field. + // Forward compat: an older client must survive a future additive field. var json = """{"daemon":{"name":"m","version":"1","server_url":"u","connection":"connected","max_agents":5,"active_agents":0,"future_field":42},"agents":[{"id":"a","kind":"agent","vendor":"codex","repo_path":null,"status":"Live","flow_run_id":null,"flow_role":null,"requester":null,"created_at":"2026-08-01T00:00:00Z","model":null,"future_field":{"x":1}}],"future_top":true}"""; @@ -107,8 +107,8 @@ public async Task Transcript_path_serializes_after_title_and_null_is_emitted() { var json = JsonSerializer.Serialize(withPath, StatusIpcJsonContext.Default.AgentStatusDto); var jsonNull = JsonSerializer.Serialize(without, StatusIpcJsonContext.Default.AgentStatusDto); - await Assert.That(json).EndsWith(""","has_terminal":true,"title":"t","transcript_path":"/home/u/.claude/projects/-repo/abc.jsonl","worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); - await Assert.That(jsonNull).EndsWith(""","has_terminal":true,"title":"t","transcript_path":null,"worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); + await Assert.That(json).EndsWith(""","has_terminal":true,"title":"t","transcript_path":"/home/u/.claude/projects/-repo/abc.jsonl","worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); + await Assert.That(jsonNull).EndsWith(""","has_terminal":true,"title":"t","transcript_path":null,"worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); } [Test] @@ -139,8 +139,8 @@ public async Task Checkout_members_serialize_last_and_nulls_are_emitted() { var json = JsonSerializer.Serialize(borrowed, StatusIpcJsonContext.Default.AgentStatusDto); var jsonNull = JsonSerializer.Serialize(older, StatusIpcJsonContext.Default.AgentStatusDto); - await Assert.That(json).EndsWith(""","transcript_path":"/x.jsonl","worktree_path":"/repo/.capacitor/worktrees/agent-1","work_location":"borrowed","borrowed_from":"/repo/.capacitor/worktrees/agent-1","session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); - await Assert.That(jsonNull).EndsWith(""","transcript_path":"/x.jsonl","worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); + await Assert.That(json).EndsWith(""","transcript_path":"/x.jsonl","worktree_path":"/repo/.capacitor/worktrees/agent-1","work_location":"borrowed","borrowed_from":"/repo/.capacitor/worktrees/agent-1","session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); + await Assert.That(jsonNull).EndsWith(""","transcript_path":"/x.jsonl","worktree_path":null,"work_location":null,"borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); } [Test] @@ -174,8 +174,8 @@ public async Task Session_and_branch_members_serialize_last_and_nulls_are_emitte var json = JsonSerializer.Serialize(full, StatusIpcJsonContext.Default.AgentStatusDto); var jsonNull = JsonSerializer.Serialize(older, StatusIpcJsonContext.Default.AgentStatusDto); - await Assert.That(json).EndsWith(""","borrowed_from":null,"session_id":"0123456789abcdef0123456789abcdef","branch":"feature/sidebar","awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); - await Assert.That(jsonNull).EndsWith(""","borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); + await Assert.That(json).EndsWith(""","borrowed_from":null,"session_id":"0123456789abcdef0123456789abcdef","branch":"feature/sidebar","awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); + await Assert.That(jsonNull).EndsWith(""","borrowed_from":null,"session_id":null,"branch":null,"awaiting_input":null,"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); } /// The daemon's own verdict on whether the agent finished its turn and waits on the user; an @@ -191,7 +191,7 @@ public async Task Awaiting_input_serializes_before_transcript_format_and_never_o var json = JsonSerializer.Serialize(dto, StatusIpcJsonContext.Default.AgentStatusDto); - await Assert.That(json).EndsWith($$$""","branch":"main","awaiting_input":{{{value.ToString().ToLowerInvariant()}}},"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); + await Assert.That(json).EndsWith($$$""","branch":"main","awaiting_input":{{{value.ToString().ToLowerInvariant()}}},"transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); } [Test] @@ -227,9 +227,9 @@ public async Task Transcript_format_serializes_before_live_subagents_and_always_ var envelopes = vendor with { TranscriptFormat = TranscriptFormats.Envelopes }; var unset = vendor with { TranscriptFormat = null }; - await Assert.That(JsonSerializer.Serialize(vendor, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","awaiting_input":null,"transcript_format":"vendor","live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); - await Assert.That(JsonSerializer.Serialize(envelopes, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"envelopes","live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); - await Assert.That(JsonSerializer.Serialize(unset, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); + await Assert.That(JsonSerializer.Serialize(vendor, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","awaiting_input":null,"transcript_format":"vendor","live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); + await Assert.That(JsonSerializer.Serialize(envelopes, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"envelopes","live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); + await Assert.That(JsonSerializer.Serialize(unset, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":null,"live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); } [Test] @@ -248,9 +248,9 @@ public async Task Live_subagents_serializes_before_usage_limit_and_both_are_alwa var zero = unset with { LiveSubagents = 0 }; var positive = unset with { LiveSubagents = 3 }; - await Assert.That(JsonSerializer.Serialize(unset, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"vendor","live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); - await Assert.That(JsonSerializer.Serialize(zero, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"vendor","live_subagents":0,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); - await Assert.That(JsonSerializer.Serialize(positive, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"vendor","live_subagents":3,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null}"""); + await Assert.That(JsonSerializer.Serialize(unset, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"vendor","live_subagents":null,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); + await Assert.That(JsonSerializer.Serialize(zero, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"vendor","live_subagents":0,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); + await Assert.That(JsonSerializer.Serialize(positive, StatusIpcJsonContext.Default.AgentStatusDto)).EndsWith(""","transcript_format":"vendor","live_subagents":3,"usage_limit":null,"terminal_dialog":null,"interruption":null,"working_since":null,"account_directory":null,"account_id":null,"account_label":null}"""); } [Test] @@ -301,8 +301,23 @@ public async Task Interruption_roundtrips_and_is_null_from_an_older_daemon() { var json = JsonSerializer.Serialize(dto, StatusIpcJsonContext.Default.AgentStatusDto); var old = """{"id":"a","kind":"agent","vendor":"claude","repo_path":null,"status":"Running","flow_run_id":null,"flow_role":null,"requester":null,"created_at":"2026-08-01T00:00:00Z","model":null,"requester_display":null}"""; - await Assert.That(json).EndsWith("\"interruption\":\"too_old\",\"working_since\":null}"); + await Assert.That(json).EndsWith("\"interruption\":\"too_old\",\"working_since\":null,\"account_directory\":null,\"account_id\":null,\"account_label\":null}"); await Assert.That(JsonSerializer.Deserialize(json, StatusIpcJsonContext.Default.AgentStatusDto)!.Interruption).IsEqualTo("too_old"); await Assert.That(JsonSerializer.Deserialize(old, StatusIpcJsonContext.Default.AgentStatusDto)!.Interruption).IsNull(); } + + [Test] + public async Task Account_members_serialize_last_and_are_null_from_an_older_daemon() { + var dto = new AgentStatusDto("a", "agent", "claude", null, "Running", null, null, null, DateTime.UnixEpoch, null, null, + AccountDirectory: "/Users/x/.claude-work", AccountId: "a1b2", AccountLabel: "Work"); + var old = """{"id":"a","kind":"agent","vendor":"claude","repo_path":null,"status":"Running","flow_run_id":null,"flow_role":null,"requester":null,"created_at":"2026-08-01T00:00:00Z","model":null,"requester_display":null}"""; + + var json = JsonSerializer.Serialize(dto, StatusIpcJsonContext.Default.AgentStatusDto); + var back = JsonSerializer.Deserialize(old, StatusIpcJsonContext.Default.AgentStatusDto)!; + + await Assert.That(json).EndsWith(""","interruption":null,"working_since":null,"account_directory":"/Users/x/.claude-work","account_id":"a1b2","account_label":"Work"}"""); + await Assert.That(back.AccountDirectory).IsNull(); + await Assert.That(back.AccountId).IsNull(); + await Assert.That(back.AccountLabel).IsNull(); + } } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Resume/ResumeLedgerStoreTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Resume/ResumeLedgerStoreTests.cs index b75874ef9e..4fecb99dfd 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Resume/ResumeLedgerStoreTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Resume/ResumeLedgerStoreTests.cs @@ -17,7 +17,8 @@ public class ResumeLedgerStoreTests { "do it", "opus", "high", "/repo", "/repo/wt", WorkLocation.OwnedWorktree, "sub/dir", "sandbox", "never", "preset", "/mcp.json", "title", true, "server", "user-1", "User One", "owner", "2026-10-17T08:00:00Z", "flow-1", "reviewer", WorktreeBranch: "capacitor/wt", WorktreeIsStandalone: false, WorktreeSourceRepo: "/repo", - PermissionMode: "plan", InactivityBoundSeconds: 600), + PermissionMode: "plan", InactivityBoundSeconds: 600, + AccountDirectory: "/home/u/.claude-work", AccountId: "a1b2", AccountLabel: "Work"), ResumeState.Running, 2, "err", "pred-1", T0.AddMinutes(1), DaemonInstanceId: "instance-1"); [Test] @@ -49,10 +50,11 @@ public async Task An_entry_written_before_the_instance_worktree_and_permission_f var path = store.EntryPath("agent-1"); var json = System.Text.RegularExpressions.Regex.Replace( File.ReadAllText(path), - ",\"(daemon_instance_id|worktree_branch|worktree_is_standalone|worktree_source_repo|permission_mode|inactivity_bound_seconds)\":(\"[^\"]*\"|true|false|[0-9]+)", ""); + ",\"(daemon_instance_id|worktree_branch|worktree_is_standalone|worktree_source_repo|permission_mode|inactivity_bound_seconds|account_directory|account_id|account_label)\":(\"[^\"]*\"|true|false|[0-9]+)", ""); await Assert.That(json).DoesNotContain("worktree_branch"); await Assert.That(json).DoesNotContain("permission_mode"); await Assert.That(json).DoesNotContain("inactivity_bound_seconds"); + await Assert.That(json).DoesNotContain("account_id"); File.WriteAllText(path, json); var read = store.TryRead("agent-1")!; @@ -63,6 +65,9 @@ public async Task An_entry_written_before_the_instance_worktree_and_permission_f await Assert.That(read.Launch.WorktreeSourceRepo).IsNull(); await Assert.That(read.Launch.PermissionMode).IsNull(); await Assert.That(read.Launch.InactivityBoundSeconds).IsNull(); + await Assert.That(read.Launch.AccountDirectory).IsNull(); + await Assert.That(read.Launch.AccountId).IsNull(); + await Assert.That(read.Launch.AccountLabel).IsNull(); } [Test] diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ShellArgumentTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/ShellArgumentTests.cs similarity index 93% rename from test/Capacitor.Cli.Tests.Unit/Commands/ShellArgumentTests.cs rename to test/Capacitor.Cli.Core.Tests.Unit/ShellArgumentTests.cs index 084acea623..4dbd3a30e9 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ShellArgumentTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/ShellArgumentTests.cs @@ -1,6 +1,4 @@ -using Capacitor.Cli.Commands; - -namespace Capacitor.Cli.Tests.Unit.Commands; +namespace Capacitor.Cli.Core.Tests.Unit; public class ShellArgumentTests { [Test] diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerCapabilityRefreshTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerCapabilityRefreshTests.cs index 07ea9ed85b..42b9bfa583 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerCapabilityRefreshTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerCapabilityRefreshTests.cs @@ -67,7 +67,7 @@ public async Task Every_field_but_the_version_comes_from_the_reprobe() { static PtyHostedAgentRuntimeFactory Factory(string vendor, string cliPath) => new(new SpyHostedAgentLauncher(vendor, cliPath), new SpyPtyProcessFactory(), - NullLogger.Instance, TimeProvider.System); + NullLogger.Instance, TimeProvider.System, home: new UserHome("/unused-home")); // The startup fingerprint is what the watcher compares against, so it must describe the same // file the version probe ran, through the same factory-owned path. diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeCommandProbeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeCommandProbeTests.cs index 7b052c63a3..3f0a8fd42f 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeCommandProbeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeCommandProbeTests.cs @@ -1,4 +1,5 @@ using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Daemon.Harness.Claude; namespace Capacitor.Cli.Daemon.Tests.Unit.Harness.Claude; @@ -40,4 +41,24 @@ public async Task ParseCommands_returns_empty_when_no_control_response_is_presen public async Task ParseCommands_returns_empty_on_garbage() { await Assert.That(ClaudeCommandProbe.ParseCommands("not json\n\n")).IsEmpty(); } + + /// An account launch's picker lists the commands its own directory provides, not the + /// daemon's. + [Test] + [NotInParallel] + public async Task Probe_runs_under_the_agents_account_directory() { + using var home = new TempHome(); + var inherited = home.CreateDir(".claude-work"); + var other = home.CreateDir(".claude-other"); + using var env = EnvScope.Exclusive("CLAUDE_CONFIG_DIR", inherited); + + var custom = ClaudeCommandProbe.BuildStartInfo("claude", home.Path, new ResolvedAccount(other, "acct-other", "Other"), home); + var builtIn = ClaudeCommandProbe.BuildStartInfo("claude", home.Path, new ResolvedAccount(home.CreateDir(".claude"), null, ".claude"), home); + var none = ClaudeCommandProbe.BuildStartInfo("claude", home.Path, null, home); + + await Assert.That(custom.Environment["CLAUDE_CONFIG_DIR"]).IsEqualTo((string)other); + await Assert.That(builtIn.Environment.ContainsKey("CLAUDE_CONFIG_DIR")).IsFalse(); + await Assert.That(none.Environment["CLAUDE_CONFIG_DIR"]).IsEqualTo((string)inherited); + await Assert.That(Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR")).IsEqualTo((string)inherited); + } } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeLauncherAccountTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeLauncherAccountTests.cs new file mode 100644 index 0000000000..cb986ca883 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Claude/ClaudeLauncherAccountTests.cs @@ -0,0 +1,125 @@ +using System.Text.Json.Nodes; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness.Claude; +using Capacitor.Cli.Daemon.Harness.Claude; +using Capacitor.Cli.Daemon.Services; +using Capacitor.Cli.Daemon.Tests.Unit.Pty; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Harness.Claude; + +/// The environment's layout is ~/.claude under the temp home and the account is a second +/// directory beside it, each staged so a launch keyed on the wrong one writes visibly there. +public class ClaudeLauncherAccountTests { + [TempHome] public required TempHome Home { get; init; } + + ClaudePaths EnvironmentLayout => new(Home, null); + + ClaudeLauncher NewLauncher(bool configHome) { + var config = new DaemonConfig { ClaudePath = "claude", ServerUrl = "", CapacitorPath = "kcap" }; + if (configHome) config.Home = Home; + + return new(config, TestHarnesses.Under(Home), NullLogger.Instance); + } + + static LauncherContext Ctx(string source, string worktree, ResolvedAccount? account, bool isReviewFlow = false) => + new( + AgentId: "a-1", + SourceRepoPath: source, + Worktree: new WorktreeInfo(Path: worktree, Branch: "wt-branch", SourceRepo: source), + Prompt: "build it", + Model: null, + Effort: null, + Tools: null, + IsReview: false, + IsReviewFlow: isReviewFlow, + Review: null, + ReviewLaunch: null, + Account: account + ); + + /// A layout whose .claude.json registers for the source repo, with + /// the source's project directory present for the worktree's to link to. + static void Stage(ClaudePaths layout, string source, string server) { + Directory.CreateDirectory(layout.ProjectDir(source)); + File.WriteAllText(layout.UserConfigJson, new JsonObject { + ["projects"] = new JsonObject { + [source] = new JsonObject { + ["mcpServers"] = new JsonObject { [server] = new JsonObject { ["command"] = server } } + } + } + }.ToJsonString()); + } + + static bool Trusted(ClaudePaths layout, string worktree) => + JsonNode.Parse(File.ReadAllText(layout.UserConfigJson))?["projects"]?[ClaudeLauncher.NormalizeClaudeProjectKey(worktree)] + ?["hasTrustDialogAccepted"]?.GetValue() == true; + + static JsonObject McpServers(string worktree) => + JsonNode.Parse(File.ReadAllText(Path.Combine(worktree, ".mcp.json")))!["mcpServers"]!.AsObject(); + + /// A review-flow launch, so the bypass-permissions consent is written as well. + [Test] + public async Task Prepare_writes_trust_and_mcp_config_under_the_account_directory() { + var source = Home.CreateDir("repo"); + var worktree = Home.CreateDir("wt"); + var accountDir = Home.CreateDir("claude-work"); + var account = AccountLayouts.Claude(Home, accountDir); + Stage(EnvironmentLayout, source, "env-server"); + Stage(account, source, "account-server"); + var environmentConfig = File.ReadAllText(EnvironmentLayout.UserConfigJson); + + NewLauncher(configHome: true).Prepare( + Ctx(source, worktree, new ResolvedAccount(accountDir, "acct-1", "Work"), isReviewFlow: true)); + + await Assert.That(Trusted(account, worktree)).IsTrue(); + await Assert.That(McpServers(worktree).ContainsKey("account-server")).IsTrue(); + await Assert.That(McpServers(worktree).ContainsKey("env-server")).IsFalse(); + await Assert.That(new DirectoryInfo(account.ProjectDir(worktree)).LinkTarget).IsNotNull(); + await Assert.That(File.Exists(account.UserSettings)).IsTrue(); + + await Assert.That(File.ReadAllText(EnvironmentLayout.UserConfigJson)).IsEqualTo(environmentConfig); + await Assert.That(Path.Exists(EnvironmentLayout.ProjectDir(worktree))).IsFalse(); + await Assert.That(File.Exists(EnvironmentLayout.UserSettings)).IsFalse(); + } + + /// The config carries no home: a launch without an account must not need one. + [Test] + public async Task Prepare_with_no_account_uses_the_environment_layout() { + var source = Home.CreateDir("repo"); + var worktree = Home.CreateDir("wt"); + Stage(EnvironmentLayout, source, "env-server"); + + NewLauncher(configHome: false).Prepare(Ctx(source, worktree, account: null)); + + await Assert.That(Trusted(EnvironmentLayout, worktree)).IsTrue(); + await Assert.That(McpServers(worktree).ContainsKey("env-server")).IsTrue(); + await Assert.That(new DirectoryInfo(EnvironmentLayout.ProjectDir(worktree)).LinkTarget).IsNotNull(); + } + + [Test] + public async Task Teardown_removes_the_project_symlink_from_the_account_directory() { + var source = Home.CreateDir("repo"); + var worktree = Home.CreateDir("wt"); + var accountDir = Home.CreateDir("claude-work"); + var account = AccountLayouts.Claude(Home, accountDir); + var resolved = new ResolvedAccount(accountDir, "acct-1", "Work"); + Stage(account, source, "account-server"); + var launcher = NewLauncher(configHome: true); + launcher.Prepare(Ctx(source, worktree, resolved)); + await Assert.That(new DirectoryInfo(account.ProjectDir(worktree)).LinkTarget).IsNotNull(); + + launcher.Cleanup(new AgentInstance( + "a-1", "build it", null, null, source, "claude", + new PtyHostedAgentRuntime("claude", new StubPtyProcess(), TimeProvider.System), + new WorktreeInfo(worktree, "wt-branch", source), new CancellationTokenSource(), resolved + ) { + ActivityClock = new AgentActivityClock(TimeProvider.System), + CreatedAt = DateTime.UtcNow, + LastOutputAt = DateTime.UtcNow + }); + + await Assert.That(Path.Exists(account.ProjectDir(worktree))).IsFalse(); + await Assert.That(Directory.Exists(account.ProjectDir(source))).IsTrue(); + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexHostedAgentRuntimeFactoryTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexHostedAgentRuntimeFactoryTests.cs index eb755d3f64..ef1118b25b 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexHostedAgentRuntimeFactoryTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexHostedAgentRuntimeFactoryTests.cs @@ -1,4 +1,9 @@ +using System.Collections.Concurrent; +using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness.Codex; using Capacitor.Cli.Daemon.Acp; using Capacitor.Cli.Daemon.Harness.Codex; using Capacitor.Cli.Daemon.Services; @@ -59,7 +64,7 @@ sealed class FakeAcpProcess : IAcpProcess { } CodexLauncher NewLauncher() => - new(new DaemonConfig { CodexPath = "codex", CapacitorPath = "/opt/kcap", ServerUrl = "https://t.example" }, + new(new DaemonConfig { CodexPath = "codex", CapacitorPath = "/opt/kcap", ServerUrl = "https://t.example", Home = Home }, TestHarnesses.Under(Home), NullLogger.Instance) { ReadInheritedMcpServers = static () => [], }; @@ -79,6 +84,7 @@ CodexHostedAgentRuntimeFactory Factory( CodexAppServerActive = appServerActive, CodexAppServerInteractive = appServerInteractive, Version = "0.146.0", + Home = Home, }, NullLoggerFactory.Instance, TimeProvider.System, spawn); @@ -259,7 +265,7 @@ public async Task App_server_resume_still_sends_thread_resume() { using var codexEnv = EnvScope.Exclusive("CODEX_HOME", Home.PathTo(".codex")); - CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _) => + CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _, _) => Task.FromResult<(CodexAppServerConnection, IAcpProcess)>((fake.ConnectClient(), new FakeAcpProcess())); var factory = Factory(new RecordingPtyFactory(), appServerActive: true, seam); @@ -282,7 +288,7 @@ public async Task Active_review_flow_produces_an_app_server_runtime_via_the_spaw using var codexEnv = EnvScope.Exclusive("CODEX_HOME", Home.PathTo(".codex")); var pty = new RecordingPtyFactory(); - CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _) => + CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _, _) => Task.FromResult<(CodexAppServerConnection, IAcpProcess)>((fake.ConnectClient(), new FakeAcpProcess())); var factory = Factory(pty, appServerActive: true, seam); @@ -320,7 +326,7 @@ public async Task App_server_launch_is_envelope_sourced_after_activation() { using var codexEnv = EnvScope.Exclusive("CODEX_HOME", Home.PathTo(".codex")); - CodexAppServerSpawnFactory seam = (_, _, _, _, env, _, _) => { + CodexAppServerSpawnFactory seam = (_, _, _, _, env, _, _, _) => { capturedEnv = env; return Task.FromResult<(CodexAppServerConnection, IAcpProcess)>((fake.ConnectClient(), new FakeAcpProcess())); }; @@ -353,7 +359,7 @@ public async Task BuildEnv_marks_a_participant_and_an_interactive_launch_by_kind public async Task ApplyChildEnv_drops_an_inherited_flow_role() { var childEnv = new Dictionary { ["KCAP_FLOW_ROLE"] = "reviewer" }; - CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, new Dictionary { ["KCAP_SESSION_KIND"] = "hosted_agent" }); + CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, new Dictionary { ["KCAP_SESSION_KIND"] = "hosted_agent" }, []); await Assert.That(childEnv.ContainsKey("KCAP_FLOW_ROLE")).IsFalse(); } @@ -365,7 +371,7 @@ public async Task ApplyChildEnv_clears_an_inherited_marker_when_the_overlay_does var childEnv = new Dictionary { ["KCAP_HOSTED_APPSERVER"] = "1", ["PATH"] = "/usr/bin" }; var overlay = new Dictionary { ["KCAP_AGENT_ID"] = "agent-1" }; // dormant: no marker - CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, overlay); + CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, overlay, []); await Assert.That(childEnv.ContainsKey("KCAP_HOSTED_APPSERVER")).IsFalse(); await Assert.That(childEnv["KCAP_AGENT_ID"]).IsEqualTo("agent-1"); @@ -377,7 +383,7 @@ public async Task ApplyChildEnv_sets_the_marker_when_the_overlay_emits() { var childEnv = new Dictionary(); var overlay = new Dictionary { ["KCAP_HOSTED_APPSERVER"] = "1" }; - CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, overlay); + CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, overlay, []); await Assert.That(childEnv["KCAP_HOSTED_APPSERVER"]).IsEqualTo("1"); } @@ -391,7 +397,7 @@ public async Task Missing_hooks_on_the_app_server_route_fails_closed() { // the worktree above is empty, and so is the home this points CODEX_HOME into. using var codexEnv = EnvScope.Exclusive("CODEX_HOME", Home.PathTo(".codex")); - CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _) => + CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _, _) => throw new InvalidOperationException("spawn must never be reached when hooks are missing"); var factory = Factory(new RecordingPtyFactory(), appServerActive: true, seam); @@ -399,6 +405,35 @@ await Assert.ThrowsAsync( () => factory.StartAsync(Ctx(isReviewFlow: true, wt.Path), CancellationToken.None)); } + /// Hooks exist only in the account directory, so the start passes the preflight only when the + /// launcher ran against the launch's account; the trust entry shows where it wrote. + [Test] + public async Task AppServer_factory_prepares_with_the_launch_account() { + var worktree = Home.CreateDir("wt"); + var accountDir = Home.CreateDir("codex-work"); + accountDir.CreateFile("hooks.json", """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kcap hook --codex"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kcap hook --codex"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kcap hook --codex"}]}] + }} + """); + await using var fake = new FakeCodexAppServer(); + + CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _, _) => + Task.FromResult<(CodexAppServerConnection, IAcpProcess)>((fake.ConnectClient(), new FakeAcpProcess())); + var factory = Factory(new RecordingPtyFactory(), appServerActive: true, seam, appServerInteractive: true); + var ctx = Ctx(isReviewFlow: false, worktree) with { Account = new ResolvedAccount(accountDir, "acct-1", "Work") }; + + var start = await factory.StartAsync(ctx, CancellationToken.None).WaitAsync(HangGuard); + await start.Runtime.DisposeAsync(); + + await Assert.That(fake.ReceivedMethods).Contains("thread/start"); + await Assert.That(File.ReadAllText(AccountLayouts.Codex(Home, accountDir).ConfigToml)) + .Contains($"\"{CodexPaths.NormalizeProjectKey(worktree).Replace("\\", "\\\\")}\""); + await Assert.That(File.Exists(Home.PathTo(".codex", "config.toml"))).IsFalse(); + } + [Test] [NotInParallel] public async Task Handshake_failure_disposes_the_spawned_child() { @@ -409,7 +444,7 @@ public async Task Handshake_failure_disposes_the_spawned_child() { using var codexEnv = EnvScope.Exclusive("CODEX_HOME", Home.PathTo(".codex")); var process = new FakeAcpProcess(); - CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _) => + CodexAppServerSpawnFactory seam = (_, _, _, _, _, _, _, _) => Task.FromResult<(CodexAppServerConnection, IAcpProcess)>((fake.ConnectClient(), process)); var factory = Factory(new RecordingPtyFactory(), appServerActive: true, seam); @@ -420,6 +455,123 @@ await Assert.ThrowsAsync( await Assert.That(process.HasExited).IsTrue(); } + /// The seam's overlay, and the environment the real spawn would hand this launch's child: the + /// daemon's own, as a seeds it, with the seam's removals and overlay applied. + async Task<(IReadOnlyDictionary Overlay, IDictionary Child)> AppServerChildEnvFor(ResolvedAccount account) { + using var wt = new TempDir(); + WriteWorktreeHooks(wt); + await using var fake = new FakeCodexAppServer(); + IReadOnlyDictionary? overlay = null; + IReadOnlyCollection? remove = null; + + CodexAppServerSpawnFactory seam = (_, _, _, _, env, removeEnv, _, _) => { + (overlay, remove) = (env, removeEnv); + return Task.FromResult<(CodexAppServerConnection, IAcpProcess)>((fake.ConnectClient(), new FakeAcpProcess())); + }; + var factory = Factory(new RecordingPtyFactory(), appServerActive: true, seam); + + var start = await factory.StartAsync(Ctx(isReviewFlow: true, wt.Path) with { Account = account }, CancellationToken.None).WaitAsync(HangGuard); + await start.Runtime.DisposeAsync(); + + var child = new ProcessStartInfo().Environment; + CodexHostedAgentRuntimeFactory.ApplyChildEnv(child, overlay!, remove!); + + return (overlay!, child); + } + + string CodexDaemonDefault => Home.CreateDir(".codex-work"); + + [Test] + [NotInParallel] + public async Task Launch_resolved_to_the_built_in_directory_removes_the_inherited_variable() { + using var codexEnv = EnvScope.Exclusive("CODEX_HOME", CodexDaemonDefault); + + var (_, child) = await AppServerChildEnvFor(new ResolvedAccount(Home.CreateDir(".codex"), "acct-personal", "Personal")); + + await Assert.That(child.ContainsKey("CODEX_HOME")).IsFalse(); + await Assert.That(Environment.GetEnvironmentVariable("CODEX_HOME")).IsEqualTo(CodexDaemonDefault); + } + + [Test] + [NotInParallel] + public async Task Launch_resolved_to_the_daemons_own_custom_default_sets_it_explicitly() { + using var codexEnv = EnvScope.Exclusive("CODEX_HOME", CodexDaemonDefault); + + var (overlay, child) = await AppServerChildEnvFor(new ResolvedAccount(CodexDaemonDefault, null, ".codex-work")); + + await Assert.That(overlay["CODEX_HOME"]).IsEqualTo(CodexDaemonDefault); + await Assert.That(child["CODEX_HOME"]).IsEqualTo(CodexDaemonDefault); + await Assert.That(Environment.GetEnvironmentVariable("CODEX_HOME")).IsEqualTo(CodexDaemonDefault); + } + + [Test] + [NotInParallel] + public async Task Launch_resolved_to_another_custom_account_sets_that_directory() { + using var codexEnv = EnvScope.Exclusive("CODEX_HOME", CodexDaemonDefault); + var other = Home.CreateDir(".codex-other"); + + var (_, child) = await AppServerChildEnvFor(new ResolvedAccount(other, "acct-other", "Other")); + + await Assert.That(child["CODEX_HOME"]).IsEqualTo(other); + await Assert.That(Environment.GetEnvironmentVariable("CODEX_HOME")).IsEqualTo(CodexDaemonDefault); + } + + /// The built-in directory is reached by removing the daemon's inherited variable and a custom one by + /// naming it; each account's probe runs in a working directory of its own, and its answer lands + /// under its own id. + [Test] + public async Task Probe_runs_once_per_codex_account_with_its_home_in_the_environment() { + using var daemons = new TempDaemonStore(); + var builtIn = Home.PathTo(".codex"); + var work = Home.PathTo(".codex-work"); + var spawns = new ConcurrentQueue<(string Cwd, IReadOnlyDictionary Env, IReadOnlyCollection Remove)>(); + var fakes = new ConcurrentQueue(); + + CodexAppServerSpawnFactory seam = (_, _, _, cwd, env, removeEnv, _, _) => { + spawns.Enqueue((cwd, env, removeEnv)); + var named = env.TryGetValue("CODEX_HOME", out var codexHome) ? Path.GetFileName(codexHome) : "built-in"; + var fake = new FakeCodexAppServer { + ModelPages = [new JsonArray(new JsonObject { ["id"] = named, ["model"] = named, ["displayName"] = named, ["hidden"] = false })], + }; + fakes.Enqueue(fake); + return Task.FromResult<(CodexAppServerConnection, IAcpProcess)>((fake.ConnectClient(), new FakeAcpProcess())); + }; + var factory = new CodexHostedAgentRuntimeFactory( + NewLauncher(), new RecordingPtyFactory(), + new DaemonConfig { Name = "test", Store = daemons.Store, Version = "0.146.0", Home = Home }, + NullLoggerFactory.Instance, TimeProvider.System, seam); + + try { + var byAccount = await VendorModelCatalogs.ProbeByAccountAsync( + factory, [("acct-personal", builtIn), ("acct-work", work)], CancellationToken.None).WaitAsync(HangGuard); + + await Assert.That(spawns.Count).IsEqualTo(2); + var personal = spawns.Single(s => !s.Env.ContainsKey("CODEX_HOME")); + var custom = spawns.Single(s => s.Env.ContainsKey("CODEX_HOME")); + await Assert.That(personal.Remove).Contains("CODEX_HOME"); + await Assert.That(custom.Env["CODEX_HOME"]).IsEqualTo(work); + await Assert.That(custom.Remove).DoesNotContain("CODEX_HOME"); + await Assert.That(personal.Cwd).IsNotEqualTo(custom.Cwd); + + await Assert.That(byAccount["acct-personal"].Single().Value).IsEqualTo("built-in"); + await Assert.That(byAccount["acct-work"].Single().Value).IsEqualTo(".codex-work"); + } finally { + foreach (var fake in fakes) await fake.DisposeAsync(); + } + } + + [Test] + public async Task ApplyChildEnv_removes_before_it_overlays() { + var childEnv = new Dictionary { ["CODEX_HOME"] = "/inherited", ["PATH"] = "/usr/bin" }; + + CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, new Dictionary(), ["CODEX_HOME"]); + await Assert.That(childEnv.ContainsKey("CODEX_HOME")).IsFalse(); + + CodexHostedAgentRuntimeFactory.ApplyChildEnv(childEnv, new Dictionary { ["CODEX_HOME"] = "/account" }, ["CODEX_HOME"]); + await Assert.That(childEnv["CODEX_HOME"]).IsEqualTo("/account"); + await Assert.That(childEnv["PATH"]).IsEqualTo("/usr/bin"); + } + static void WriteWorktreeHooks(TempDir worktree) => worktree.CreateFile([".codex", "hooks.json"], """ {"hooks":{ diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherAccountTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherAccountTests.cs new file mode 100644 index 0000000000..45de392e03 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherAccountTests.cs @@ -0,0 +1,98 @@ +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness.Codex; +using Capacitor.Cli.Daemon.Harness.Codex; +using Capacitor.Cli.Daemon.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Harness.Codex; + +/// The environment's layout is ~/.codex under the temp home and the account is a second +/// directory beside it, so a launch keyed on the wrong one reads or writes visibly there. +public class CodexLauncherAccountTests { + [TempHome] public required TempHome Home { get; init; } + + const string CapacitorHooks = """ + {"hooks":{ + "SessionStart":[{"hooks":[{"type":"command","command":"kcap hook --codex"}]}], + "Stop":[{"hooks":[{"type":"command","command":"kcap hook --codex"}]}], + "PermissionRequest":[{"hooks":[{"type":"command","command":"kcap hook --codex"}]}] + }} + """; + + CodexPaths EnvironmentLayout => new(Home, null); + + CodexLauncher NewLauncher() => + new(new DaemonConfig { CodexPath = "codex", CapacitorPath = "/opt/kcap", ServerUrl = "https://t.example", Home = Home }, + TestHarnesses.Under(Home), NullLogger.Instance) { + ReadInheritedMcpServers = static () => [] + }; + + static ResolvedAccount Account(string directory) => new(directory, "acct-1", "Work"); + + static LauncherContext Ctx(string source, string worktree, ResolvedAccount? account, string model = "gpt-5.3-codex") => new( + AgentId: "a-1", + SourceRepoPath: source, + Worktree: new WorktreeInfo(Path: worktree, Branch: "wt-branch", SourceRepo: source), + Prompt: null, + Model: model, + Effort: null, + Tools: null, + IsReview: false, + IsReviewFlow: false, + Review: null, + ReviewLaunch: null, + Account: account + ); + + [Test] + public async Task Prepare_checks_hooks_in_the_account_directory_not_the_default() { + var source = Home.CreateDir("repo"); + var worktree = Home.CreateDir("wt"); + var accountDir = Home.CreateDir("codex-work"); + accountDir.CreateFile("hooks.json", CapacitorHooks); + + await Assert.That(() => NewLauncher().Prepare(Ctx(source, worktree, Account(accountDir)))).ThrowsNothing(); + } + + /// The environment layout carries the hooks, so a launch that consulted it would pass. + [Test] + public async Task Prepare_without_account_or_repository_hooks_in_the_account_directory_throws() { + var source = Home.CreateDir("repo"); + var worktree = Home.CreateDir("wt"); + var accountDir = Home.CreateDir("codex-work"); + Home.CreateFile([".codex", "hooks.json"], CapacitorHooks); + + await Assert.That(() => NewLauncher().Prepare(Ctx(source, worktree, Account(accountDir)))) + .Throws() + .WithMessageContaining($"kcap accounts add codex {ShellArgument.Quote(accountDir.Path)}"); + } + + [Test] + public async Task TrustWorktree_writes_the_account_config_toml() { + var source = Home.CreateDir("repo"); + var worktree = Home.CreateDir("wt"); + var accountDir = Home.CreateDir("codex-work"); + accountDir.CreateFile("hooks.json", CapacitorHooks); + var account = AccountLayouts.Codex(Home, accountDir); + + NewLauncher().Prepare(Ctx(source, worktree, Account(accountDir))); + + var configToml = File.ReadAllText(account.ConfigToml); + await Assert.That(configToml).Contains($"\"{CodexPaths.NormalizeProjectKey(worktree).Replace("\\", "\\\\")}\""); + await Assert.That(configToml).Contains("trust_level = \"trusted\""); + await Assert.That(File.Exists(EnvironmentLayout.ConfigToml)).IsFalse(); + } + + [Test] + public async Task Model_migrations_are_read_from_the_account_config_toml() { + var accountDir = Home.CreateDir("codex-work"); + accountDir.CreateFile("config.toml", "[notice.model_migrations]\n\"gpt-5.4-mini\" = \"gpt-5.6-luna\"\n"); + Home.CreateFile([".codex", "config.toml"], "[notice.model_migrations]\n\"gpt-5.4-mini\" = \"gpt-5.5-env\"\n"); + + var args = NewLauncher().BuildArgs(Ctx("/tmp/repo", "/tmp/wt", Account(accountDir), model: "gpt-5.4-mini")).Args; + + await Assert.That(args).Contains("gpt-5.6-luna"); + await Assert.That(args).DoesNotContain("gpt-5.5-env"); + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherTests.cs index 6f2e3689c9..68375c71a4 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexLauncherTests.cs @@ -556,7 +556,10 @@ public async Task Prepare_throws_when_no_hooks_json_anywhere() { NewLauncher().Prepare(ctx); await Task.CompletedTask; }); - await Assert.That(ex!.Message).Contains("kcap plugin install --codex"); + var checkedDir = Path.Combine(Home.Path, ".codex"); + await Assert.That(ex!.Message).Contains(checkedDir); + await Assert.That(ex.Message).Contains($"kcap accounts add codex {ShellArgument.Quote(checkedDir)}"); + await Assert.That(ex.Message).Contains("kcap plugin install --codex --project"); } [Test, NotInParallel] diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/FixedPtyProcessFactory.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/FixedPtyProcessFactory.cs index f56a3cbb5f..0c6e031396 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/FixedPtyProcessFactory.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/FixedPtyProcessFactory.cs @@ -10,6 +10,7 @@ public IPtyProcess Spawn( string cwd, Dictionary? extraEnv = null, ushort cols = 120, - ushort rows = 40 + ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => process; } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/PtyEnvScrubTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/PtyEnvScrubTests.cs index 83774daff4..826f52c18a 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/PtyEnvScrubTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/PtyEnvScrubTests.cs @@ -51,7 +51,7 @@ await Assert.That(PtyEnvScrub.HostedAgentVars).IsEquivalentTo(new[] { public async Task A_pty_child_does_not_inherit_another_harnesss_session(string name) { using var inherited = EnvScope.Exclusive(name, "inherited-from-the-daemons-shell"); - var env = UnixPtyProcess.BuildChildEnv(new() { ["KCAP_DAEMON_AGENT_ID"] = "agent-1" }, cols: 80, rows: 24); + var env = UnixPtyProcess.BuildChildEnv(new() { ["KCAP_DAEMON_AGENT_ID"] = "agent-1" }, removeEnv: null, cols: 80, rows: 24); await Assert.That(env.TryGetValue(name, out var value) ? value : null).IsNotEqualTo("inherited-from-the-daemons-shell"); await Assert.That(env["KCAP_DAEMON_AGENT_ID"]).IsEqualTo("agent-1"); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/SpyPtyProcessFactory.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/SpyPtyProcessFactory.cs index 884ad1efa3..0293a24298 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/SpyPtyProcessFactory.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/SpyPtyProcessFactory.cs @@ -7,6 +7,7 @@ sealed class SpyPtyProcessFactory(IPtyProcess? process = null) : IPtyProcessFact public string? LastCommand { get; private set; } public string[]? LastArgs { get; private set; } public Dictionary? LastEnv { get; private set; } + public IReadOnlyCollection? LastRemoveEnv { get; private set; } public IPtyProcess Spawn( string command, @@ -14,12 +15,14 @@ public IPtyProcess Spawn( string cwd, Dictionary? extraEnv = null, ushort cols = 120, - ushort rows = 40 + ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) { SpawnCalls++; - LastCommand = command; - LastArgs = args; - LastEnv = extraEnv; + LastCommand = command; + LastArgs = args; + LastEnv = extraEnv; + LastRemoveEnv = removeEnv; return process ?? new StubPtyProcess(); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessEnvTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessEnvTests.cs new file mode 100644 index 0000000000..f4f7f8b495 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Pty/Unix/UnixPtyProcessEnvTests.cs @@ -0,0 +1,35 @@ +using Capacitor.Cli.Daemon.Pty.Unix; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Pty.Unix; + +/// The child environment the PTY spawn hands a vendor: the daemon's own, minus what the launch +/// removes, plus its overlay. +[NotInParallel] +public class UnixPtyProcessEnvTests { + const string Variable = "CLAUDE_CONFIG_DIR"; + + [TempHome] public required TempHome Home { get; init; } + + [Test] + public async Task Inherited_variable_is_removed_when_named_in_the_removal_set() { + var inherited = Home.PathTo(".claude-work"); + using var env = EnvScope.Exclusive(Variable, inherited); + + var kept = UnixPtyProcess.BuildChildEnv(null, null, 80, 24); + var removed = UnixPtyProcess.BuildChildEnv(null, [Variable], 80, 24); + + await Assert.That(kept[Variable]).IsEqualTo(inherited); + await Assert.That(removed.ContainsKey(Variable)).IsFalse(); + await Assert.That(Environment.GetEnvironmentVariable(Variable)).IsEqualTo(inherited); + } + + [Test] + public async Task Overlay_sets_the_variable_after_removal() { + var account = Home.PathTo(".claude-other"); + using var env = EnvScope.Exclusive(Variable, Home.PathTo(".claude-work")); + + var child = UnixPtyProcess.BuildChildEnv(new Dictionary { [Variable] = account }, [Variable], 80, 24); + + await Assert.That(child[Variable]).IsEqualTo(account); + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsIpcTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsIpcTests.cs new file mode 100644 index 0000000000..1307f97dcd --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsIpcTests.cs @@ -0,0 +1,93 @@ +using System.IO.Pipelines; +using System.Text.Json; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Core.LocalIpc; +using Capacitor.Cli.Daemon.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +public class AccountsIpcTests { + [TempHome] public required TempHome Home { get; init; } + + AccountStore Store() => new(Home.PathTo("accounts")); + + void Add(AccountStore store, string label) { + var account = new VendorAccount(Guid.NewGuid().ToString("N"), HarnessId.Codex, + AccountDirectory.Normalize(Home.PathTo($".codex-{label}")), label, DateTimeOffset.UnixEpoch); + store.Mutate(r => (r with { Accounts = [.. r.Accounts, account] }, 0)); + } + + AccountsPublisher Publisher(AccountStore store) => + new(new DaemonConfig { Accounts = store }, Home, new FakeTimeProvider(), NullLogger.Instance); + + static async Task NextSnapshot(Stream client, CancellationToken ct) { + var frame = await FrameCodec.ReadAsync(client, ct); + await Assert.That(frame?.Type).IsEqualTo(FrameType.AccountsSnapshot); + return JsonSerializer.Deserialize(frame!.Text, AccountsSnapshotJsonContext.Default.AccountsSnapshotDto)!; + } + + [Test] + public async Task Subscribe_writes_the_current_snapshot_then_each_change() { + var store = Store(); + Add(store, "work"); + using var publisher = Publisher(store); + await publisher.StartAsync(CancellationToken.None); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var toDaemon = new Pipe(); + var toClient = new Pipe(); + var client = toClient.Reader.AsStream(); + + var ipc = new AccountsIpc(publisher); + var handler = ipc.HandleSubscribeAsync( + new DuplexTestStream(toDaemon.Reader.AsStream(), toClient.Writer.AsStream()), cts.Token); + try { + var initial = await NextSnapshot(client, cts.Token); + await Assert.That(initial.HostId).IsEqualTo(store.HostId()); + await Assert.That(initial.Revision).IsEqualTo(1L); + await Assert.That(ipc.ActiveSubscribersForTest).IsEqualTo(1); + + Add(store, "spare"); + await publisher.RebuildAsync(cts.Token); + var second = await NextSnapshot(client, cts.Token); + await Assert.That(second.Revision).IsEqualTo(2L); + await Assert.That(second.Accounts.Select(a => a.Label)).IsEquivalentTo(["work", "spare"]); + + Add(store, "third"); + await publisher.RebuildAsync(cts.Token); + await Assert.That((await NextSnapshot(client, cts.Token)).Revision).IsEqualTo(3L); + } finally { + await toDaemon.Writer.CompleteAsync(); + } + + await handler.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(ipc.ActiveSubscribersForTest).IsEqualTo(0); + await publisher.StopAsync(CancellationToken.None); + } + + [Test] + public async Task Subscribe_before_the_first_snapshot_waits_for_it() { + var store = Store(); + Add(store, "work"); + using var publisher = Publisher(store); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var toDaemon = new Pipe(); + var toClient = new Pipe(); + var client = toClient.Reader.AsStream(); + + var handler = new AccountsIpc(publisher).HandleSubscribeAsync( + new DuplexTestStream(toDaemon.Reader.AsStream(), toClient.Writer.AsStream()), cts.Token); + try { + await publisher.StartAsync(cts.Token); + + await Assert.That((await NextSnapshot(client, cts.Token)).Revision).IsEqualTo(1L); + } finally { + await toDaemon.Writer.CompleteAsync(); + await publisher.StopAsync(CancellationToken.None); + } + + await handler.WaitAsync(TimeSpan.FromSeconds(5)); + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherServerTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherServerTests.cs new file mode 100644 index 0000000000..2f542e64f6 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherServerTests.cs @@ -0,0 +1,161 @@ +using System.Collections.Frozen; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Daemon.Services; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using TUnit.Assertions.Enums; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +public class AccountsPublisherServerTests { + [TempHome] public required TempHome Home { get; init; } + [TempConfigRoot] public required TempConfigRoot Config { get; init; } + + static readonly TimeSpan Throttle = TimeSpan.FromSeconds(30); + static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + /// Ready but never started, so every send fails inside the hub. + sealed class UnstartedServerConnection(ILogger logger) : ServerConnection( + new DaemonConfig { Name = "test", ServerUrl = "http://127.0.0.1:1" }, + new FixedCredentialSource(), NullLoggerFactory.Instance, logger, TimeProvider.System) { + internal override bool IsReady => true; + } + + /// Connected, with the hub invoke and send recorded, so readiness follows the real + /// registration bracket. + sealed class RegisteringServerConnection(DaemonConfig config) : ServerConnection( + config, new FixedCredentialSource(), NullLoggerFactory.Instance, NullLogger.Instance, TimeProvider.System) { + readonly List _connects = []; + readonly List _sent = []; + + public Func? DuringConnect { get; set; } + + public DaemonConnect[] Connects { get { lock (_connects) return [.. _connects]; } } + public AccountsSnapshotDto[] Sent { get { lock (_sent) return [.. _sent]; } } + + internal override HubConnectionState HubState => HubConnectionState.Connected; + + internal override async Task InvokeDaemonConnectRawAsync(DaemonConnect connect) { + lock (_connects) _connects.Add(connect); + if (DuringConnect is { } during) await during(); + } + + internal override Task SendAccountsChangedRawAsync(AccountsSnapshotDto snapshot) { + lock (_sent) _sent.Add(snapshot); + return Task.CompletedTask; + } + } + + AccountStore Store() => new(Home.PathTo("accounts")); + + void Add(AccountStore store, string label) { + var account = new VendorAccount(Guid.NewGuid().ToString("N"), HarnessId.Claude, + AccountDirectory.Normalize(Home.PathTo($".claude-{label}")), label, DateTimeOffset.UnixEpoch); + store.Mutate(r => (r with { Accounts = [.. r.Accounts, account] }, 0)); + } + + AccountsPublisher Publisher(AccountStore store, TimeProvider time) => + new(new DaemonConfig { Accounts = store, DefaultDirectories = FrozenDictionary.Empty }, + Home, time, NullLogger.Instance); + + static async Task WaitUntil(Func condition) { + var deadline = DateTime.UtcNow + Timeout; + while (!condition()) { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met"); + await Task.Delay(10); + } + } + + [Test] + public async Task First_change_sends_at_once_and_a_burst_sends_the_last_change_after_thirty_seconds() { + var store = Store(); + Add(store, "work"); + var time = new FakeTimeProvider(); + using var publisher = Publisher(store, time); + var sent = new List(); + publisher.SendToServer = s => { + lock (sent) sent.Add(s.Revision); + return Task.CompletedTask; + }; + + await publisher.RebuildAsync(CancellationToken.None); + await Assert.That(Sent()).IsEquivalentTo([1L], CollectionOrdering.Matching); + + Add(store, "spare"); + await publisher.RebuildAsync(CancellationToken.None); + time.Advance(TimeSpan.FromSeconds(10)); + Add(store, "third"); + await publisher.RebuildAsync(CancellationToken.None); + time.Advance(TimeSpan.FromSeconds(20) - TimeSpan.FromTicks(1)); + await Assert.That(Sent()).IsEquivalentTo([1L], CollectionOrdering.Matching); + + time.Advance(TimeSpan.FromTicks(1)); + await Assert.That(Sent()).IsEquivalentTo([1L, 3L], CollectionOrdering.Matching); + + time.Advance(Throttle); + Add(store, "fourth"); + await publisher.RebuildAsync(CancellationToken.None); + await Assert.That(Sent()).IsEquivalentTo([1L, 3L, 4L], CollectionOrdering.Matching); + + long[] Sent() { + lock (sent) return [.. sent]; + } + } + + /// A failed send ends as a debug line on the connection; the publisher keeps the + /// connection as its sink and sends the next change through it. + [Test] + public async Task A_server_without_the_handler_logs_at_debug_and_keeps_the_connection() { + var store = Store(); + Add(store, "work"); + var time = new FakeTimeProvider(); + var log = new CapturingLogger(); + await using var connection = new UnstartedServerConnection(log); + using var publisher = Publisher(store, time); + publisher.SendToServer = connection.AccountsChangedAsync; + + await publisher.RebuildAsync(CancellationToken.None); + await WaitUntil(() => Entries().Length == 1); + time.Advance(Throttle); + Add(store, "spare"); + await publisher.RebuildAsync(CancellationToken.None); + await WaitUntil(() => Entries().Length == 2); + + await Assert.That(Entries().All(e => e is { Level: LogLevel.Debug, Message: var m } && m.Contains("AccountsChanged"))).IsTrue(); + await Assert.That(publisher.Current!.Revision).IsEqualTo(2L); + + (LogLevel Level, string Message)[] Entries() { + lock (log.Entries) return [.. log.Entries]; + } + } + + /// The publisher's own send of a change landing inside the registration bracket is dropped + /// by the readiness guard; an unchanged snapshot is not sent again. + [Test] + public async Task A_change_between_the_connect_payload_and_registration_reaches_the_server_after_registration() { + var store = Store(); + Add(store, "work"); + using var publisher = Publisher(store, new FakeTimeProvider()); + await publisher.RebuildAsync(CancellationToken.None); + await using var connection = new RegisteringServerConnection( + new DaemonConfig { Name = "test", ServerUrl = "http://127.0.0.1:1", ConfigRoot = Config.Root }); + connection.GetAccountsSnapshot = () => publisher.Current; + publisher.SendToServer = connection.AccountsChangedAsync; + + await connection.RegisterDaemonAsync(); + await Assert.That(connection.Sent).IsEmpty(); + + connection.DuringConnect = async () => { + Add(store, "spare"); + await publisher.RebuildAsync(CancellationToken.None); + }; + await connection.RegisterDaemonAsync(); + + await Assert.That(connection.Connects.Select(c => c.Accounts!.Revision)).IsEquivalentTo([1L, 1L], CollectionOrdering.Matching); + await Assert.That(connection.Sent.Select(s => s.Revision)).IsEquivalentTo([2L], CollectionOrdering.Matching); + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherTests.cs new file mode 100644 index 0000000000..495281545c --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AccountsPublisherTests.cs @@ -0,0 +1,309 @@ +using System.Collections.Frozen; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Daemon.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +public class AccountsPublisherTests { + [TempHome] public required TempHome Home { get; init; } + + static readonly TimeSpan Poll = TimeSpan.FromSeconds(2); + static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + sealed class Warnings : ILogger { + public CapturingLogger Captured { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => Captured.BeginScope(state); + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Captured.Log(logLevel, eventId, state, exception, formatter); + } + + AccountStore Store() => new(Home.PathTo("accounts")); + + static async Task WaitUntil(Func condition) { + var deadline = DateTime.UtcNow + Timeout; + while (!condition()) { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met"); + await Task.Delay(10); + } + } + + void Add(AccountStore store, string label) { + var account = new VendorAccount(Guid.NewGuid().ToString("N"), HarnessId.Claude, + AccountDirectory.Normalize(Home.PathTo($".claude-{label}")), label, DateTimeOffset.UnixEpoch); + store.Mutate(r => (r with { Accounts = [.. r.Accounts, account] }, 0)); + } + + AccountsPublisher Publisher(AccountStore store, TimeProvider time, + IReadOnlyDictionary? defaults = null, ILogger? logger = null) => + new(new DaemonConfig { Accounts = store, DefaultDirectories = defaults ?? FrozenDictionary.Empty }, + Home, time, logger ?? NullLogger.Instance); + + [Test] + public async Task Start_builds_the_snapshot_with_host_id_revision_and_defaults() { + var store = Store(); + Add(store, "work"); + Add(store, "spare"); + var claude = Home.PathTo(".claude"); + var codex = Home.PathTo(".codex"); + using var publisher = Publisher(store, new FakeTimeProvider(), + new Dictionary { [HarnessId.Claude] = claude, [HarnessId.Codex] = codex }); + + await publisher.StartAsync(CancellationToken.None); + try { + await Assert.That(publisher.Current).IsNotNull(); + var snapshot = publisher.Current!; + await Assert.That(snapshot.HostId).IsEqualTo(store.HostId()); + await Assert.That(snapshot.Revision).IsEqualTo(2L); + await Assert.That(snapshot.Accounts.Select(a => a.Label)).IsEquivalentTo(["work", "spare"]); + await Assert.That(snapshot.Defaults.Select(d => $"{d.Vendor}={d.Directory}")) + .IsEquivalentTo([$"claude={claude}", $"codex={codex}"]); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task A_registry_rewrite_republishes_within_the_debounce() { + var store = Store(); + Add(store, "work"); + var time = new FakeTimeProvider(); + using var publisher = Publisher(store, time); + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await publisher.StartAsync(CancellationToken.None); + publisher.Changed += s => changed.TrySetResult(s); + try { + Add(store, "spare"); + time.Advance(Poll); + + var snapshot = await changed.Task.WaitAsync(Timeout); + await Assert.That(snapshot.Revision).IsEqualTo(2L); + await Assert.That(snapshot.Accounts.Select(a => a.Label)).IsEquivalentTo(["work", "spare"]); + await Assert.That(publisher.Current).IsSameReferenceAs(snapshot); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + /// The daemon reads a snapshot before its host starts the publisher; a registry change in between + /// replaces one a reader already has, so start announces it. + [Test] + public async Task Start_announces_a_snapshot_that_replaces_one_built_before_it() { + var store = Store(); + Add(store, "work"); + using var publisher = Publisher(store, new FakeTimeProvider()); + await publisher.RebuildAsync(CancellationToken.None); + var announced = new List(); + publisher.Changed += s => { lock (announced) announced.Add(s.Revision); }; + Add(store, "spare"); + + await publisher.StartAsync(CancellationToken.None); + try { + long[] revisions; + lock (announced) revisions = [.. announced]; + await Assert.That(revisions).IsEquivalentTo([2L]); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + /// The rebuild the daemon runs before its host starts can fail; start then builds the first + /// snapshot a subscriber sees, and announces it. + [Test] + public async Task Start_announces_the_first_snapshot_when_the_rebuild_before_it_failed() { + var store = Store(); + Add(store, "work"); + using var publisher = Publisher(store, new FakeTimeProvider()); + var loads = 0; + publisher.LoadRegistry = s => Interlocked.Increment(ref loads) == 1 ? throw new IOException("sharing violation") : s.Load(); + await publisher.RebuildAsync(CancellationToken.None); + await Assert.That(publisher.Current).IsNull(); + var announced = new List(); + publisher.Changed += s => { lock (announced) announced.Add(s.Revision); }; + + await publisher.StartAsync(CancellationToken.None); + try { + long[] revisions; + lock (announced) revisions = [.. announced]; + await Assert.That(revisions).IsEquivalentTo([1L]); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + /// An unchanged revision is not a change, so the snapshot the daemon read before its host started + /// is not announced again. + [Test] + public async Task Start_does_not_announce_the_snapshot_built_before_it_again() { + var store = Store(); + Add(store, "work"); + using var publisher = Publisher(store, new FakeTimeProvider()); + await publisher.RebuildAsync(CancellationToken.None); + var announced = 0; + publisher.Changed += _ => Interlocked.Increment(ref announced); + + await publisher.StartAsync(CancellationToken.None); + try { + await Assert.That(Volatile.Read(ref announced)).IsEqualTo(0); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task A_burst_of_rewrites_yields_one_snapshot() { + var store = Store(); + var time = new FakeTimeProvider(); + using var publisher = Publisher(store, time); + var changes = new List(); + var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await publisher.StartAsync(CancellationToken.None); + publisher.Changed += s => { + lock (changes) changes.Add(s); + first.TrySetResult(); + }; + try { + for (var i = 0; i < 10; i++) { + Add(store, $"account-{i}"); + time.Advance(TimeSpan.FromMilliseconds(50)); + } + time.Advance(Poll); + await first.Task.WaitAsync(Timeout); + time.Advance(Poll); + await publisher.RebuildAsync(CancellationToken.None); + + AccountsSnapshotDto[] seen; + lock (changes) seen = [.. changes]; + await Assert.That(seen.Select(s => s.Revision)).IsEquivalentTo([10L]); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task A_malformed_registry_keeps_the_last_snapshot() { + var store = Store(); + Add(store, "work"); + var time = new FakeTimeProvider(); + var warnings = new Warnings(); + using var publisher = Publisher(store, time, logger: warnings); + await publisher.StartAsync(CancellationToken.None); + try { + var before = publisher.Current; + var changes = 0; + publisher.Changed += _ => Interlocked.Increment(ref changes); + + await File.WriteAllTextAsync(store.RegistryPath, "{ not a registry"); + time.Advance(Poll); + await publisher.RebuildAsync(CancellationToken.None); + await publisher.RebuildAsync(CancellationToken.None); + + await Assert.That(publisher.Current).IsSameReferenceAs(before); + await Assert.That(Volatile.Read(ref changes)).IsEqualTo(0); + await Assert.That(warnings.Captured.Warnings.Count).IsEqualTo(1); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + [Test] + public async Task A_throwing_handler_neither_stops_the_poll_nor_fails_a_rebuild() { + var store = Store(); + Add(store, "work"); + var time = new FakeTimeProvider(); + var warnings = new Warnings(); + using var publisher = Publisher(store, time, logger: warnings); + var seen = new List(); + await publisher.StartAsync(CancellationToken.None); + publisher.Changed += _ => throw new InvalidOperationException("subscriber bug"); + publisher.Changed += s => { lock (seen) seen.Add(s.Revision); }; + try { + Add(store, "spare"); + time.Advance(Poll); + await WaitUntil(() => { lock (seen) return seen.Count == 1; }); + + Add(store, "third"); + time.Advance(Poll); + await WaitUntil(() => { lock (seen) return seen.Count == 2; }); + + Add(store, "fourth"); + await publisher.RebuildAsync(CancellationToken.None); + + long[] revisions; + lock (seen) revisions = [.. seen]; + await Assert.That(revisions).IsEquivalentTo([2L, 3L, 4L]); + await Assert.That(publisher.Current!.Revision).IsEqualTo(4L); + await Assert.That(warnings.Captured.Warnings.Count).IsEqualTo(3); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + /// A throw the read does not expect lands on the change tick and again on the settle read: it + /// must neither end the service nor leave the stat trusted. + [Test] + public async Task An_unexpected_poll_failure_does_not_end_the_service_and_the_next_tick_polls() { + var store = Store(); + Add(store, "work"); + var time = new FakeTimeProvider(); + var warnings = new Warnings(); + using var publisher = Publisher(store, time, logger: warnings); + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await publisher.StartAsync(CancellationToken.None); + publisher.Changed += s => changed.TrySetResult(s); + var loads = 0; + publisher.LoadRegistry = s => + Interlocked.Increment(ref loads) <= 2 ? throw new InvalidOperationException("bug") : s.Load(); + try { + Add(store, "spare"); + time.Advance(Poll); + await WaitUntil(() => Volatile.Read(ref loads) == 1); + time.Advance(Poll); + await WaitUntil(() => Volatile.Read(ref loads) == 2); + time.Advance(Poll); + + var snapshot = await changed.Task.WaitAsync(Timeout); + await Assert.That(snapshot.Revision).IsEqualTo(2L); + await Assert.That(publisher.ExecuteTask!.IsCompleted).IsFalse(); + await Assert.That(warnings.Captured.Warnings.Count).IsEqualTo(1); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } + + /// The failure lands on the change tick and again on the settle read, the case that would + /// otherwise mark the stat trusted and skip every later tick until the next write. + [Test] + public async Task A_transient_read_failure_is_retried_on_the_next_tick() { + var store = Store(); + Add(store, "work"); + var time = new FakeTimeProvider(); + var warnings = new Warnings(); + using var publisher = Publisher(store, time, logger: warnings); + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await publisher.StartAsync(CancellationToken.None); + publisher.Changed += s => changed.TrySetResult(s); + var loads = 0; + publisher.LoadRegistry = s => + Interlocked.Increment(ref loads) <= 2 ? throw new IOException("sharing violation") : s.Load(); + try { + Add(store, "spare"); + time.Advance(Poll); + await WaitUntil(() => Volatile.Read(ref loads) == 1); + time.Advance(Poll); + await WaitUntil(() => Volatile.Read(ref loads) == 2); + time.Advance(Poll); + + var snapshot = await changed.Task.WaitAsync(Timeout); + await Assert.That(snapshot.Revision).IsEqualTo(2L); + await Assert.That(warnings.Captured.Warnings.Count).IsEqualTo(1); + } finally { + await publisher.StopAsync(CancellationToken.None); + } + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AdmissionFenceIpcTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AdmissionFenceIpcTests.cs index 7535e0a841..be229b9d7d 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AdmissionFenceIpcTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AdmissionFenceIpcTests.cs @@ -37,7 +37,7 @@ static async Task StartAsync(ServerConnection server, CancellationToken var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(config.Store, config.Name, "test", new NoopRestartStrategy(), TimeProvider.System); var control = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, - TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await control.StartAsync(ct); var sockPath = config.Store.SocketPath(config.Name); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorAccountTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorAccountTests.cs new file mode 100644 index 0000000000..b1273cb819 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorAccountTests.cs @@ -0,0 +1,431 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Core.Harness.Claude; +using Capacitor.Cli.Core.Harness.Codex; +using Capacitor.Cli.Core.LocalIpc; +using Capacitor.Cli.Core.Resume; +using Capacitor.Cli.Daemon.Services; +using Capacitor.Cli.Daemon.Tests.Unit.Pty; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +[ParallelLimiter] +public class AgentOrchestratorAccountTests { + const string WorkId = "c1a0de01-work"; + const string CodexId = "c0dec001-codex"; + + const string AccountSession = "0a0a0a0a-1111-4111-8111-000000000001"; + const string EnvironmentSession = "0e0e0e0e-2222-4222-8222-000000000002"; + + [TempDir] public required TempDir Tmp { get; init; } + + UserHome? _daemonHome; + + /// The home the daemon's environment layouts hang off; set by . + UserHome DaemonHome => _daemonHome!; + + string ClaudeDefault => Tmp.CreateDir(".claude"); + string CodexDefault => Tmp.CreateDir(".codex"); + + AccountStore Store => field ??= new AccountStore(Tmp.PathTo("accounts")); + + string Register(string id, HarnessId vendor, string directory, string label) { + var normalized = AccountDirectory.Normalize(directory); + Store.Mutate(r => (r with { Accounts = [.. r.Accounts, new VendorAccount(id, vendor, normalized, label, DateTimeOffset.UnixEpoch)] }, 0)); + + return normalized; + } + + string RefusalFor(string accountId) => + AccountResolution.Resolve(Store.TryLoad(), HarnessId.Claude, accountId, ClaudeDefault).Refusal!; + + AgentOrchestrator Build(CaptureServerConnection server, SpyPtyProcessFactory pty, SpyHostedAgentLauncher launcher, string? allowedRepo = null) => + AgentOrchestratorHarness.BuildOrchestrator( + server, pty, new Dictionary { [launcher.Vendor] = launcher }, + allowedRepoPath: allowedRepo, + configure: c => { + c.Accounts = Store; + c.DefaultDirectories = new Dictionary { [HarnessId.Claude] = ClaudeDefault, [HarnessId.Codex] = CodexDefault }; + _daemonHome = c.Home; + }); + + static SpyHostedAgentLauncher Spy(string vendor = "claude") => new(vendor, cliPath: $"spy-{vendor}"); + + static SpyPtyProcessFactory LivePty() => new(new OneChunkThenBlockPtyProcess()); + + static LaunchAgentCommand Launch(string agentId, string repoPath, string? accountId, string vendor = "claude", string model = "opus") => new( + AgentId: agentId, Prompt: "hi", Model: model, Effort: null, RepoPath: repoPath, + Tools: null, AttachmentIds: null, Vendor: vendor, AccountId: accountId); + + [Test] + public async Task Null_account_id_launches_under_the_daemon_default_directory() { + using var repo = GitRepo.CreateWithCommit(); + var launcher = Spy(); + await using var orch = Build(new CaptureServerConnection(), LivePty(), launcher, repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-default", repo, accountId: null)); + + var expected = new ResolvedAccount(AccountDirectory.Normalize(ClaudeDefault), null, ".claude"); + await Assert.That(launcher.LastContext!.Account).IsEqualTo(expected); + await Assert.That(orch.GetAgentForTest("acct-default")!.Account).IsEqualTo(expected); + } + + [Test] + public async Task Registered_account_id_launches_under_its_directory_with_its_label() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + using var repo = GitRepo.CreateWithCommit(); + var launcher = Spy(); + await using var orch = Build(new CaptureServerConnection(), LivePty(), launcher, repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-work", repo, WorkId)); + + var expected = new ResolvedAccount(work, WorkId, "Work"); + await Assert.That(launcher.LastContext!.Account).IsEqualTo(expected); + await Assert.That(orch.GetAgentForTest("acct-work")!.Account).IsEqualTo(expected); + } + + [Test] + public async Task Unknown_account_id_is_refused_with_account_unavailable_and_spawns_nothing() { + using var repo = GitRepo.CreateWithCommit(); + var server = new CaptureServerConnection(); + var pty = new SpyPtyProcessFactory(); + var launcher = Spy(); + await using var orch = Build(server, pty, launcher, repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-unknown", repo, "no-such-account")); + + await Assert.That(server.LaunchFailedCalls).Count().IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].AgentId).IsEqualTo("acct-unknown"); + await Assert.That(server.LaunchFailedCalls[0].Reason).StartsWith("account_unavailable: "); + await Assert.That(server.LaunchFailedCalls[0].Reason).IsEqualTo(RefusalFor("no-such-account")); + await Assert.That(launcher.PrepareCalls).IsEqualTo(0); + await Assert.That(pty.SpawnCalls).IsEqualTo(0); + await Assert.That(orch.GetAgentForTest("acct-unknown")).IsNull(); + await Assert.That(Directory.Exists(repo.PathTo(".capacitor", "worktrees"))).IsFalse(); + } + + [Test] + public async Task Account_id_of_another_vendor_is_refused() { + Register(CodexId, HarnessId.Codex, Tmp.CreateDir(".codex-work"), "Codex work"); + using var repo = GitRepo.CreateWithCommit(); + var server = new CaptureServerConnection(); + var pty = new SpyPtyProcessFactory(); + await using var orch = Build(server, pty, Spy(), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-codex", repo, CodexId)); + + await Assert.That(server.LaunchFailedCalls).Count().IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].Reason).StartsWith("account_unavailable: "); + await Assert.That(server.LaunchFailedCalls[0].Reason).IsEqualTo(RefusalFor(CodexId)); + await Assert.That(pty.SpawnCalls).IsEqualTo(0); + } + + [Test] + public async Task Explicit_account_id_for_a_vendor_without_accounts_is_refused() { + Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + using var repo = GitRepo.CreateWithCommit(); + var server = new CaptureServerConnection(); + var pty = new SpyPtyProcessFactory(); + var launcher = Spy("cursor"); + await using var orch = Build(server, pty, launcher, repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-cursor", repo, WorkId, vendor: "cursor")); + + await Assert.That(server.LaunchFailedCalls).Count().IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].AgentId).IsEqualTo("acct-cursor"); + await Assert.That(server.LaunchFailedCalls[0].Reason).IsEqualTo("account_unavailable: cursor has no accounts"); + await Assert.That(launcher.PrepareCalls).IsEqualTo(0); + await Assert.That(pty.SpawnCalls).IsEqualTo(0); + await Assert.That(orch.GetAgentForTest("acct-cursor")).IsNull(); + } + + [Test] + public async Task Explicit_account_id_on_a_review_flow_launch_is_refused() { + Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + using var repo = GitRepo.CreateWithCommit(); + var server = new CaptureServerConnection(); + var pty = new SpyPtyProcessFactory(); + var launcher = Spy(); + await using var orch = Build(server, pty, launcher, repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-reviewer", repo, WorkId) with { Kind = LaunchKind.ReviewFlow }); + + await Assert.That(server.LaunchFailedCalls).Count().IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].AgentId).IsEqualTo("acct-reviewer"); + await Assert.That(server.LaunchFailedCalls[0].Reason).IsEqualTo("account_unavailable: a review-flow launch runs on the default account"); + await Assert.That(launcher.PrepareCalls).IsEqualTo(0); + await Assert.That(pty.SpawnCalls).IsEqualTo(0); + await Assert.That(orch.GetAgentForTest("acct-reviewer")).IsNull(); + } + + [Test] + public async Task Registered_account_whose_directory_is_gone_is_refused() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + Directory.Delete(work); + using var repo = GitRepo.CreateWithCommit(); + var server = new CaptureServerConnection(); + var pty = new SpyPtyProcessFactory(); + await using var orch = Build(server, pty, Spy(), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-gone", repo, WorkId)); + + await Assert.That(server.LaunchFailedCalls).Count().IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].Reason).StartsWith("account_unavailable: "); + await Assert.That(server.LaunchFailedCalls[0].Reason).Contains(work); + await Assert.That(pty.SpawnCalls).IsEqualTo(0); + await Assert.That(Directory.Exists(repo.PathTo(".capacitor", "worktrees"))).IsFalse(); + } + + [Test] + public async Task Spawn_frame_account_id_resolves_like_a_server_launch() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + var cwd = Tmp.CreateDir("cwd"); + var pty = LivePty(); + var launcher = Spy(); + await using var orch = Build(new CaptureServerConnection(), pty, launcher); + + await SpawnAsync(orch, cwd, WorkId); + + var expected = new ResolvedAccount(work, WorkId, "Work"); + await Assert.That(launcher.LastContext!.Account).IsEqualTo(expected); + await Assert.That(orch.GetAgentForTest(launcher.LastContext.AgentId)!.Account).IsEqualTo(expected); + + var refused = await SpawnAsync(orch, cwd, "no-such-account"); + + await Assert.That(refused.Single(f => f.Type == FrameType.Error).Text).IsEqualTo(RefusalFor("no-such-account")); + await Assert.That(refused.Any(f => f.Type is FrameType.Attached or FrameType.AttachedReadOnly)).IsFalse(); + await Assert.That(pty.SpawnCalls).IsEqualTo(1); + } + + /// The local spawn hands its child the account's directory, and drops the variable for the + /// built-in one rather than naming it. + [Test] + public async Task Spawn_frame_launch_hands_the_child_its_account_directory() { + const string PersonalId = "c1a0de02-personal"; + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + var cwd = Tmp.CreateDir("cwd"); + var pty = new SpyPtyProcessFactory(); + await using var orch = Build(new CaptureServerConnection(), pty, Spy()); + Register(PersonalId, HarnessId.Claude, new TempDirHandle(DaemonHome.Path).CreateDir(".claude"), "Personal"); + + await SpawnAsync(orch, cwd, WorkId); + + await Assert.That(pty.LastEnv!["CLAUDE_CONFIG_DIR"]).IsEqualTo(work); + await Assert.That(pty.LastRemoveEnv ?? []).DoesNotContain("CLAUDE_CONFIG_DIR"); + + await SpawnAsync(orch, cwd, PersonalId); + + await Assert.That(pty.SpawnCalls).IsEqualTo(2); + await Assert.That(pty.LastEnv!.ContainsKey("CLAUDE_CONFIG_DIR")).IsFalse(); + await Assert.That(pty.LastRemoveEnv!).Contains("CLAUDE_CONFIG_DIR"); + } + + [Test] + public async Task Status_dto_names_the_resolved_account_directory_id_and_label() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(new CaptureServerConnection(), LivePty(), Spy(), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-status", repo, WorkId)); + + var row = orch.SnapshotAgentsForStatus().Single(a => a.Id == "acct-status"); + await Assert.That(row.AccountDirectory).IsEqualTo(work); + await Assert.That(row.AccountId).IsEqualTo(WorkId); + await Assert.That(row.AccountLabel).IsEqualTo("Work"); + } + + [Test] + public async Task Registration_and_live_agent_report_carry_the_resolved_account() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + var server = new CaptureServerConnection(); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(server, LivePty(), Spy(), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-report", repo, WorkId)); + + await Assert.That(server.AgentRegisteredAccounts).IsEquivalentTo([("acct-report", (string?)work, (string?)WorkId, (string?)"Work")]); + + var live = orch.BuildLiveAgents().Single(a => a.Id == "acct-report"); + await Assert.That(live.AccountDirectory).IsEqualTo(work); + await Assert.That(live.AccountId).IsEqualTo(WorkId); + await Assert.That(live.AccountLabel).IsEqualTo("Work"); + } + + /// A reconnect re-registers the agent, and the server has no launch to read the account from. + [Test] + public async Task Reconnect_registration_carries_the_resolved_account() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + var server = new CaptureServerConnection(); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(server, LivePty(), Spy(), repo); + await orch.HandleLaunchAgentForTest(Launch("acct-rereg", repo, WorkId)); + + await orch.ReRegisterAgentsForTestAsync(); + + var expected = ("acct-rereg", (string?)work, (string?)WorkId, (string?)"Work"); + await Assert.That(server.AgentRegisteredAccounts).IsEquivalentTo([expected, expected]); + } + + [Test] + public async Task Unregistered_default_reports_the_directory_and_a_null_id() { + var physical = AccountDirectory.Normalize(ClaudeDefault); + var server = new CaptureServerConnection(); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(server, LivePty(), Spy(), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-unregistered", repo, accountId: null)); + + var row = orch.SnapshotAgentsForStatus().Single(a => a.Id == "acct-unregistered"); + await Assert.That(row.AccountDirectory).IsEqualTo(physical); + await Assert.That(row.AccountId).IsNull(); + await Assert.That(row.AccountLabel).IsEqualTo(".claude"); + await Assert.That(server.AgentRegisteredAccounts).IsEquivalentTo([("acct-unregistered", (string?)physical, (string?)null, (string?)".claude")]); + + var live = orch.BuildLiveAgents().Single(a => a.Id == "acct-unregistered"); + await Assert.That(live.AccountDirectory).IsEqualTo(physical); + await Assert.That(live.AccountId).IsNull(); + await Assert.That(live.AccountLabel).IsEqualTo(".claude"); + } + + [Test] + public async Task Vendor_without_accounts_reports_no_account() { + var server = new CaptureServerConnection(); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(server, LivePty(), Spy("gemini"), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-none", repo, accountId: null, vendor: "gemini")); + + var row = orch.SnapshotAgentsForStatus().Single(a => a.Id == "acct-none"); + await Assert.That((row.AccountDirectory, row.AccountId, row.AccountLabel)).IsEqualTo(((string?)null, (string?)null, (string?)null)); + await Assert.That(server.AgentRegisteredAccounts).IsEquivalentTo([("acct-none", (string?)null, (string?)null, (string?)null)]); + + var live = orch.BuildLiveAgents().Single(a => a.Id == "acct-none"); + await Assert.That((live.AccountDirectory, live.AccountId, live.AccountLabel)).IsEqualTo(((string?)null, (string?)null, (string?)null)); + } + + /// A resuming or interrupted agent has no process, so its row reads the account its ledger entry recorded. + [Test] + public async Task Ledger_only_status_row_reports_the_account_its_entry_recorded() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + await using var orch = Build(new CaptureServerConnection(), LivePty(), Spy()); + var entry = ResumeLedgerEntries.For("acct-ledger", ResumeState.Interrupted); + orch.ResumeLedger!.Write(entry with { Launch = entry.Launch with { AccountDirectory = work, AccountId = WorkId, AccountLabel = "Work" } }); + + var row = orch.SnapshotAgentsForStatus().Single(a => a.Id == "acct-ledger"); + + await Assert.That(row.AccountDirectory).IsEqualTo(work); + await Assert.That(row.AccountId).IsEqualTo(WorkId); + await Assert.That(row.AccountLabel).IsEqualTo("Work"); + } + + [Test] + public async Task Malformed_registry_refuses_an_explicit_id_and_defaults_a_null_one() { + Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + File.WriteAllText(Store.RegistryPath, "{ not a registry"); + using var repo = GitRepo.CreateWithCommit(); + var server = new CaptureServerConnection(); + var launcher = Spy(); + await using var orch = Build(server, LivePty(), launcher, repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-malformed-id", repo, WorkId)); + await orch.HandleLaunchAgentForTest(Launch("acct-malformed-default", repo, accountId: null)); + + await Assert.That(server.LaunchFailedCalls).Count().IsEqualTo(1); + await Assert.That(server.LaunchFailedCalls[0].AgentId).IsEqualTo("acct-malformed-id"); + await Assert.That(server.LaunchFailedCalls[0].Reason).IsEqualTo(RefusalFor(WorkId)); + await Assert.That(launcher.PrepareCalls).IsEqualTo(1); + await Assert.That(launcher.LastContext!.Account).IsEqualTo(new ResolvedAccount(AccountDirectory.Normalize(ClaudeDefault), null, ".claude")); + } + + /// Both layouts hold a transcript for the agent's worktree, so the one detection links + /// says which layout it searched. + [Test] + public async Task Codex_session_detection_searches_the_account_sessions_directory() { + var work = Register(CodexId, HarnessId.Codex, Tmp.CreateDir(".codex-work"), "Codex work"); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(new CaptureServerConnection(), LivePty(), Spy("codex"), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-codex-session", repo, CodexId, vendor: "codex")); + + var agent = orch.GetAgentForTest("acct-codex-session")!; + var worktree = agent.Worktree.Path; + StageRollout(new CodexPaths(DaemonHome, null).Sessions, EnvironmentSession, worktree); + var rollout = StageRollout(AccountLayouts.Codex(DaemonHome, work).Sessions, AccountSession, worktree); + + await WaitHarness.PollUntilAsync(() => agent.TranscriptPath is not null); + + await Assert.That(agent.SessionId).IsEqualTo(Dashless(AccountSession)); + await Assert.That(agent.TranscriptPath).IsEqualTo(rollout); + } + + /// + [Test] + public async Task Claude_session_detection_searches_the_account_projects_directory() { + var work = Register(WorkId, HarnessId.Claude, Tmp.CreateDir(".claude-work"), "Work"); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(new CaptureServerConnection(), LivePty(), Spy(), repo); + + await orch.HandleLaunchAgentForTest(Launch("acct-claude-session", repo, WorkId)); + + // A Claude launch pins its session id, so both transcripts carry that name and only the path differs. + var agent = orch.GetAgentForTest("acct-claude-session")!; + var worktree = agent.Worktree.Path; + StageTranscript(new ClaudePaths(DaemonHome, null).ProjectDir(worktree), agent.SessionId!, worktree); + var transcript = StageTranscript(AccountLayouts.Claude(DaemonHome, work).ProjectDir(worktree), agent.SessionId!, worktree); + + await WaitHarness.PollUntilAsync(() => agent.TranscriptPath is not null); + + await Assert.That(agent.TranscriptPath).IsEqualTo(transcript); + } + + [Test] + public async Task Codex_resolved_model_report_reads_the_account_config_toml() { + var work = Tmp.CreateDir(".codex-work"); + Register(CodexId, HarnessId.Codex, work, "Codex work"); + using var repo = GitRepo.CreateWithCommit(); + var server = new CaptureServerConnection(); + await using var orch = Build(server, LivePty(), Spy("codex"), repo); + new TempDirHandle(new CodexPaths(DaemonHome, null).Home).CreateFile("config.toml", "model = \"gpt-environment\"\n"); + work.CreateFile("config.toml", "model = \"gpt-account\"\n"); + + await orch.HandleLaunchAgentForTest(Launch("acct-codex-model", repo, CodexId, vendor: "codex", model: "default")); + + await Assert.That(server.ReportAgentResolvedModelCalls).IsEquivalentTo([("acct-codex-model", "gpt-account")]); + } + + static string Dashless(string sessionId) => sessionId.Replace("-", ""); + + static string StageTranscript(string projectDir, string sessionId, string cwd) => + new TempDirHandle(projectDir).CreateFile($"{sessionId}.jsonl", new JsonObject { ["type"] = "user", ["cwd"] = cwd }.ToJsonString() + "\n"); + + // Codex names the day folders and the rollout by local time. + static string StageRollout(string sessionsRoot, string sessionId, string cwd) { + var now = TimeProvider.System.GetLocalNow(); + var meta = new JsonObject { ["type"] = "session_meta", ["payload"] = new JsonObject { ["id"] = sessionId, ["cwd"] = cwd } }; + + return new TempDirHandle(sessionsRoot).CreateFile( + [Stamp("yyyy"), Stamp("MM"), Stamp("dd"), $"rollout-{Stamp("yyyy-MM-dd")}T{Stamp("HH-mm-ss")}-{sessionId}.jsonl"], + meta.ToJsonString() + "\n"); + + string Stamp(string format) => now.ToString(format, CultureInfo.InvariantCulture); + } + + static async Task> SpawnAsync(AgentOrchestrator orch, string cwd, string accountId) { + var readBuf = new MemoryStream(); + await FrameCodec.WriteAsync(readBuf, LocalFrame.Detach(), default); + readBuf.Position = 0; + using var client = new DuplexTestStream(readBuf, new MemoryStream()); + + await orch.HandleLocalSpawnAsync( + FrameCodec.Spawn("claude", WorkLocation.BorrowedCwd, isPrivate: true, cwd, [], 80, 24, accountId: accountId), client, default); + + client.WrittenStream.Position = 0; + var frames = new List(); + while (await FrameCodec.ReadAsync(client.WrittenStream, default) is { } f) frames.Add(f); + + return frames; + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorHarness.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorHarness.cs index 3628a54abe..c94673c19c 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorHarness.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorHarness.cs @@ -90,7 +90,7 @@ internal static AgentOrchestrator BuildOrchestrator( // test that still needs that vendor's launcher registered for the local-spawn path. var runtimeFactories = new Dictionary(); foreach (var l in launchers.Values) - runtimeFactories[l.Vendor] = new PtyHostedAgentRuntimeFactory(l, ptyFactory, NullLogger.Instance, TimeProvider.System); + runtimeFactories[l.Vendor] = new PtyHostedAgentRuntimeFactory(l, ptyFactory, NullLogger.Instance, TimeProvider.System, config.Home); foreach (var f in extraRuntimeFactories ?? []) runtimeFactories[f.Vendor] = f; diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs index d80168d295..8602037bdc 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorLocalAttachTests.cs @@ -790,7 +790,7 @@ public async Task Attach_over_the_real_socket_refuses_a_hosted_agent_with_no_ter }); var config = new DaemonConfig { Store = daemons.Store, Name = "test", ServerUrl = "http://127.0.0.1:1" }; - listener = new LocalControlServer(config, orch, TestCoordinator(daemons.Store), TestConsentIpc(config, daemons.CreateDir("consent")), TestPermissionIpc(), TestStatusIpc(config, orch, server), TestSettingsIpc(config, orch), TestFences.Ipc(config, orch), TestShutdown.Ipc(orch, TestCoordinator(daemons.Store)), NullLogger.Instance); + listener = new LocalControlServer(config, orch, TestCoordinator(daemons.Store), TestConsentIpc(config, daemons.CreateDir("consent")), TestPermissionIpc(), TestStatusIpc(config, orch, server), TestSettingsIpc(config, orch), TestFences.Ipc(config, orch), TestShutdown.Ipc(orch, TestCoordinator(daemons.Store)), TestAccountsIpc.Inert(), NullLogger.Instance); await listener.StartAsync(cts.Token); var sockPath = daemons.Store.SocketPath("test"); @@ -839,7 +839,7 @@ public async Task Local_socket_list_round_trips_registered_agents_over_a_real_so }); var config = new DaemonConfig { Store = daemons.Store, Name = "test", ServerUrl = "http://127.0.0.1:1" }; - listener = new LocalControlServer(config, orch, TestCoordinator(daemons.Store), TestConsentIpc(config, daemons.CreateDir("consent")), TestPermissionIpc(), TestStatusIpc(config, orch, server), TestSettingsIpc(config, orch), TestFences.Ipc(config, orch), TestShutdown.Ipc(orch, TestCoordinator(daemons.Store)), NullLogger.Instance); + listener = new LocalControlServer(config, orch, TestCoordinator(daemons.Store), TestConsentIpc(config, daemons.CreateDir("consent")), TestPermissionIpc(), TestStatusIpc(config, orch, server), TestSettingsIpc(config, orch), TestFences.Ipc(config, orch), TestShutdown.Ipc(orch, TestCoordinator(daemons.Store)), TestAccountsIpc.Inert(), NullLogger.Instance); await listener.StartAsync(cts.Token); var sockPath = daemons.Store.SocketPath("test"); @@ -877,7 +877,7 @@ public async Task Local_socket_list_round_trips_registered_agents_over_a_real_so orch.SeedAgentForTest("flow-1", kind: LaunchKind.ReviewFlow, flowRunId: "flow-7f3a", flowRole: "reviewer"); var config = new DaemonConfig { Store = daemons.Store, Name = daemonName, ServerUrl = "http://127.0.0.1:1" }; - listener = new LocalControlServer(config, orch, TestCoordinator(daemons.Store), TestConsentIpc(config, daemons.CreateDir("consent")), TestPermissionIpc(), TestStatusIpc(config, orch, server), TestSettingsIpc(config, orch), TestFences.Ipc(config, orch), TestShutdown.Ipc(orch, TestCoordinator(daemons.Store)), NullLogger.Instance); + listener = new LocalControlServer(config, orch, TestCoordinator(daemons.Store), TestConsentIpc(config, daemons.CreateDir("consent")), TestPermissionIpc(), TestStatusIpc(config, orch, server), TestSettingsIpc(config, orch), TestFences.Ipc(config, orch), TestShutdown.Ipc(orch, TestCoordinator(daemons.Store)), TestAccountsIpc.Inert(), NullLogger.Instance); await listener.StartAsync(cts.Token); var sockPath = daemons.Store.SocketPath(daemonName); @@ -1051,7 +1051,7 @@ public async Task An_attach_asking_for_the_runtime_is_told_its_start_before_the_ sealed class EnvCapturingPtyFactory : IPtyProcessFactory { public Dictionary? LastEnv { get; private set; } - public IPtyProcess Spawn(string command, string[] args, string cwd, Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40) { + public IPtyProcess Spawn(string command, string[] args, string cwd, Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, IReadOnlyCollection? removeEnv = null) { LastEnv = extraEnv; return new StubPtyProcess(); @@ -1146,7 +1146,7 @@ sealed class TripwireServerConnection() : ServerConnection( public override Task SendTerminalDimensionsAsync(string agentId, int cols, int rows) { LastDims = (cols, rows); Calls.Add(nameof(SendTerminalDimensionsAsync)); return Task.CompletedTask; } public override Task LaunchFailedAsync(string agentId, string reason) { Calls.Add(nameof(LaunchFailedAsync)); return Task.CompletedTask; } public ConcurrentBag RegisteredTitles { get; } = []; - public override Task AgentRegisteredAsync(string agentId, string? prompt, string? model, string? effort, string? repoPath, string? sandboxPolicy = null, string? approvalPolicy = null, string? permissionPreset = null, string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null) { Calls.Add(nameof(AgentRegisteredAsync)); RegisteredTitles.Add(title); return Task.FromResult(AgentRegisteredResponse.Accepted); } + public override Task AgentRegisteredAsync(string agentId, string? prompt, string? model, string? effort, string? repoPath, string? sandboxPolicy = null, string? approvalPolicy = null, string? permissionPreset = null, string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null, string? accountDirectory = null, string? accountId = null, string? accountLabel = null) { Calls.Add(nameof(AgentRegisteredAsync)); RegisteredTitles.Add(title); return Task.FromResult(AgentRegisteredResponse.Accepted); } public override Task AgentStatusChangedAsync(string agentId, string status, string? sessionId) { Calls.Add(nameof(AgentStatusChangedAsync)); return Task.CompletedTask; } public override Task AgentUnregisteredAsync(string agentId, string? stopReason = null) { Calls.Add(nameof(AgentUnregisteredAsync)); return Task.CompletedTask; } public override Task UpdateRepoPathsAsync() { Calls.Add(nameof(UpdateRepoPathsAsync)); return Task.CompletedTask; } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorResumeAccountTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorResumeAccountTests.cs new file mode 100644 index 0000000000..1a97915d27 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentOrchestratorResumeAccountTests.cs @@ -0,0 +1,221 @@ +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Core.LocalIpc; +using Capacitor.Cli.Core.Resume; +using Capacitor.Cli.Daemon.Services; +using Capacitor.Cli.Daemon.Tests.Unit.Pty; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +/// +/// A resume relaunches under the account its ledger entry recorded, checked against the registry as it +/// is now; an account that can no longer be used interrupts the entry by name, with no continuation. +/// +[ParallelLimiter] +public class AgentOrchestratorResumeAccountTests { + const string WorkId = "c1a0de01-work"; + + [TempDir] public required TempDir Tmp { get; init; } + + readonly SpyHostedAgentLauncher _launcher = new("claude", cliPath: "spy-claude") { SupportsResume = true }; + readonly SpyPtyProcessFactory _pty = new(new ScriptedPtyProcess()); + + string ClaudeDefault => Tmp.CreateDir(".claude"); + string Repo => Tmp.CreateDir("repo"); + + AccountStore Store => field ??= new AccountStore(Tmp.PathTo("accounts")); + + string Register(string id, string directory, string label) { + var normalized = AccountDirectory.Normalize(directory); + Store.Mutate(r => (r with { Accounts = [.. r.Accounts, new VendorAccount(id, HarnessId.Claude, normalized, label, DateTimeOffset.UnixEpoch)] }, 0)); + + return normalized; + } + + AgentOrchestrator Build(string? allowedRepo = null, CaptureServerConnection? server = null) => + AgentOrchestratorHarness.BuildOrchestrator( + server ?? new CaptureServerConnection(), _pty, new Dictionary { [_launcher.Vendor] = _launcher }, + allowedRepoPath: allowedRepo ?? Repo, + configure: c => { + c.InstanceId = "instance-now"; + c.ResumeReadinessWindow = TimeSpan.FromMilliseconds(100); + c.Accounts = Store; + c.DefaultDirectories = new Dictionary { [HarnessId.Claude] = ClaudeDefault }; + }); + + ResumeLedgerEntry Resuming(AgentOrchestrator orch, string agentId, string? directory, string? accountId, string? label) { + var stamp = TimeProvider.System.GetUtcNow(); + var launch = new ResumeLaunchDescriptor( + Prompt: "build the thing", Model: "opus", Effort: null, RepoPath: Repo, + WorktreePath: Tmp.CreateDir($"wt-{agentId}"), Work: WorkLocation.OwnedWorktree, + GitRelativeCwd: null, SandboxPolicy: null, ApprovalPolicy: null, PermissionPreset: null, + McpConfigPath: null, Title: "the thing", IsPrivate: false, Origin: "server", + RequesterUserId: "user-1", RequesterDisplay: "User One", ConsentSource: "default", + ConsentAt: stamp.ToString("O"), FlowRunId: null, FlowRole: null, + WorktreeBranch: $"capacitor/custom-{agentId}", WorktreeIsStandalone: false, WorktreeSourceRepo: Repo, + AccountDirectory: directory, AccountId: accountId, AccountLabel: label); + var entry = new ResumeLedgerEntry( + ResumeLedgerEntry.CurrentSchemaVersion, agentId, "daemon-1", stamp, stamp, "claude", "pty", + $"session-{agentId}", $"vendor-{agentId}", launch, ResumeState.Resuming, Attempts: 0, LastError: null, + PredecessorInstanceId: "instance-old", ResumingAt: stamp); + orch.ResumeLedger!.Write(entry); + + return entry; + } + + static async Task ResumeAsync(AgentOrchestrator orch) { + orch.LoadResumeLedger(); + await orch.ResumeOnBootAsync(CancellationToken.None); + } + + /// Counts the continuations the lane asks for; each one is reported as failed. + static Func CountContinuations(AgentOrchestrator orch) { + var continued = 0; + orch.ContinueEntry = (_, _, _) => { + Interlocked.Increment(ref continued); + return Task.FromResult(ResumeOutcome.Interrupted); + }; + + return () => Volatile.Read(ref continued); + } + + static (string? Directory, string? Id, string? Label) Recorded(ResumeLedgerEntry entry) => + (entry.Launch.AccountDirectory, entry.Launch.AccountId, entry.Launch.AccountLabel); + + static LaunchAgentCommand Launch(string agentId, string repoPath, string? accountId) => new( + AgentId: agentId, Prompt: "hi", Model: "opus", Effort: null, RepoPath: repoPath, + Tools: null, AttachmentIds: null, Vendor: "claude", AccountId: accountId); + + /// A default launch records its directory too: a resume after the default moves needs it. + [Test] + public async Task Ledger_entry_records_the_resolved_account() { + var work = Register(WorkId, Tmp.CreateDir(".claude-work"), "Work"); + using var repo = GitRepo.CreateWithCommit(); + await using var orch = Build(repo); + var atStart = new Dictionary(); + _launcher.OnPrepare = ctx => atStart[ctx.AgentId] = orch.ResumeLedger!.TryRead(ctx.AgentId); + + await orch.HandleLaunchAgentForTest(Launch("ledger-work", repo, WorkId)); + await orch.HandleLaunchAgentForTest(Launch("ledger-default", repo, accountId: null)); + + await Assert.That(Recorded(atStart["ledger-work"]!)).IsEqualTo((work, WorkId, "Work")); + await Assert.That(Recorded(orch.ResumeLedger!.TryRead("ledger-work")!)).IsEqualTo((work, WorkId, "Work")); + await Assert.That(Recorded(atStart["ledger-default"]!)).IsEqualTo((AccountDirectory.Normalize(ClaudeDefault), (string?)null, ".claude")); + await Assert.That(Recorded(orch.ResumeLedger!.TryRead("ledger-default")!)).IsEqualTo((AccountDirectory.Normalize(ClaudeDefault), (string?)null, ".claude")); + } + + [Test] + public async Task Resume_relaunches_under_the_ledger_directory_after_the_default_changed() { + var before = Tmp.CreateDir(".claude-before"); + await using var orch = Build(); + Resuming(orch, "resume-moved", before, accountId: null, ".claude-before"); + + await ResumeAsync(orch); + + var expected = new ResolvedAccount(AccountDirectory.Normalize(before), null, ".claude-before"); + await Assert.That(_launcher.LastContext!.Account).IsEqualTo(expected); + await Assert.That(orch.GetAgentForTest("resume-moved")!.Account).IsEqualTo(expected); + await Assert.That(_pty.LastEnv!["CLAUDE_CONFIG_DIR"]).IsEqualTo(AccountDirectory.Normalize(before)); + await Assert.That(orch.ResumeLedger!.TryRead("resume-moved")!.State).IsEqualTo(ResumeState.Running); + } + + [Test] + public async Task Resume_of_a_removed_registered_account_fails_with_account_unavailable_and_skips_continue() { + var work = Register(WorkId, Tmp.CreateDir(".claude-work"), "Work"); + Store.Mutate(r => (r with { Accounts = [] }, 0)); + var server = new CaptureServerConnection(); + await using var orch = Build(server: server); + var entry = Resuming(orch, "resume-removed", work, WorkId, "Work"); + var continued = CountContinuations(orch); + var refusal = AccountResolution.ResolveForResume(Store.TryLoad(), HarnessId.Claude, WorkId, work, ClaudeDefault).Refusal!; + + await ResumeAsync(orch); + + var after = orch.ResumeLedger!.TryRead("resume-removed")!; + await Assert.That(after.State).IsEqualTo(ResumeState.Interrupted); + await Assert.That(after.LastError).StartsWith("account_unavailable: "); + await Assert.That(after.LastError).IsEqualTo(refusal); + await Assert.That(after.Attempts).IsEqualTo(1); + await Assert.That(continued()).IsEqualTo(0); + await Assert.That(_launcher.PrepareCalls).IsEqualTo(0); + await Assert.That(_pty.SpawnCalls).IsEqualTo(0); + await Assert.That(Directory.Exists(entry.Launch.WorktreePath)).IsTrue(); + await Assert.That(server.ShutdownReports.Select(r => (r.AgentId, r.Status, r.Reason))) + .Contains(("resume-removed", "Failed", $"resume_failed:{refusal}")); + await Assert.That(orch.ResumingClaims()).IsEmpty(); + } + + /// With no vendor session the lane goes straight to the continuation, so the account check has + /// to come first there too. + [Test] + public async Task Resume_without_a_vendor_session_of_a_removed_registered_account_fails_with_account_unavailable() { + var work = Register(WorkId, Tmp.CreateDir(".claude-work"), "Work"); + Store.Mutate(r => (r with { Accounts = [] }, 0)); + var server = new CaptureServerConnection(); + await using var orch = Build(server: server); + var entry = Resuming(orch, "resume-sessionless", work, WorkId, "Work"); + orch.ResumeLedger!.Write(entry with { VendorSession = null }); + var continued = CountContinuations(orch); + var refusal = AccountResolution.ResolveForResume(Store.TryLoad(), HarnessId.Claude, WorkId, work, ClaudeDefault).Refusal!; + + await ResumeAsync(orch); + + var after = orch.ResumeLedger!.TryRead("resume-sessionless")!; + await Assert.That(after.State).IsEqualTo(ResumeState.Interrupted); + await Assert.That(after.LastError).StartsWith("account_unavailable: "); + await Assert.That(after.LastError).IsEqualTo(refusal); + await Assert.That(after.Attempts).IsEqualTo(1); + await Assert.That(continued()).IsEqualTo(0); + await Assert.That(_pty.SpawnCalls).IsEqualTo(0); + await Assert.That(server.ShutdownReports.Select(r => (r.AgentId, r.Status, r.Reason))) + .Contains(("resume-sessionless", "Failed", $"resume_failed:{refusal}")); + } + + /// The resume leaves the registry as it found it: an unregistered default stays unregistered. + [Test] + public async Task Resume_of_an_unregistered_default_uses_its_stored_directory() { + Register(WorkId, Tmp.CreateDir(".claude-work"), "Work"); + var personal = Tmp.CreateDir(".claude-personal"); + await using var orch = Build(); + Resuming(orch, "resume-personal", personal, accountId: null, ".claude-personal"); + + await ResumeAsync(orch); + + var expected = new ResolvedAccount(AccountDirectory.Normalize(personal), null, ".claude-personal"); + await Assert.That(_launcher.LastContext!.Account).IsEqualTo(expected); + await Assert.That(orch.GetAgentForTest("resume-personal")!.Account).IsEqualTo(expected); + await Assert.That(Recorded(orch.ResumeLedger!.TryRead("resume-personal")!)).IsEqualTo((personal, (string?)null, ".claude-personal")); + await Assert.That(Store.TryLoad()!.Accounts.Select(a => a.Id)).IsEquivalentTo([WorkId]); + } + + [Test] + public async Task Resume_of_an_unregistered_default_whose_directory_is_gone_fails_named() { + var gone = Tmp.PathTo(".claude-gone"); + await using var orch = Build(); + Resuming(orch, "resume-gone", gone, accountId: null, ".claude-gone"); + var continued = CountContinuations(orch); + + await ResumeAsync(orch); + + var after = orch.ResumeLedger!.TryRead("resume-gone")!; + await Assert.That(after.State).IsEqualTo(ResumeState.Interrupted); + await Assert.That(after.LastError).StartsWith("account_unavailable: "); + await Assert.That(after.LastError).Contains(gone); + await Assert.That(continued()).IsEqualTo(0); + await Assert.That(_pty.SpawnCalls).IsEqualTo(0); + } + + [Test] + public async Task Legacy_entry_without_account_fields_resumes_under_the_current_default() { + await using var orch = Build(); + Resuming(orch, "resume-legacy", directory: null, accountId: null, label: null); + + await ResumeAsync(orch); + + var expected = new ResolvedAccount(AccountDirectory.Normalize(ClaudeDefault), null, ".claude"); + await Assert.That(_launcher.LastContext!.Account).IsEqualTo(expected); + await Assert.That(orch.GetAgentForTest("resume-legacy")!.Account).IsEqualTo(expected); + await Assert.That(orch.ResumeLedger!.TryRead("resume-legacy")!.State).IsEqualTo(ResumeState.Running); + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentResolvedTitleTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentResolvedTitleTests.cs index 156d6fce7d..1fd6be9e98 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentResolvedTitleTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentResolvedTitleTests.cs @@ -24,7 +24,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("AgentResolvedTitleTests never spawns a PTY"); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentStatusSnapshotTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentStatusSnapshotTests.cs index 01b8ea5825..e0f473603b 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentStatusSnapshotTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/AgentStatusSnapshotTests.cs @@ -31,7 +31,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("AgentStatusSnapshotTests never spawns a PTY"); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/CaptureServerConnection.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/CaptureServerConnection.cs index 2c81773e22..caf8f02a25 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/CaptureServerConnection.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/CaptureServerConnection.cs @@ -123,6 +123,9 @@ public override Task DaemonStatusReportAsync(DaemonStatusReport report) { public List<(string AgentId, string? Transport)> AgentRegisteredTransports { get; } = []; public List<(string AgentId, string? Vendor)> AgentRegisteredVendors { get; } = []; + /// The account each registration named, in call order. + public List<(string AgentId, string? Directory, string? AccountId, string? Label)> AgentRegisteredAccounts { get; } = []; + /// The start title echoed on each registration, in call order. public List<(string AgentId, AgentStartTitle? Title)> AgentRegisteredTitles { get; } = []; @@ -135,7 +138,8 @@ public override Task DaemonStatusReportAsync(DaemonStatusReport report) { public override async Task AgentRegisteredAsync( string agentId, string? prompt, string? model, string? effort, string? repoPath, string? sandboxPolicy = null, string? approvalPolicy = null, string? permissionPreset = null, - string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null) { + string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null, + string? accountDirectory = null, string? accountId = null, string? accountLabel = null) { AgentRegisteredCallCount++; lock (AgentRegisteredContinues) AgentRegisteredContinues.Add((agentId, continuesAgentId)); lock (AgentRegisteredTitles) AgentRegisteredTitles.Add((agentId, title)); @@ -144,6 +148,7 @@ public override async Task AgentRegisteredAsync( lock (AgentRegisteredPostures) AgentRegisteredPostures.Add((agentId, sandboxPolicy, approvalPolicy)); lock (AgentRegisteredTransports) AgentRegisteredTransports.Add((agentId, runtimeTransport)); lock (AgentRegisteredVendors) AgentRegisteredVendors.Add((agentId, vendor)); + lock (AgentRegisteredAccounts) AgentRegisteredAccounts.Add((agentId, accountDirectory, accountId, accountLabel)); AgentRegisteredEntered?.TrySetResult(); if (AgentRegisteredGate is { } gate) await gate.Task.ConfigureAwait(false); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ConsentRulesPutV2Tests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ConsentRulesPutV2Tests.cs index 39b5c57d91..0fbcf853d1 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ConsentRulesPutV2Tests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ConsentRulesPutV2Tests.cs @@ -33,7 +33,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("ConsentRulesPutV2Tests never spawns a PTY"); } @@ -82,7 +83,7 @@ async Task StartAsync(string daemonName, CancellationToken ct, string s var statusIpc = new DaemonStatusIpc(config, orchestrator, connection, notifier, TimeProvider.System); var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(daemons.Store, daemonName, daemonName, new NoopRestartStrategy(), TimeProvider.System); - var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(ct); var sockPath = daemons.Store.SocketPath(daemonName); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonSettingsIpcTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonSettingsIpcTests.cs index 73fc47e34f..343f1c3840 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonSettingsIpcTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonSettingsIpcTests.cs @@ -39,7 +39,7 @@ static async Task StartAsync(ServerConnection server, CancellationToken var statusIpc = new DaemonStatusIpc(config, orchestrator, server, notifier, TimeProvider.System); var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(config.Store, config.Name, "test", new NoopRestartStrategy(), TimeProvider.System); - var control = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + var control = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await control.StartAsync(ct); var sockPath = config.Store.SocketPath(config.Name); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusIpcTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusIpcTests.cs index 678daf15bf..15f38091cd 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusIpcTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusIpcTests.cs @@ -39,7 +39,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("DaemonStatusIpcTests never spawns a PTY"); } @@ -188,7 +189,7 @@ async Task StartAsync(string daemonName, CancellationToken ct) { var permissionIpc = new PermissionIpc(new PermissionPromptBroker(), NullLogger.Instance); var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(daemons.Store, daemonName, daemonName, new NoopRestartStrategy(), TimeProvider.System); - var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(ct); var sockPath = daemons.Store.SocketPath(daemonName); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusWiringTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusWiringTests.cs index 833977ea8b..3df98f23e4 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusWiringTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/DaemonStatusWiringTests.cs @@ -60,7 +60,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("DaemonStatusWiringTests never spawns a PTY"); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LaunchConsentIpcTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LaunchConsentIpcTests.cs index 320af34915..9928f08151 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LaunchConsentIpcTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LaunchConsentIpcTests.cs @@ -33,7 +33,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("LaunchConsentIpcTests never spawns a PTY"); } @@ -87,7 +88,7 @@ async Task StartAsync( var statusIpc = new DaemonStatusIpc(config, orchestrator, connection, notifier, TimeProvider.System); var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(daemons.Store, daemonName, daemonName, new NoopRestartStrategy(), TimeProvider.System); - var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(ct); var sockPath = daemons.Store.SocketPath(daemonName); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LiveAgentsSnapshotTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LiveAgentsSnapshotTests.cs index 22aa979b65..7268bc9db2 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LiveAgentsSnapshotTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LiveAgentsSnapshotTests.cs @@ -1,4 +1,5 @@ using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Daemon.Services; using Capacitor.Cli.Daemon.Tests.Unit.Pty; @@ -27,4 +28,17 @@ public async Task BuildLiveAgents_carries_reviewflow_identity_and_excludes_stopp await Assert.That(live[0].FlowRunId).IsEqualTo("flow-1"); await Assert.That(live[0].FlowRole).IsEqualTo("reviewer"); } + + [Test] + public async Task BuildLiveAgents_names_the_account_of_a_launch_still_in_its_handshake() { + await using var orch = AgentOrchestratorHarness.BuildOrchestrator( + new CaptureServerConnection(), new SpyPtyProcessFactory(), new Dictionary()); + var clock = new AgentActivityClock(TimeProvider.System); + using var pending = orch.TrackPendingLaunch("p1", LaunchKind.Default, null, null, clock, vendor: "claude", account: new ResolvedAccount("/home/.claude-work", "work-id", "Work")); + + var live = orch.BuildLiveAgents().Single(); + + await Assert.That(live.Id).IsEqualTo("p1"); + await Assert.That((live.AccountDirectory, live.AccountId, live.AccountLabel)).IsEqualTo(((string?)"/home/.claude-work", (string?)"work-id", (string?)"Work")); + } } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlAgentResumeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlAgentResumeTests.cs index 4258094e4c..c91925b61d 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlAgentResumeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlAgentResumeTests.cs @@ -32,7 +32,8 @@ sealed class SpyStrategy : IRestartStrategy { sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("never spawns a PTY"); } @@ -77,7 +78,7 @@ async Task RunAsync(string name, Func.Instance), new DaemonStatusIpc(config, orchestrator, connection, notifier, TimeProvider.System), new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance), - TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart, lifetime), NullLogger.Instance); + TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart, lifetime), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(cts.Token); try { diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlCapabilitiesTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlCapabilitiesTests.cs index ec51b81cff..36d7be15fc 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlCapabilitiesTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlCapabilitiesTests.cs @@ -13,6 +13,6 @@ public class LocalControlCapabilitiesTests { [Test] public async Task The_advertised_list_is_exactly_the_routed_capabilities() => await Assert.That(LocalControlCapabilities.Current) - .IsEquivalentTo(new[] { "consent/1", "consent/2", "consent/3", "status/1", "permission/1", "input/1", "input/2", "settings/1", "fence/1", "shutdown/1", "resume/1", "attach-runtime/1", "agent-session/1" }, + .IsEquivalentTo(new[] { "consent/1", "consent/2", "consent/3", "status/1", "permission/1", "input/1", "input/2", "settings/1", "fence/1", "shutdown/1", "resume/1", "attach-runtime/1", "agent-session/1", "accounts/1" }, CollectionOrdering.Matching); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlHelloTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlHelloTests.cs index 77d2cbedc5..478632a6e0 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlHelloTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlHelloTests.cs @@ -34,7 +34,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("LocalControlHelloTests never spawns a PTY"); } @@ -83,7 +84,7 @@ async Task StartAsync(string daemonName, CancellationToken ct) { var statusIpc = new DaemonStatusIpc(config, orchestrator, connection, notifier, TimeProvider.System); var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(daemons.Store, daemonName, daemonName, new NoopRestartStrategy(), TimeProvider.System); - var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(ct); var sockPath = daemons.Store.SocketPath(daemonName); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlOpsV2PutTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlOpsV2PutTests.cs index b3add629ab..c639198a17 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlOpsV2PutTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlOpsV2PutTests.cs @@ -32,7 +32,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("LocalControlOpsV2PutTests never spawns a PTY"); } @@ -81,7 +82,7 @@ async Task StartAsync(string daemonName, CancellationToken ct, string s var statusIpc = new DaemonStatusIpc(config, orchestrator, connection, notifier, TimeProvider.System); var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(daemons.Store, daemonName, daemonName, new NoopRestartStrategy(), TimeProvider.System); - var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(ct); var sockPath = daemons.Store.SocketPath(daemonName); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlProbeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlProbeTests.cs index 1967d61427..4ebbc74c41 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlProbeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlProbeTests.cs @@ -30,7 +30,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("LocalControlProbeTests never spawns a PTY"); } @@ -79,7 +80,7 @@ async Task StartAsync(string daemonName, CancellationToken ct) { var statusIpc = new DaemonStatusIpc(config, orchestrator, connection, notifier, TimeProvider.System); var settingsIpc = new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance); var restart = RestartCoordinator.ForTest(daemons.Store, daemonName, daemonName, new NoopRestartStrategy(), TimeProvider.System); - var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), NullLogger.Instance); + var server = new LocalControlServer(config, orchestrator, restart, consentIpc, permissionIpc, statusIpc, settingsIpc, TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(ct); var sockPath = daemons.Store.SocketPath(daemonName); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlShutdownTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlShutdownTests.cs index c963afc994..dabc1acb65 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlShutdownTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/LocalControlShutdownTests.cs @@ -36,7 +36,8 @@ sealed class SpyStrategy : IRestartStrategy { sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("never spawns a PTY"); } @@ -82,7 +83,7 @@ async Task RunAsync(string name, Func? hardBusy, Func.Instance), new DaemonStatusIpc(config, orchestrator, connection, notifier, TimeProvider.System), new DaemonSettingsIpc(config, orchestrator, notifier, NullLogger.Instance), - TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart, lifetime), NullLogger.Instance); + TestFences.Ipc(config, orchestrator), TestShutdown.Ipc(orchestrator, restart, lifetime), TestAccountsIpc.Inert(), NullLogger.Instance); await server.StartAsync(cts.Token); try { diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PermissionWiringTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PermissionWiringTests.cs index 6bdc240016..761813bce8 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PermissionWiringTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PermissionWiringTests.cs @@ -36,7 +36,8 @@ public void StopApplication() { } sealed class NoopPtyProcessFactory : IPtyProcessFactory { public IPtyProcess Spawn( string command, string[] args, string cwd, - Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40 + Dictionary? extraEnv = null, ushort cols = 120, ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => throw new NotSupportedException("PermissionWiringTests never spawns a PTY"); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryEnvTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryEnvTests.cs new file mode 100644 index 0000000000..35aa5038d5 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryEnvTests.cs @@ -0,0 +1,75 @@ +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Daemon.Pty.Unix; +using Capacitor.Cli.Daemon.Services; +using Capacitor.Cli.Daemon.Tests.Unit.Pty; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +/// A daemon started with CLAUDE_CONFIG_DIR set hands every child that value unless the +/// launch says otherwise, so the spawn must name the account's directory or drop the variable. +[NotInParallel] +public class PtyHostedAgentRuntimeFactoryEnvTests { + const string Variable = "CLAUDE_CONFIG_DIR"; + + [TempHome] public required TempHome Home { get; init; } + + string DaemonDefault => Home.CreateDir(".claude-work"); + + /// The spawn's overlay, and the environment the PTY builds from it over the daemon's own. + async Task<(IReadOnlyDictionary Overlay, IReadOnlyDictionary Child)> ChildEnvFor(ResolvedAccount account) { + var pty = new SpyPtyProcessFactory(); + var factory = new PtyHostedAgentRuntimeFactory( + new SpyHostedAgentLauncher("claude", "claude"), pty, NullLogger.Instance, + TimeProvider.System, Home); + + var start = await factory.StartAsync(Context(account), CancellationToken.None); + await start.Runtime.DisposeAsync(); + + return (pty.LastEnv!, UnixPtyProcess.BuildChildEnv(pty.LastEnv, pty.LastRemoveEnv, 120, 40)); + } + + RuntimeStartContext Context(ResolvedAccount account) { + var worktree = Home.CreateDir("wt"); + + return new( + AgentId: "agent-env-1", Vendor: "claude", SourceRepoPath: worktree, + Worktree: new WorktreeInfo(worktree, "wt", worktree), + Prompt: null, Model: null, Effort: null, Tools: null, + IsReview: false, IsReviewFlow: false, Review: null, + Cols: 120, Rows: 40, ServerUrl: "", DaemonBridgeUrl: null, CapacitorPath: "kcap", + Account: account); + } + + [Test] + public async Task Launch_resolved_to_the_built_in_directory_removes_the_inherited_variable() { + using var env = EnvScope.Exclusive(Variable, DaemonDefault); + + var (_, child) = await ChildEnvFor(new ResolvedAccount(Home.CreateDir(".claude"), "acct-personal", "Personal")); + + await Assert.That(child.ContainsKey(Variable)).IsFalse(); + await Assert.That(Environment.GetEnvironmentVariable(Variable)).IsEqualTo(DaemonDefault); + } + + [Test] + public async Task Launch_resolved_to_the_daemons_own_custom_default_sets_it_explicitly() { + using var env = EnvScope.Exclusive(Variable, DaemonDefault); + + var (overlay, child) = await ChildEnvFor(new ResolvedAccount(DaemonDefault, null, ".claude-work")); + + await Assert.That(overlay[Variable]).IsEqualTo(DaemonDefault); + await Assert.That(child[Variable]).IsEqualTo(DaemonDefault); + await Assert.That(Environment.GetEnvironmentVariable(Variable)).IsEqualTo(DaemonDefault); + } + + [Test] + public async Task Launch_resolved_to_another_custom_account_sets_that_directory() { + using var env = EnvScope.Exclusive(Variable, DaemonDefault); + var other = Home.CreateDir(".claude-other"); + + var (_, child) = await ChildEnvFor(new ResolvedAccount(other, "acct-other", "Other")); + + await Assert.That(child[Variable]).IsEqualTo(other); + await Assert.That(Environment.GetEnvironmentVariable(Variable)).IsEqualTo(DaemonDefault); + } +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryTests.cs index 7db280d7c2..67b28c43fa 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/PtyHostedAgentRuntimeFactoryTests.cs @@ -88,7 +88,7 @@ RuntimeStartContext BuildClaudeLaunchContext(string? permissionMode) { [Test] public async Task Interactive_launch_hands_the_permission_mode_to_the_launcher() { var launcher = new RecordingLauncher("claude", cliPath: "/opt/vendor/claude"); - var factory = new PtyHostedAgentRuntimeFactory(launcher, new NullPtyProcessFactory(), NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, new NullPtyProcessFactory(), NullLogger.Instance, TimeProvider.System, Home); var ctx = BuildInteractiveContext("claude") with { PermissionMode = "acceptEdits" }; var start = await factory.StartAsync(ctx, CancellationToken.None); @@ -105,7 +105,7 @@ public async Task Interactive_claude_launch_spawns_exactly_one_selected_mode_pai var config = new DaemonConfig { ClaudePath = "claude", ServerUrl = "", CapacitorPath = "kcap" }; var launcher = new ClaudeLauncher(config, TestHarnesses.Under(Home), NullLogger.Instance); var pty = new SpyPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System, Home); var start = await factory.StartAsync(BuildClaudeLaunchContext("auto"), CancellationToken.None); @@ -125,7 +125,7 @@ public async Task A_flow_participant_is_marked_with_its_kind_and_role_and_never_ var config = new DaemonConfig { ClaudePath = "claude", ServerUrl = "", CapacitorPath = "kcap" }; var launcher = new ClaudeLauncher(config, TestHarnesses.Under(Home), NullLogger.Instance); var pty = new SpyPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System, Home); var start = await factory.StartAsync( BuildClaudeLaunchContext(null) with { IsReviewFlow = true, FlowRole = "reviewer" }, CancellationToken.None); @@ -144,7 +144,7 @@ public async Task An_interactive_launch_is_a_hosted_agent_with_no_role() { var config = new DaemonConfig { ClaudePath = "claude", ServerUrl = "", CapacitorPath = "kcap" }; var launcher = new ClaudeLauncher(config, TestHarnesses.Under(Home), NullLogger.Instance); var pty = new SpyPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System, Home); var start = await factory.StartAsync(BuildClaudeLaunchContext(null), CancellationToken.None); @@ -163,7 +163,7 @@ public async Task A_private_launch_carries_no_hosted_agent_identity() { var config = new DaemonConfig { ClaudePath = "claude", ServerUrl = "", CapacitorPath = "kcap" }; var launcher = new ClaudeLauncher(config, TestHarnesses.Under(Home), NullLogger.Instance); var pty = new SpyPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System, Home); var start = await factory.StartAsync( BuildClaudeLaunchContext(null) with { IsPrivate = true, DaemonBridgeUrl = "http://127.0.0.1:9" }, CancellationToken.None); @@ -193,7 +193,7 @@ public async Task The_pty_scrub_covers_the_session_markers() { var launcher = new ClaudeLauncher(config, TestHarnesses.Under(Home), NullLogger.Instance); var process = new RecordingScriptedPtyProcess(); var pty = new SpyPtyProcessFactory(process); - var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, pty, NullLogger.Instance, TimeProvider.System, Home); return (factory, pty, process); } @@ -254,7 +254,7 @@ public async Task An_agent_that_exits_before_its_launch_prompt_is_logged() { var launcher = new ClaudeLauncher(config, TestHarnesses.Under(Home), NullLogger.Instance); var process = new RecordingScriptedPtyProcess(); var logger = new CapturingLogger(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, new SpyPtyProcessFactory(process), logger, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, new SpyPtyProcessFactory(process), logger, TimeProvider.System, Home); var start = await factory.StartAsync(BuildClaudeLaunchContext(null), CancellationToken.None); @@ -315,7 +315,7 @@ public async Task An_overlong_slash_command_is_pasted_only_on_Windows() { [Test] public async Task A_launch_that_does_not_put_the_prompt_on_the_command_line_pastes_nothing() { var launcher = new RecordingLauncher("claude", cliPath: "/opt/vendor/claude"); - var factory = new PtyHostedAgentRuntimeFactory(launcher, new NullPtyProcessFactory(), NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, new NullPtyProcessFactory(), NullLogger.Instance, TimeProvider.System, Home); var start = await factory.StartAsync(BuildInteractiveContext("claude") with { Prompt = "build it" }, CancellationToken.None); @@ -331,7 +331,7 @@ public async Task A_launch_that_does_not_put_the_prompt_on_the_command_line_past public async Task Review_launch_builds_the_MCP_command_from_CapacitorPath_not_the_agent_CliPath() { var launcher = new RecordingLauncher("claude", cliPath: "/opt/vendor/claude"); var ptyFactory = new NullPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System, Home); var ctx = BuildReviewContext("claude", capacitorPath: "/opt/kcap/kcap"); @@ -363,7 +363,7 @@ public async Task Review_launch_builds_the_MCP_command_from_CapacitorPath_not_th public async Task Review_launch_for_codex_builds_MCP_command_from_CapacitorPath_not_the_agent_CliPath() { var launcher = new RecordingLauncher("codex", cliPath: "/opt/vendor/codex"); var ptyFactory = new NullPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System, Home); var ctx = BuildReviewContext("codex", capacitorPath: "/opt/kcap/kcap"); @@ -391,7 +391,7 @@ public async Task Review_launch_for_codex_builds_MCP_command_from_CapacitorPath_ public async Task Codex_launch_threads_the_posture_into_the_launcher_context() { var launcher = new RecordingLauncher("codex", cliPath: "/opt/vendor/codex"); var ptyFactory = new NullPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System, Home); var ctx = BuildInteractiveContext("codex") with { CodexPosture = new("danger-full-access", "untrusted") }; @@ -411,7 +411,7 @@ public async Task Codex_launch_threads_the_posture_into_the_launcher_context() { public async Task Codex_launch_without_a_posture_threads_null_into_the_launcher_context() { var launcher = new RecordingLauncher("codex", cliPath: "/opt/vendor/codex"); var ptyFactory = new NullPtyProcessFactory(); - var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System); + var factory = new PtyHostedAgentRuntimeFactory(launcher, ptyFactory, NullLogger.Instance, TimeProvider.System, Home); var start = await factory.StartAsync(BuildInteractiveContext("codex"), CancellationToken.None); @@ -450,7 +450,8 @@ public IPtyProcess Spawn( string cwd, Dictionary? extraEnv = null, ushort cols = 120, - ushort rows = 40 + ushort rows = 40, + IReadOnlyCollection? removeEnv = null ) => new NoopPty(); sealed class NoopPty : IPtyProcess { diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeededLaunchEnvironmentTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeededLaunchEnvironmentTests.cs index 533e556f9e..7a6d3e5770 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeededLaunchEnvironmentTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeededLaunchEnvironmentTests.cs @@ -35,7 +35,7 @@ public class SeededLaunchEnvironmentTests { static IDictionary CodexChildEnvironment() { var env = new ProcessStartInfo().Environment; - CodexHostedAgentRuntimeFactory.ApplyChildEnv(env, CodexHostedAgentRuntimeFactory.BuildEnv(Ctx("codex"), emitEnvelopeTranscript: false)); + CodexHostedAgentRuntimeFactory.ApplyChildEnv(env, CodexHostedAgentRuntimeFactory.BuildEnv(Ctx("codex"), emitEnvelopeTranscript: false), remove: []); return env; } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeqCaptureServerConnection.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeqCaptureServerConnection.cs index 6ceb3a0dce..902a6cb207 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeqCaptureServerConnection.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SeqCaptureServerConnection.cs @@ -32,5 +32,6 @@ internal sealed class SeqCaptureServerConnection() : ServerConnection( public override Task AgentRegisteredAsync( string agentId, string? prompt, string? model, string? effort, string? repoPath, string? sandboxPolicy = null, string? approvalPolicy = null, string? permissionPreset = null, - string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null) => Task.FromResult(AgentRegisteredResponse.Accepted); + string? runtimeTransport = null, string? vendor = null, AgentStartTitle? title = null, string? continuesAgentId = null, + string? accountDirectory = null, string? accountId = null, string? accountLabel = null) => Task.FromResult(AgentRegisteredResponse.Accepted); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAdvertisementTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAdvertisementTests.cs index cb7afb37cc..b8c4a70326 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAdvertisementTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAdvertisementTests.cs @@ -1,4 +1,5 @@ using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Daemon.Services; using Microsoft.Extensions.Logging.Abstractions; @@ -98,6 +99,25 @@ await Assert.That(json).Contains( "\"resuming_agents\":[{\"agent_id\":\"agent-1\",\"predecessor_instance_id\":\"instance-old\",\"session_id\":\"session-1\"}]"); } + [Test] + public async Task Connect_payload_advertises_account_launches_and_carries_the_snapshot() { + var config = new DaemonConfig { Name = "test", ServerUrl = "http://127.0.0.1:1", ConfigRoot = Config.Root }; + await using var conn = new ServerConnection(config, new FixedCredentialSource(), NullLoggerFactory.Instance, + NullLogger.Instance, TimeProvider.System); + var snapshot = new AccountsSnapshotDto("host-1", 3, + [new("acc-1", "claude", "/home/u/.claude-work", "key-1", "work", "recording")], + [new("claude", "/home/u/.claude", "key-0")]); + conn.GetAccountsSnapshot = () => snapshot; + + var connect = conn.BuildDaemonConnect("mac", [], [], null); + var json = System.Text.Json.JsonSerializer.Serialize(connect, CapacitorJsonContext.Default.DaemonConnect); + + await Assert.That(connect.SupportsAccountLaunches).IsTrue(); + await Assert.That(connect.Accounts).IsSameReferenceAs(snapshot); + await Assert.That(json).Contains("\"supports_account_launches\":true"); + await Assert.That(json).Contains("\"accounts\":{\"host_id\":\"host-1\",\"revision\":3,"); + } + [Test] public async Task SendRawInputCommand_serializes_with_snake_case_names() { var json = System.Text.Json.JsonSerializer.Serialize( diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAgentRegisteredTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAgentRegisteredTests.cs index 61bfeb2983..e2f148e646 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAgentRegisteredTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAgentRegisteredTests.cs @@ -76,6 +76,21 @@ public async Task A_server_without_the_acked_method_gets_the_legacy_call_and_rea await Assert.That(conn.LegacyCalls.Single().RuntimeTransport).IsEqualTo("pty"); } + [Test] + public async Task The_registration_names_the_account_on_both_calls() { + var acked = new TestServerConnection(); + var legacy = new TestServerConnection { Acked = UnknownMethod }; + + await acked.AgentRegisteredAsync("agent-1", "prompt", "model", null, "/repo", accountDirectory: "/home/.claude-work", accountId: "work-id", accountLabel: "Work"); + await legacy.AgentRegisteredAsync("agent-1", "prompt", "model", null, "/repo", accountDirectory: "/home/.claude", accountLabel: ".claude"); + + var named = acked.AckedCalls.Single(); + await Assert.That((named.AccountDirectory, named.AccountId, named.AccountLabel)).IsEqualTo(((string?)"/home/.claude-work", (string?)"work-id", (string?)"Work")); + + var fallback = legacy.LegacyCalls.Single(); + await Assert.That((fallback.AccountDirectory, fallback.AccountId, fallback.AccountLabel)).IsEqualTo(((string?)"/home/.claude", (string?)null, (string?)".claude")); + } + /// A server that predates the field ignores it, so the same registration serves both methods. [Test] public async Task The_continued_agent_rides_on_the_acked_and_the_legacy_call() { diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SpyHostedAgentLauncher.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SpyHostedAgentLauncher.cs index d6ad155e4c..fb1f6c778e 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SpyHostedAgentLauncher.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/SpyHostedAgentLauncher.cs @@ -6,16 +6,25 @@ sealed class SpyHostedAgentLauncher(string vendor, string cliPath) : IHostedAgen public string Vendor { get; } = vendor; public string CliPath { get; } = cliPath; public bool SupportsUnattended { get; init; } = true; + public bool SupportsResume { get; init; } public int PrepareCalls { get; private set; } public int BuildArgsCalls { get; private set; } public int CleanupCalls { get; private set; } public Exception? PrepareThrow { get; init; } + public LauncherContext? LastContext { get; private set; } + + /// Runs inside , after the launch wrote its pre-start ledger entry and + /// before the PTY spawns. + public Action? OnPrepare { get; set; } + public bool IsAvailable() => true; public void Prepare(LauncherContext ctx) { PrepareCalls++; + LastContext = ctx; + OnPrepare?.Invoke(ctx); if (PrepareThrow is not null) throw PrepareThrow; } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/StubCatalogFactory.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/StubCatalogFactory.cs index 0353d03665..35485f1987 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/StubCatalogFactory.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/StubCatalogFactory.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Capacitor.Cli.Core; using Capacitor.Cli.Daemon.Services; @@ -12,6 +13,14 @@ internal sealed class StubCatalogFactory(string vendor, IReadOnlyList false; public int ProbeCalls { get; private set; } + /// Answers a probe of an account's directory; unset, it answers like the default probe. + public Func?>>? ProbeDirectory { get; init; } + + /// An account directory's catalog files; unset, they are the default ones. + public Func>? PathsFor { get; init; } + + public ConcurrentQueue ProbedDirectories { get; } = new(); + public IReadOnlyList CatalogFingerprintPaths { get; } = paths ?? []; public bool IsAvailable() => true; @@ -21,6 +30,16 @@ internal sealed class StubCatalogFactory(string vendor, IReadOnlyList?> ProbeModelsAsync(string? configDirectory, CancellationToken ct) { + if (configDirectory is null) return ProbeModelsAsync(ct); + + ProbedDirectories.Enqueue(configDirectory); + return ProbeDirectory?.Invoke(configDirectory) ?? Task.FromResult(models); + } + + public IReadOnlyList CatalogFingerprintPathsFor(string? configDirectory) => + configDirectory is not null && PathsFor is { } pathsFor ? pathsFor(configDirectory) : CatalogFingerprintPaths; + public Task StartAsync(RuntimeStartContext ctx, CancellationToken ct) => throw new NotSupportedException("a catalog stub launches nothing"); } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/TestAccountsIpc.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/TestAccountsIpc.cs new file mode 100644 index 0000000000..ce6bf71f66 --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/TestAccountsIpc.cs @@ -0,0 +1,12 @@ +using Capacitor.Cli.Core; +using Capacitor.Cli.Daemon.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +/// An accounts handler over a publisher with no store, for tests that start a control server. +static class TestAccountsIpc { + public static AccountsIpc Inert() => + new(new AccountsPublisher(new DaemonConfig(), new UserHome(typeof(TestAccountsIpc).Assembly.Location), TimeProvider.System, + NullLogger.Instance)); +} diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorCliWatcherTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorCliWatcherTests.cs index 6cfb620d75..a234060c50 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorCliWatcherTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorCliWatcherTests.cs @@ -1,5 +1,11 @@ +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; using Capacitor.Cli.Core.Setup; using Capacitor.Cli.Daemon.Services; +using Capacitor.Cli.Daemon.Tests.Unit.Pty; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; namespace Capacitor.Cli.Daemon.Tests.Unit.Services; @@ -273,4 +279,67 @@ public async Task WatchSet_includes_a_catalog_only_vendor_and_a_vendor_known_onl await Assert.That(set.Select(w => w.Vendor)).IsEquivalentTo(["claude", "pi", "silent"]); await Assert.That(set.Single(w => w.Vendor == "pi").CatalogPaths).IsEquivalentTo(["/a"]); } + + static VendorAccount AddCodexAccount(AccountStore store, string directory) { + var account = new VendorAccount(Guid.NewGuid().ToString("N"), HarnessId.Codex, + AccountDirectory.Normalize(directory), Path.GetFileName(directory), DateTimeOffset.UnixEpoch); + store.Mutate(r => (r with { Accounts = [.. r.Accounts, account] }, 0)); + return account; + } + + /// Driven through the real publisher and orchestrator: the announcement both re-probes, which puts + /// the new account in the map, and rebuilds the watch set from the fingerprint taken before that + /// probe, so a write to the account's auth file between the probe and the rebuilding tick still + /// asks for a refresh. + [Test] + public async Task Adding_an_account_probes_it_and_extends_the_watch_set() { + using var home = new TempHome(); + var store = new AccountStore(home.PathTo("accounts")); + AddCodexAccount(store, home.PathTo(".codex-work")); + var codex = new StubCatalogFactory("codex", [new("gpt-default", "Default")], paths: [home.PathTo(".codex", "auth.json")]) { + ProbeDirectory = dir => Task.FromResult?>([new(Path.GetFileName(dir), Path.GetFileName(dir))]), + PathsFor = dir => [Path.Combine(dir, "auth.json")], + }; + DaemonConfig? config = null; + await using var orch = AgentOrchestratorHarness.BuildOrchestrator( + new CaptureServerConnection(), new SpyPtyProcessFactory(), new Dictionary(), + extraRuntimeFactories: [codex], + configure: c => { + c.SupportedVendors = ["codex"]; + c.UnattendedVendors = []; + c.UnattendedVendorCapabilities = []; + config = c; + }); + using var publisher = new AccountsPublisher( + new DaemonConfig { Accounts = store }, home, new FakeTimeProvider(), NullLogger.Instance); + await publisher.StartAsync(CancellationToken.None); + orch.AccountsSnapshot = () => publisher.Current; + var factories = new Dictionary(StringComparer.Ordinal) { ["codex"] = codex }; + using var watcher = new VendorCliWatcher(config!, orch, factories, publisher, NullLogger.Instance, new FakeTimeProvider()); + watcher.StatBinary = _ => null; + await watcher.StartAsync(CancellationToken.None); + try { + var spare = AddCodexAccount(store, home.PathTo(".codex-spare")); + await publisher.RebuildAsync(CancellationToken.None); + await orch.CapabilityRefreshForTest.WaitAsync(TimeSpan.FromSeconds(10)); + + await Assert.That(codex.ProbedDirectories).Contains(spare.Directory); + await Assert.That(config!.VendorModelsByAccount![spare.Id].Single().Value).IsEqualTo(".codex-spare"); + + var refreshes = new List(); + watcher.Refresh = refreshes.Add; + home.CreateFile([".codex-spare", "auth.json"], "{}"); + watcher.Tick(); + + await Assert.That(watcher.Watched.SelectMany(w => w.CatalogPaths)).Contains(home.PathTo(".codex-spare", "auth.json")); + await Assert.That(refreshes.Count).IsEqualTo(1); + await Assert.That(refreshes[0]).Contains(spare.Id); + + watcher.Tick(); + await Assert.That(refreshes.Count).IsEqualTo(1); + } finally { + await watcher.StopAsync(CancellationToken.None); + await publisher.StopAsync(CancellationToken.None); + } + } } diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorModelCatalogsAccountTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorModelCatalogsAccountTests.cs new file mode 100644 index 0000000000..9875a90e3e --- /dev/null +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/Services/VendorModelCatalogsAccountTests.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.LocalIpc; +using Capacitor.Cli.Daemon.Services; +using Capacitor.Cli.Daemon.Tests.Unit.Pty; +using Microsoft.Extensions.Logging.Abstractions; +using TUnit.Assertions.Enums; + +namespace Capacitor.Cli.Daemon.Tests.Unit.Services; + +/// +/// The Codex catalog per account: the machine's default stays in VendorModels, each registered +/// Codex account's catalog is probed under its own directory into VendorModelsByAccount, and the +/// map follows the accounts snapshot. +/// +public class VendorModelCatalogsAccountTests { + [TempDir] public required TempDir Tmp { get; init; } + + static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + static readonly VendorModelOption Default = new("gpt-default", "Default"); + static readonly VendorModelOption Old = new("gpt-old", "Old"); + + static Task?> ModelNamedAfter(string directory) => + Task.FromResult?>([new(Path.GetFileName(directory), Path.GetFileName(directory))]); + + static Dictionary Map(params (string Id, VendorModelOption[] Models)[] entries) => + entries.ToDictionary(e => e.Id, e => e.Models, StringComparer.Ordinal); + + AccountsSnapshotDto Snapshot(params (string Id, string Directory)[] codexAccounts) { + var claude = Tmp.PathTo(".claude-work"); + var codexDefault = Tmp.PathTo(".codex"); + return new("host-1", 1, + [ + .. codexAccounts.Select(a => new AccountViewDto(a.Id, "codex", a.Directory, DirectoryKey.Of(a.Directory), a.Id, "recording")), + new AccountViewDto("claude-work", "claude", claude, DirectoryKey.Of(claude), "work", "recording"), + ], + [new DefaultDirectoryDto("codex", codexDefault, DirectoryKey.Of(codexDefault))]); + } + + static (AgentOrchestrator Orchestrator, CaptureServerConnection Server, DaemonConfig Config) Build( + StubCatalogFactory codex, Dictionary? byAccount) { + var server = new CaptureServerConnection(); + DaemonConfig? captured = null; + var orch = AgentOrchestratorHarness.BuildOrchestrator( + server, new SpyPtyProcessFactory(), new Dictionary(), + extraRuntimeFactories: [codex], + configure: config => { + config.UnattendedVendors = []; + config.UnattendedVendorCapabilities = []; + config.SupportedVendors = [codex.Vendor]; + config.VendorModels = new(StringComparer.Ordinal) { ["codex"] = [Default] }; + config.VendorModelsByAccount = byAccount; + captured = config; + }); + return (orch, server, captured!); + } + + /// The unregistered default has no registry id, so its catalog is the machine's; a Claude account + /// is never probed, since its catalog does not depend on the account. + [Test] + public async Task Default_catalog_stays_in_vendor_models_and_accounts_land_in_the_map() { + var work = Tmp.PathTo(".codex-work"); + var spare = Tmp.PathTo(".codex-spare"); + var codex = new StubCatalogFactory("codex", [Default]) { ProbeDirectory = ModelNamedAfter }; + var config = new DaemonConfig { SupportedVendors = ["codex"] }; + + await DaemonRunner.ProbeVendorCatalogsAsync( + config, [codex], Snapshot(("acct-work", work), ("acct-spare", spare)), CancellationToken.None); + + await Assert.That(config.VendorModels!.Keys).IsEquivalentTo(["codex"]); + await Assert.That(config.VendorModels["codex"]).IsEquivalentTo([Default], CollectionOrdering.Matching); + await Assert.That(config.VendorModelsByAccount!.Keys).IsEquivalentTo(["acct-work", "acct-spare"]); + await Assert.That(config.VendorModelsByAccount["acct-work"].Single().Value).IsEqualTo(".codex-work"); + await Assert.That(config.VendorModelsByAccount["acct-spare"].Single().Value).IsEqualTo(".codex-spare"); + await Assert.That(codex.ProbeCalls).IsEqualTo(1); + await Assert.That(codex.ProbedDirectories).IsEquivalentTo([work, spare]); + } + + /// The work account's probe is still running when the account is removed; the answer that arrives + /// afterwards names an account the snapshot no longer has, so neither it nor the old entry survives. + [Test] + public async Task Removing_an_account_drops_its_catalog_and_a_late_probe_result_is_discarded() { + var work = Tmp.PathTo(".codex-work"); + var spare = Tmp.PathTo(".codex-spare"); + var workStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var workAnswer = new TaskCompletionSource?>(TaskCreationOptions.RunContinuationsAsynchronously); + var codex = new StubCatalogFactory("codex", [Default]) { + ProbeDirectory = directory => { + if (directory != work) return ModelNamedAfter(directory); + + workStarted.TrySetResult(); + return workAnswer.Task; + }, + }; + var (orch, server, config) = Build(codex, Map(("acct-work", [Old]), ("acct-spare", [Old]))); + await using var _ = orch; + var snapshot = Snapshot(("acct-work", work), ("acct-spare", spare)); + orch.AccountsSnapshot = () => snapshot; + + orch.RefreshAdvertisedCapabilities("accounts changed"); + await workStarted.Task.WaitAsync(Timeout); + snapshot = Snapshot(("acct-spare", spare)); + workAnswer.SetResult([new("gpt-late", "Late")]); + await orch.CapabilityRefreshForTest.WaitAsync(Timeout); + + await Assert.That(config.VendorModelsByAccount!.Keys).IsEquivalentTo(["acct-spare"]); + await Assert.That(config.VendorModelsByAccount["acct-spare"].Single().Value).IsEqualTo(".codex-spare"); + await Assert.That(server.RegisterDaemonCalls).IsEqualTo(1); + } + + /// The machine catalog reprobes equal, so only the account map differs; the change still reaches + /// the local status stream and the server, and both carry the new map. + [Test] + public async Task Map_only_change_republishes_locally_and_to_the_server() { + var work = Tmp.PathTo(".codex-work"); + var codex = new StubCatalogFactory("codex", [Default]) { ProbeDirectory = ModelNamedAfter }; + var (orch, server, config) = Build(codex, Map(("acct-work", [Old]))); + await using var _ = orch; + orch.AccountsSnapshot = () => Snapshot(("acct-work", work)); + var vendorModels = config.VendorModels; + var v0 = orch.StatusNotifierForTest.Version; + + orch.RefreshAdvertisedCapabilities("test"); + await orch.CapabilityRefreshForTest.WaitAsync(Timeout); + + await Assert.That(VendorModelCatalogs.Equal(config.VendorModels, vendorModels)).IsTrue(); + await Assert.That(config.VendorModelsByAccount!["acct-work"].Single().Value).IsEqualTo(".codex-work"); + await Assert.That(orch.StatusNotifierForTest.Version).IsGreaterThan(v0); + await Assert.That(server.RegisterDaemonCalls).IsEqualTo(1); + + await using var connection = new ServerConnection(config, new FixedCredentialSource(), NullLoggerFactory.Instance, + NullLogger.Instance, TimeProvider.System); + var connect = connection.BuildDaemonConnect("mac", [], [], null); + await Assert.That(ReferenceEquals(connect.VendorModelsByAccount, config.VendorModelsByAccount)).IsTrue(); + + var status = JsonSerializer.Deserialize( + new DaemonStatusIpc(config, orch, server, orch.StatusNotifierForTest, TimeProvider.System).Snapshot(), + StatusIpcJsonContext.Default.DaemonStatusDto)!; + await Assert.That(status.Daemon.VendorModelsByAccount!["acct-work"].Single().Value).IsEqualTo(".codex-work"); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/AccountsCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/AccountsCommandTests.cs index 5f793c833d..68d7c20c5e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/AccountsCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/AccountsCommandTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Capacitor.Cli.Commands; using Capacitor.Cli.Core; using Capacitor.Cli.Core.Accounts; @@ -182,6 +183,186 @@ public async Task Rewire_reinstalls_into_every_account() { await Assert.That(ClaudePluginInstaller.IsPluginEnabled(Path.Combine(dir, "settings.json"))).IsTrue(); } + async Task Quietly(params string[] args) { + using var capture = ConsoleOutput.StartFullCapture(); + await Sut().HandleAsync(args); + } + + static AccountActionResultDto SingleResult(string stdout) { + var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (lines.Length != 1) throw new InvalidOperationException($"Expected one stdout line, got {lines.Length}: {stdout}"); + return JsonSerializer.Deserialize(lines[0], AccountActionResultJsonContext.Default.AccountActionResultDto)!; + } + + [Test] + public async Task Rewire_that_changes_nothing_still_increments_the_revision() { + var dir = ClaudeDir(); + var settings = Path.Combine(dir, "settings.json"); + await Quietly("accounts", "add", "claude", dir); + var revision = Store.Load().Revision; + var before = File.ReadAllText(settings); + using var capture = ConsoleOutput.StartCapture(); + + var exit = await Sut().HandleAsync(["accounts", "rewire", dir]); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That(File.ReadAllText(settings)).IsEqualTo(before); + await Assert.That(Store.Load().Revision).IsEqualTo(revision + 1); + } + + [Test] + public async Task Rewire_of_an_account_removed_meanwhile_reports_the_conflict_not_success() { + var dir = ClaudeDir(); + await Quietly("accounts", "add", "claude", dir); + var sut = Sut(); + sut.BetweenWiringAndStamp = () => Store.Mutate(r => (r with { Accounts = [] }, 0)); + using var capture = ConsoleOutput.StartFullCapture(); + + var exit = await sut.HandleAsync(["accounts", "rewire", dir, "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + await Assert.That(exit).IsEqualTo(1); + await Assert.That(result.Succeeded).IsFalse(); + await Assert.That(result.Error).Contains("removed while it was being wired"); + await Assert.That(capture.GetCapturedError()).Contains("removed while it was being wired"); + await Assert.That(Store.Load().Accounts).IsEmpty(); + } + + [Test] + public async Task Add_whose_wiring_fails_registers_and_reports_failure_with_the_committed_revision() { + var dir = ClaudeDir(); + File.WriteAllText(Path.Combine(dir, "settings.json"), "not json"); + using var capture = ConsoleOutput.StartFullCapture(); + + var exit = await Sut().HandleAsync(["accounts", "add", "claude", dir, "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + var registry = Store.Load(); + var account = registry.Accounts.Single(); + await Assert.That(exit).IsEqualTo(1); + await Assert.That(result.Succeeded).IsFalse(); + await Assert.That(result.AccountId).IsEqualTo(account.Id); + await Assert.That(result.Revision).IsEqualTo(registry.Revision); + await Assert.That(result.Steps.Single(s => !s.Succeeded).Name).IsEqualTo("plugin"); + await Assert.That(result.Error).Contains("plugin"); + await Assert.That(account.WiringFailure).Contains("plugin"); + await Assert.That(account.WiredAt).IsNull(); + await Assert.That(capture.GetCapturedError()).Contains("Run `kcap accounts rewire`"); + } + + [Test] + public async Task Json_flag_prints_one_result_line_with_steps_and_revision() { + var dir = ClaudeDir(); + using var capture = ConsoleOutput.StartFullCapture(); + + var exit = await Sut().HandleAsync(["accounts", "add", "claude", dir, "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + var registry = Store.Load(); + var account = registry.Accounts.Single(); + await Assert.That(exit).IsEqualTo(0).Because(capture.GetCapturedError()); + await Assert.That(result.Action).IsEqualTo("add"); + await Assert.That(result.AccountId).IsEqualTo(account.Id); + await Assert.That(result.Succeeded).IsTrue(); + await Assert.That(result.Error).IsNull(); + await Assert.That(result.Revision).IsEqualTo(registry.Revision); + await Assert.That(result.Steps.Select(s => (s.Name, s.Succeeded))).IsEquivalentTo([("plugin", true)]); + await Assert.That(account.WiredAt).IsNotNull(); + await Assert.That(account.WiringFailure).IsNull(); + await Assert.That(capture.GetCapturedOutput()).Contains("\"account_id\":").And.Contains("\"revision\":"); + } + + [Test] + public async Task Remove_reports_the_revision_of_the_removal() { + var dir = ClaudeDir(); + await Quietly("accounts", "add", "claude", dir); + var revision = Store.Load().Revision; + var id = Store.Load().Accounts.Single().Id; + using var capture = ConsoleOutput.StartCapture(); + + var exit = await Sut().HandleAsync(["accounts", "remove", dir, "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + await Assert.That(exit).IsEqualTo(0); + await Assert.That(result.Action).IsEqualTo("remove"); + await Assert.That(result.AccountId).IsEqualTo(id); + await Assert.That(result.Succeeded).IsTrue(); + await Assert.That(result.Revision).IsEqualTo(revision + 1); + await Assert.That(Store.Load().Revision).IsEqualTo(result.Revision); + } + + [Test] + public async Task Remove_whose_unwiring_fails_keeps_the_entry_and_commits_the_failure() { + var dir = ClaudeDir(); + await Quietly("accounts", "add", "claude", dir); + var before = Store.Load(); + File.WriteAllText(Path.Combine(dir, "settings.json"), "not json"); + using var capture = ConsoleOutput.StartFullCapture(); + + var exit = await Sut().HandleAsync(["accounts", "remove", dir, "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + var after = Store.Load(); + var account = after.Accounts.Single(); + await Assert.That(exit).IsEqualTo(1); + await Assert.That(result.Action).IsEqualTo("remove"); + await Assert.That(result.AccountId).IsEqualTo(account.Id); + await Assert.That(result.Succeeded).IsFalse(); + await Assert.That(result.Steps.Single(s => !s.Succeeded).Name).IsEqualTo("plugin"); + await Assert.That(result.Error).Contains("It stays registered."); + await Assert.That(result.Revision).IsEqualTo(before.Revision + 1); + await Assert.That(after.Revision).IsEqualTo(result.Revision); + await Assert.That(account.WiringFailure).Contains("plugin"); + await Assert.That(account.WiredAt).IsEqualTo(before.Accounts.Single().WiredAt); + await Assert.That(capture.GetCapturedError()).Contains("It stays registered."); + } + + [Test] + public async Task Json_flag_reports_a_refused_action_on_stdout_and_stderr() { + using var capture = ConsoleOutput.StartFullCapture(); + + var exit = await Sut().HandleAsync(["accounts", "rename", "zzzzzzzz", "Work", "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + await Assert.That(exit).IsEqualTo(1); + await Assert.That(result.Action).IsEqualTo("rename"); + await Assert.That(result.Succeeded).IsFalse(); + await Assert.That(result.Revision).IsNull(); + await Assert.That(result.Error).Contains("No account matches zzzzzzzz"); + await Assert.That(capture.GetCapturedError()).Contains("No account matches zzzzzzzz"); + } + + [Test] + public async Task Rename_reports_the_revision_it_committed() { + var dir = ClaudeDir(); + await Quietly("accounts", "add", "claude", dir); + using var capture = ConsoleOutput.StartCapture(); + + var exit = await Sut().HandleAsync(["accounts", "rename", dir, "Work", "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + await Assert.That(exit).IsEqualTo(0); + await Assert.That(result.Steps.Length).IsEqualTo(0); + await Assert.That(result.Revision).IsEqualTo(Store.Load().Revision); + } + + [Test] + public async Task Rewire_of_every_account_reports_one_result_covering_all_of_them() { + var first = ClaudeDir(); + var second = ClaudeDir(".claude-other"); + await Quietly("accounts", "add", "claude", first); + await Quietly("accounts", "add", "claude", second); + using var capture = ConsoleOutput.StartCapture(); + + var exit = await Sut().HandleAsync(["accounts", "rewire", "--json"]); + + var result = SingleResult(capture.GetCapturedOutput()); + await Assert.That(exit).IsEqualTo(0); + await Assert.That(result.AccountId).IsNull(); + await Assert.That(result.Steps.Length).IsEqualTo(2); + await Assert.That(result.Revision).IsEqualTo(Store.Load().Revision); + } + [Test] public async Task Unknown_subcommand_prints_usage_and_exits_1() { using var capture = ConsoleOutput.StartErrorCapture(); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandAccountTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandAccountTests.cs new file mode 100644 index 0000000000..fc8accb150 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandAccountTests.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; +using Capacitor.Cli.Core.Harness; +using Capacitor.Cli.Core.LocalIpc; + +namespace Capacitor.Cli.Tests.Unit.Commands; + +[NotInParallel] +public class AgentCommandAccountTests { + const string Server = "https://kcap.example"; + const string DaemonName = "acct"; + const string WorkId = "c1a0de01c1a0de01c1a0de01c1a0de01"; + const string PlayId = "c1a0de02c1a0de02c1a0de02c1a0de02"; + const string AgentId = "0123456789abcdef0123456789abcdef"; + + [TempDaemonPaths] public required TempDaemonStore Daemons { get; init; } + [TempConfigRoot] public required TempConfigRoot Config { get; init; } + [TempHome] public required TempHome Home { get; init; } + + AccountStore Accounts => AccountStore.Beside(Daemons.Store); + + AgentCommand Command() => + new(Daemons.Store, Accounts, Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.All(), + TestBinaries.None, new WorkingDirectory(Daemons.Root), TimeProvider.System, new StandardStreams(TextWriter.Null, TextWriter.Null, TextReader.Null)); + + Task Start(params string[] args) => + Command().HandleAsync(["agent", "start", .. args, "--daemon", DaemonName], Server); + + string Register(string id, HarnessId vendor, string name) { + var directory = AccountDirectory.Normalize(Home.CreateDir(name)); + Accounts.Mutate(r => (r with { Accounts = [.. r.Accounts, new VendorAccount(id, vendor, directory, name, DateTimeOffset.UnixEpoch)] }, 0)); + + return directory; + } + + static LocalFrame HelloReply(params string[] capabilities) => + LocalFrame.HelloJson(FrameType.HelloReply, + JsonSerializer.Serialize(new HelloReplyDto(1, "9.9.9", DaemonName, [.. capabilities]), HelloIpcJsonContext.Default.HelloReplyDto)); + + // The first connection is the reachability check, which dials and hangs up without a frame. + Task> Daemon(params LocalFrame[] replies) => + FakeControlSocket.AnswerInOrder(Daemons.Store, DaemonName, [null, .. replies]); + + [Test] + public async Task Account_prefix_resolves_and_the_spawn_frame_carries_the_id() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + Register(WorkId, HarnessId.Claude, ".claude-work"); + Register(PlayId, HarnessId.Claude, ".claude-play"); + Register("c0de0000c0de0000c0de0000c0de0000", HarnessId.Codex, ".codex-work"); + var daemon = Daemon(HelloReply("accounts/1"), FrameCodec.Attached(AgentId, [])); + using var console = ConsoleOutput.StartFullCapture("\n"); + + var exit = await Start("claude", "--account", "c1a0de02", "-d"); + + var frames = await daemon.WaitAsync(TimeSpan.FromSeconds(30)); + await Assert.That(exit).IsEqualTo(0).Because(console.GetCapturedError()); + await Assert.That(frames.Select(f => f?.Type)).IsEquivalentTo(new FrameType?[] { null, FrameType.Hello, FrameType.Spawn }); + await Assert.That(FrameCodec.Spawn(frames[2]!).accountId).IsEqualTo(PlayId); + } + + [Test] + public async Task Tilde_directory_resolves_to_the_registered_account() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + Register(WorkId, HarnessId.Claude, ".claude-work"); + var daemon = Daemon(HelloReply("accounts/1"), FrameCodec.Attached(AgentId, [])); + using var console = ConsoleOutput.StartFullCapture("\n"); + + var exit = await Start("claude", "--account", "~/.claude-work", "-d"); + + var frames = await daemon.WaitAsync(TimeSpan.FromSeconds(30)); + await Assert.That(exit).IsEqualTo(0).Because(console.GetCapturedError()); + await Assert.That(FrameCodec.Spawn(frames[2]!).accountId).IsEqualTo(WorkId); + } + + [Test] + public async Task Ambiguous_prefix_is_refused_listing_both_candidates_and_nothing_is_sent() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + Register(WorkId, HarnessId.Claude, ".claude-work"); + Register(PlayId, HarnessId.Claude, ".claude-play"); + using var daemon = new ControlSocketTripwire(Daemons.Store, DaemonName); + using var console = ConsoleOutput.StartErrorCapture("\n"); + + var exit = await Start("claude", "--account", "c1a0", "-d"); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(console.GetCapturedError()).Contains(WorkId).And.Contains(PlayId); + await Assert.That(daemon.WasDialed).IsFalse(); + } + + [Test] + public async Task Unregistered_directory_is_refused_with_the_add_remedy() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + Register(WorkId, HarnessId.Claude, ".claude-work"); + var stranger = Home.CreateDir(".claude-stranger").Path; + using var daemon = new ControlSocketTripwire(Daemons.Store, DaemonName); + using var console = ConsoleOutput.StartErrorCapture("\n"); + + var exit = await Start("claude", "--account", stranger, "-d"); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(console.GetCapturedError()).Contains(stranger).And.Contains("kcap accounts add"); + await Assert.That(daemon.WasDialed).IsFalse(); + } + + [Test] + public async Task Daemon_without_accounts_capability_is_refused_before_spawn() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + Register(WorkId, HarnessId.Claude, ".claude-work"); + var daemon = Daemon(HelloReply("status/1")); + using var console = ConsoleOutput.StartErrorCapture("\n"); + + var exit = await Start("claude", "--account", "c1a0de01", "-d"); + + var frames = await daemon.WaitAsync(TimeSpan.FromSeconds(30)); + await Assert.That(exit).IsEqualTo(1); + await Assert.That(console.GetCapturedError()).Contains("this daemon does not support choosing an account; update kcap on this machine"); + await Assert.That(frames.Select(f => f?.Type)).IsEquivalentTo(new FrameType?[] { null, FrameType.Hello }); + } + + [Test] + public async Task A_vendor_without_accounts_is_refused_and_nothing_is_sent() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + using var daemon = new ControlSocketTripwire(Daemons.Store, DaemonName); + using var console = ConsoleOutput.StartErrorCapture("\n"); + + var exit = await Start("cursor", "--account", "c1a0de01", "-d"); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(console.GetCapturedError()).Contains("accounts apply to claude and codex only"); + await Assert.That(daemon.WasDialed).IsFalse(); + } + + [Test] + public async Task An_unreadable_registry_is_refused_naming_its_directory() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + Directory.CreateDirectory(Accounts.Directory); + File.WriteAllText(Accounts.RegistryPath, "{ not json"); + using var daemon = new ControlSocketTripwire(Daemons.Store, DaemonName); + using var console = ConsoleOutput.StartErrorCapture("\n"); + + var exit = await Start("claude", "--account", "c1a0de01", "-d"); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(console.GetCapturedError()).Contains(Accounts.Directory); + await Assert.That(daemon.WasDialed).IsFalse(); + } + + [Test] + public async Task Start_without_account_sends_no_hello_and_no_account() { + Skip.When(OperatingSystem.IsWindows(), "the control socket is a unix socket"); + var daemon = Daemon(FrameCodec.Attached(AgentId, [])); + using var console = ConsoleOutput.StartFullCapture("\n"); + + var exit = await Start("claude", "-d"); + + var frames = await daemon.WaitAsync(TimeSpan.FromSeconds(30)); + await Assert.That(exit).IsEqualTo(0).Because(console.GetCapturedError()); + await Assert.That(frames.Select(f => f?.Type)).IsEquivalentTo(new FrameType?[] { null, FrameType.Spawn }); + await Assert.That(FrameCodec.Spawn(frames[1]!).accountId).IsNull(); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandRoutingTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandRoutingTests.cs index 8c5a34e55e..95281b0666 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandRoutingTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/AgentCommandRoutingTests.cs @@ -1,5 +1,6 @@ using Capacitor.Cli.Commands; using Capacitor.Cli.Core; +using Capacitor.Cli.Core.Accounts; using Capacitor.Cli.Core.LocalIpc; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -13,7 +14,7 @@ public class AgentCommandRoutingTests { StringWriter Err { get; } = new() { NewLine = "\n" }; AgentCommand Command() => - new(Daemons.Store, Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.All(), + new(Daemons.Store, AccountStore.Beside(Daemons.Store), Config.Root, Resolutions.None(Config.Root), Home, TestHarnesses.All(), TestBinaries.None, new WorkingDirectory(Daemons.Root), TimeProvider.System, new StandardStreams(Out, Err, TextReader.Null)); [Test] diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ControlSocketTripwire.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ControlSocketTripwire.cs new file mode 100644 index 0000000000..eaa5090d6e --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ControlSocketTripwire.cs @@ -0,0 +1,35 @@ +using System.Net.Sockets; +using Capacitor.Cli.Core; + +namespace Capacitor.Cli.Tests.Unit.Commands; + +/// A daemon control socket that hangs up on every client and remembers that one came, so a +/// test can prove the command never reached for the daemon. Hanging up, not ignoring, keeps a command +/// that does dial from blocking the suite on a reply that never arrives. +sealed class ControlSocketTripwire : IDisposable { + readonly Socket _listener = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + int _dials; + + public ControlSocketTripwire(DaemonStore store, string name) { + store.EnsureDirectory(); + _listener.Bind(new UnixDomainSocketEndPoint(store.SocketPath(name))); + _listener.Listen(8); + _ = Task.Run(HangUpOnEveryDial); + } + + // A connection still queued behind the accept loop counts too. + public bool WasDialed => Volatile.Read(ref _dials) > 0 || _listener.Poll(0, SelectMode.SelectRead); + + async Task HangUpOnEveryDial() { + try { + while (true) { + using var connection = await _listener.AcceptAsync(); + Interlocked.Increment(ref _dials); + } + } catch (Exception ex) when (ex is ObjectDisposedException or SocketException) { + // The test is over. + } + } + + public void Dispose() => _listener.Dispose(); +} diff --git a/test/Capacitor.Cli.Tests.Unit/Services/HelloProbeTests.cs b/test/Capacitor.Cli.Tests.Unit/Services/HelloProbeTests.cs index 31ba804288..0e7f77fd7b 100644 --- a/test/Capacitor.Cli.Tests.Unit/Services/HelloProbeTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Services/HelloProbeTests.cs @@ -59,7 +59,7 @@ public async Task Valid_hello_reply_is_well_formed_with_fields_populated() { Skip.When(OperatingSystem.IsWindows(), "Unix-domain socket path"); var replyJson = JsonSerializer.Serialize( - new HelloReplyDto(1, "9.9.9", "probed-daemon", []), HelloIpcJsonContext.Default.HelloReplyDto); + new HelloReplyDto(1, "9.9.9", "probed-daemon", ["accounts/1"]), HelloIpcJsonContext.Default.HelloReplyDto); await WithServerAsync(ReplyWith(LocalFrame.HelloJson(FrameType.HelloReply, replyJson)), async name => { var result = await HelloProbe.RunAsync(Daemons.Store, name, TimeProvider.System, TimeSpan.FromSeconds(5)); @@ -69,6 +69,7 @@ await WithServerAsync(ReplyWith(LocalFrame.HelloJson(FrameType.HelloReply, reply await Assert.That(result.ProtocolVersion).IsEqualTo(1); await Assert.That(result.DaemonVersion).IsEqualTo("9.9.9"); await Assert.That(result.DaemonName).IsEqualTo("probed-daemon"); + await Assert.That(result.Capabilities).IsEquivalentTo(new[] { "accounts/1" }); }); } diff --git a/test/fixtures/daemon-connect.v2.json b/test/fixtures/daemon-connect.v2.json index a40e253d98..72be20d3a7 100644 --- a/test/fixtures/daemon-connect.v2.json +++ b/test/fixtures/daemon-connect.v2.json @@ -1 +1 @@ -{"name":"tony-daemon","platform":"macOS 15.0 Arm64","repo_paths":["/Users/tony/dev/kcap-server"],"max_agents":5,"live_agent_ids":[],"instance_id":"11111111-1111-1111-1111-111111111111","version":"1.2.3-sha-abc1234","supported_vendors":["claude","codex"],"machine_id":"machine-1","live_agents":null,"unattended_vendors":null,"quarantined":null,"epoch":null,"highest_accepted_seq":null,"last_processed_seq":null,"startup_reap_complete":null,"resolved_startup_candidates":null,"unresolved_startup_candidates":null,"startup_discovery":null,"recordless_survivors_impossible":null,"supports_sequenced_commands":false,"highest_resolution_generation":null,"unattended_vendor_capabilities":null,"acp_preset_vendors":null,"supports_correlated_status_reports":false,"permission_mode_vendors":null,"eval_protocol_version":2,"pr_review_vendors":null,"vendor_models":{"pi":[{"value":"anthropic/claude-opus-5","label":"Claude Opus 5 · anthropic"}]},"supports_raw_input":false,"supports_branch_base_ref":false,"resuming_agents":null,"active_count":null,"active_count_version":null,"acp_presets":null} \ No newline at end of file +{"name":"tony-daemon","platform":"macOS 15.0 Arm64","repo_paths":["/Users/tony/dev/kcap-server"],"max_agents":5,"live_agent_ids":[],"instance_id":"11111111-1111-1111-1111-111111111111","version":"1.2.3-sha-abc1234","supported_vendors":["claude","codex"],"machine_id":"machine-1","live_agents":null,"unattended_vendors":null,"quarantined":null,"epoch":null,"highest_accepted_seq":null,"last_processed_seq":null,"startup_reap_complete":null,"resolved_startup_candidates":null,"unresolved_startup_candidates":null,"startup_discovery":null,"recordless_survivors_impossible":null,"supports_sequenced_commands":false,"highest_resolution_generation":null,"unattended_vendor_capabilities":null,"acp_preset_vendors":null,"supports_correlated_status_reports":false,"permission_mode_vendors":null,"eval_protocol_version":2,"pr_review_vendors":null,"vendor_models":{"pi":[{"value":"anthropic/claude-opus-5","label":"Claude Opus 5 · anthropic"}]},"supports_raw_input":false,"supports_branch_base_ref":false,"resuming_agents":null,"active_count":null,"active_count_version":null,"acp_presets":null,"supports_account_launches":false,"accounts":null,"vendor_models_by_account":null} \ No newline at end of file