From 852aa96c89464f817d47c660a43c21ed3b1dfbfe Mon Sep 17 00:00:00 2001 From: Lem0nTree Date: Thu, 10 Sep 2026 02:18:29 +0200 Subject: [PATCH] refactor: remove legacy Go companion --- .github/workflows/ci.yml | 81 +- AGENT_TASKS.md | 128 +- PLAN.md | 219 +-- README.md | 7 - controller/app/app.go | 1174 ---------------- controller/app/auth_service.go | 68 - controller/app/automation.go | 105 -- controller/app/companion.go | 102 -- controller/app/control.go | 597 --------- controller/app/control_test.go | 158 --- controller/app/control_unix.go | 78 -- controller/app/control_windows.go | 18 - controller/app/installed_runtime.go | 449 ------- controller/app/ipc_unix.go | 29 - controller/app/ipc_windows.go | 47 - controller/app/lifecycle.go | 941 ------------- controller/app/telemetry_wire.go | 58 - .../cmd/codexmarathon/accounts_commands.go | 352 ----- .../codexmarathon/accounts_commands_test.go | 41 - controller/cmd/codexmarathon/main.go | 486 ------- controller/cmd/codexmarathon/run_command.go | 629 --------- .../cmd/codexmarathon/run_command_test.go | 114 -- controller/go.mod | 3 - controller/internal/accounts/accounts.go | 670 ---------- controller/internal/accounts/accounts_test.go | 140 -- .../internal/accounts/atomic_registry.go | 43 - .../internal/accounts/atomic_registry_unix.go | 18 - .../accounts/atomic_registry_windows.go | 46 - controller/internal/accounts/manager.go | 816 ------------ controller/internal/accounts/manager_test.go | 364 ----- controller/internal/automation/policy_loop.go | 304 ----- .../internal/automation/policy_loop_test.go | 110 -- controller/internal/companion/process.go | 606 --------- controller/internal/companion/process_test.go | 298 ----- controller/internal/companion/process_unix.go | 38 - .../internal/companion/process_windows.go | 36 - controller/internal/credentials/atomic.go | 153 --- .../internal/credentials/atomic_unix.go | 22 - .../internal/credentials/atomic_windows.go | 52 - .../internal/credentials/credentials.go | 570 -------- .../internal/credentials/credentials_test.go | 185 --- controller/internal/journal/journal.go | 380 ------ controller/internal/journal/journal_test.go | 162 --- controller/internal/policy/policy.go | 802 ----------- controller/internal/policy/policy_test.go | 158 --- controller/internal/quota/PROVENANCE.md | 24 - .../internal/quota/credential_source.go | 69 - controller/internal/quota/quota.go | 442 ------ controller/internal/quota/quota_test.go | 129 -- controller/internal/recovery/manager.go | 1185 ----------------- controller/internal/recovery/manager_test.go | 316 ----- controller/internal/reset/scheduler.go | 473 ------- controller/internal/reset/scheduler_test.go | 153 --- controller/internal/reset/state.go | 106 -- controller/internal/runtime/client.go | 648 --------- controller/internal/runtime/client_test.go | 305 ----- controller/internal/runtime/installed.go | 1138 ---------------- controller/internal/runtime/installed_test.go | 394 ------ controller/internal/runtime/wire.go | 594 --------- controller/internal/telemetry/cache.go | 139 -- controller/internal/telemetry/domain.go | 409 ------ controller/internal/telemetry/normalize.go | 114 -- controller/internal/telemetry/provider.go | 352 ----- .../internal/telemetry/provider_test.go | 86 -- .../internal/telemetry/telemetry_test.go | 185 --- controller/internal/telemetry/updates.go | 414 ------ controller/internal/telemetry/wire.go | 123 -- .../internal/transitions/coordinator.go | 704 ---------- controller/internal/transitions/reconcile.go | 358 ----- controller/internal/transitions/replay.go | 238 ---- .../internal/transitions/replay_test.go | 72 - .../internal/transitions/snapshot_test.go | 133 -- controller/internal/transitions/state.go | 505 ------- docs/account-manager.md | 107 +- docs/architecture.md | 99 +- docs/build-test.md | 84 +- docs/companion-cli.md | 36 - docs/features-and-usage.md | 401 +----- docs/recovery.md | 70 +- docs/release.md | 97 +- docs/runtime-adapter.md | 141 +- docs/telemetry.md | 87 +- docs/transitions.md | 58 +- docs/upstream-sync.md | 52 +- integration/fake-runtime/runtime.go | 770 ----------- integration/go.mod | 7 - integration/tests/transitions_test.go | 196 --- machine-setting.md | 133 +- packaging/README.md | 61 - packaging/release-manifest.json | 65 - protocol/README.md | 61 - protocol/VERSION | 1 - protocol/commands.json | 411 ------ protocol/events.json | 310 ----- protocol/protocol.json | 110 -- runtime/README.md | 47 +- .../codex-rs/codexmarathon-runtime/README.md | 52 +- scripts/build-release.ps1 | 49 +- scripts/build-release.sh | 50 +- scripts/clean_machine_check.py | 184 --- scripts/live_smoke.py | 144 -- scripts/package_release.py | 232 ---- scripts/test_package_release.py | 102 -- scripts/verify_package.py | 255 ---- scripts/verify_protocol.py | 145 -- scripts/verify_provenance.py | 136 +- verify.ps1 | 125 +- 107 files changed, 528 insertions(+), 26015 deletions(-) delete mode 100644 controller/app/app.go delete mode 100644 controller/app/auth_service.go delete mode 100644 controller/app/automation.go delete mode 100644 controller/app/companion.go delete mode 100644 controller/app/control.go delete mode 100644 controller/app/control_test.go delete mode 100644 controller/app/control_unix.go delete mode 100644 controller/app/control_windows.go delete mode 100644 controller/app/installed_runtime.go delete mode 100644 controller/app/ipc_unix.go delete mode 100644 controller/app/ipc_windows.go delete mode 100644 controller/app/lifecycle.go delete mode 100644 controller/app/telemetry_wire.go delete mode 100644 controller/cmd/codexmarathon/accounts_commands.go delete mode 100644 controller/cmd/codexmarathon/accounts_commands_test.go delete mode 100644 controller/cmd/codexmarathon/main.go delete mode 100644 controller/cmd/codexmarathon/run_command.go delete mode 100644 controller/cmd/codexmarathon/run_command_test.go delete mode 100644 controller/go.mod delete mode 100644 controller/internal/accounts/accounts.go delete mode 100644 controller/internal/accounts/accounts_test.go delete mode 100644 controller/internal/accounts/atomic_registry.go delete mode 100644 controller/internal/accounts/atomic_registry_unix.go delete mode 100644 controller/internal/accounts/atomic_registry_windows.go delete mode 100644 controller/internal/accounts/manager.go delete mode 100644 controller/internal/accounts/manager_test.go delete mode 100644 controller/internal/automation/policy_loop.go delete mode 100644 controller/internal/automation/policy_loop_test.go delete mode 100644 controller/internal/companion/process.go delete mode 100644 controller/internal/companion/process_test.go delete mode 100644 controller/internal/companion/process_unix.go delete mode 100644 controller/internal/companion/process_windows.go delete mode 100644 controller/internal/credentials/atomic.go delete mode 100644 controller/internal/credentials/atomic_unix.go delete mode 100644 controller/internal/credentials/atomic_windows.go delete mode 100644 controller/internal/credentials/credentials.go delete mode 100644 controller/internal/credentials/credentials_test.go delete mode 100644 controller/internal/journal/journal.go delete mode 100644 controller/internal/journal/journal_test.go delete mode 100644 controller/internal/policy/policy.go delete mode 100644 controller/internal/policy/policy_test.go delete mode 100644 controller/internal/quota/PROVENANCE.md delete mode 100644 controller/internal/quota/credential_source.go delete mode 100644 controller/internal/quota/quota.go delete mode 100644 controller/internal/quota/quota_test.go delete mode 100644 controller/internal/recovery/manager.go delete mode 100644 controller/internal/recovery/manager_test.go delete mode 100644 controller/internal/reset/scheduler.go delete mode 100644 controller/internal/reset/scheduler_test.go delete mode 100644 controller/internal/reset/state.go delete mode 100644 controller/internal/runtime/client.go delete mode 100644 controller/internal/runtime/client_test.go delete mode 100644 controller/internal/runtime/installed.go delete mode 100644 controller/internal/runtime/installed_test.go delete mode 100644 controller/internal/runtime/wire.go delete mode 100644 controller/internal/telemetry/cache.go delete mode 100644 controller/internal/telemetry/domain.go delete mode 100644 controller/internal/telemetry/normalize.go delete mode 100644 controller/internal/telemetry/provider.go delete mode 100644 controller/internal/telemetry/provider_test.go delete mode 100644 controller/internal/telemetry/telemetry_test.go delete mode 100644 controller/internal/telemetry/updates.go delete mode 100644 controller/internal/telemetry/wire.go delete mode 100644 controller/internal/transitions/coordinator.go delete mode 100644 controller/internal/transitions/reconcile.go delete mode 100644 controller/internal/transitions/replay.go delete mode 100644 controller/internal/transitions/replay_test.go delete mode 100644 controller/internal/transitions/snapshot_test.go delete mode 100644 controller/internal/transitions/state.go delete mode 100644 docs/companion-cli.md delete mode 100644 integration/fake-runtime/runtime.go delete mode 100644 integration/go.mod delete mode 100644 integration/tests/transitions_test.go delete mode 100644 packaging/README.md delete mode 100644 packaging/release-manifest.json delete mode 100644 protocol/README.md delete mode 100644 protocol/VERSION delete mode 100644 protocol/commands.json delete mode 100644 protocol/events.json delete mode 100644 protocol/protocol.json delete mode 100644 scripts/clean_machine_check.py delete mode 100644 scripts/live_smoke.py delete mode 100644 scripts/package_release.py delete mode 100644 scripts/test_package_release.py delete mode 100644 scripts/verify_package.py delete mode 100644 scripts/verify_protocol.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9eec7ab..7e96bc05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,51 +14,25 @@ concurrency: cancel-in-progress: true jobs: - static: - name: Static, provenance, and package-policy checks - runs-on: ubuntu-latest - steps: - - name: Checkout product source - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Validate provenance and source boundaries - run: python scripts/verify_provenance.py --root . - - name: Validate allow-listed release inputs - run: python scripts/verify_package.py --root . --manifest packaging/release-manifest.json - - name: Validate cross-language protocol catalogs - run: python scripts/verify_protocol.py --root . - - name: Compile release scripts - run: python -m compileall -q scripts - - name: Test companion packaging modes - run: python scripts/test_package_release.py - - build-test: - name: Go companion and optional bridge tests (${{ matrix.os }}) - needs: static + rust: + name: Native Rust CLI checks (${{ matrix.os }}) strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - name: Checkout product source + - name: Checkout source uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.22.x' - cache: true - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: toolchain: stable + components: rustfmt - name: Cache Cargo registry and build uses: actions/cache@v4 with: @@ -68,43 +42,22 @@ jobs: runtime/codex-rs/target key: ${{ runner.os }}-cargo-${{ hashFiles('runtime/codex-rs/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- - - name: Run controller tests + - name: Validate provenance shell: bash - run: cd controller && go test ./... - - name: Run integration tests + run: python scripts/verify_provenance.py --root . + - name: Check formatting shell: bash - run: cd integration && go test ./... - - name: Run adapter tests + working-directory: runtime/codex-rs + run: cargo fmt --all -- --check + - name: Test native Marathon runtime shell: bash - run: cd runtime/codex-rs && cargo test --locked -p codexmarathon-runtime-adapter - - name: Run native bridge tests + working-directory: runtime/codex-rs + run: cargo test --locked -p codexmarathon-runtime + - name: Build native Codex CLI shell: bash - run: cd runtime/codex-rs && cargo test --locked -p codexmarathon-runtime - - name: Build controller and package Linux artifact + working-directory: runtime/codex-rs + run: cargo build --locked --release -p codex-cli + - name: Verify Marathon command surface if: matrix.os == 'ubuntu-latest' shell: bash - run: | - mkdir -p dist/build dist/release - (cd controller && go build -trimpath -o ../dist/build/codexmarathon ./cmd/codexmarathon) - python scripts/package_release.py \ - --root . --output dist/release \ - --controller-binary dist/build/codexmarathon \ - --platform linux-x86_64 --version 0.1.0 --format tar.gz - artifact=$(find dist/release -maxdepth 1 -type f -name '*.tar.gz' -print -quit) - python scripts/verify_package.py --artifact "$artifact" --manifest packaging/release-manifest.json - python scripts/clean_machine_check.py --root . --artifact "$artifact" - - name: Build controller and package Windows artifact - if: matrix.os == 'windows-latest' - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path dist/build, dist/release | Out-Null - Push-Location controller - go build -trimpath -o ../dist/build/codexmarathon.exe ./cmd/codexmarathon - Pop-Location - python scripts/package_release.py ` - --root . --output dist/release ` - --controller-binary dist/build/codexmarathon.exe ` - --platform windows-x86_64 --version 0.1.0 --format zip --windows-exe - $artifact = (Get-ChildItem dist/release -Filter '*.zip' | Select-Object -First 1).FullName - python scripts/verify_package.py --artifact $artifact --manifest packaging/release-manifest.json - python scripts/clean_machine_check.py --root . --artifact $artifact + run: runtime/codex-rs/target/release/codex marathon --help diff --git a/AGENT_TASKS.md b/AGENT_TASKS.md index 82a3ac46..0c2beae2 100644 --- a/AGENT_TASKS.md +++ b/AGENT_TASKS.md @@ -1,116 +1,30 @@ -# CodexMarathon companion-agent assignments +# Native CodexMarathon work areas -These assignments describe the installed-Codex companion product. Agents own -the implementation, focused tests, documentation, and evidence for their -area. They must preserve unrelated work and report compiler/live checks as -passed, blocked, or failed from observed output. +These work areas describe the Rust integration inside the custom Codex CLI. +All changes must preserve Codex's native ownership of authentication, active +turns, transports, conversation state, and recovery. -## Agent 1 — Installed-Codex discovery and command wrapper +## Runtime domain -Implement executable discovery through an explicit `--codex` path, -configuration, or `PATH`; version/capability probing; actionable `doctor` -output; argument forwarding; and launch identity capture. The wrapper must -never search donor trees or start the optional embedded app-server. +Implement account metadata, opaque credential snapshots, atomic persistence, +transition journals, quota policy, and mock-only automatic reset behavior. -Acceptance: +## App-server service -- A clean machine with `codex` installed is detected without reading secrets. -- Missing/incompatible Codex versions produce actionable diagnostics. -- Normal Codex arguments and conversation/thread identity survive wrapper - launch. +Expose typed Marathon status, enabled-state, auto-reset, import, login, and +switch requests. Verify the source identity and safe-boundary state before +writing or reloading credentials. -## Agent 2 — Multi-account login and credential lifecycle +## CLI and TUI -Implement login/add, list/status, activate/use, rename, refresh, and remove -through the supported installed-Codex interface. Store opaque snapshots in the -protected vault, preserve refreshed fields, and keep tokens out of metadata, -logs, journals, diagnostics, and output. +Expose `codex marathon` and `/marathon` with matching account controls. Login +must offer a browser-link flow and a device/auth-code flow for headless +servers. The status line must report Marathon enabled state and managed-account +count. -Acceptance: +## Verification -- Two profiles can be created and switched without manual `auth.json` copying. -- Failed or canceled activation leaves the previous profile intact. -- Refreshed credentials return to the correct profile. - -## Agent 3 — Quota and account policy - -Implement active/inactive quota observation, fresh telemetry, threshold and -`UsageLimitExceeded` decisions, candidate ranking, cooldowns, exhausted-pool -waiting, reset scheduling, and mandatory post-reset revalidation. - -Acceptance: - -- Focused tests cover multi-bucket, stale/partial, error, reset, and pool - exhaustion cases. -- No stale or ambiguous observation authorizes an account transition. - -## Agent 4 — Preferred live control transition - -Integrate the installed Codex supported local control interface. Negotiate -capabilities, wait for Codex's safe boundary, preserve Account A refreshes, -atomically deploy Account B, request native reload and transport invalidation, -and verify account/generation identity before commit. - -Acceptance: - -- A compatible running Codex process switches without losing its conversation. -- Active turns prevent credential mutation. -- Lost acknowledgements and stale generations reconcile by the same transition - ID. - -## Agent 5 — Controlled restart and resume fallback - -Implement the fallback for Codex versions without live control. Ask the same -Codex process to exit cleanly, confirm exit, deploy credentials atomically, -relaunch the same executable, resume the same conversation/thread, and verify -the target identity. - -Acceptance: - -- Deployment never occurs before process exit. -- Resume uses an exact thread ID or Codex's `--last` selection and preserves - original arguments. -- Partial restart failures remain durable and reconcilable. - -## Agent 6 — Exactly-once recovery and restart durability - -Connect policy decisions to Codex's existing parked recovery turn. Keep it -parked through exhaustion/reset waiting, release it only after a verified live -reload or controlled resume, and persist transition/recovery intent across -companion or Codex process restarts. - -Acceptance: - -- The interrupted task resumes once in the same conversation. -- Crash, reconnect, duplicate-event, and lost-response cases produce zero - duplicate continuations. - -## Agent 7 — Companion packaging and release evidence - -Build the Go-only default archive and clean-machine checks. Keep the imported -Rust runtime behind explicit opt-in, validate the archive distribution marker, -and document installed-Codex acceptance for both live reload and controlled -restart/resume. Do not replace missing live evidence with embedded-runtime or -fake-runtime smoke results. - -Acceptance: - -- Linux companion artifact contains one `codexmarathon` entrypoint and no - `codex-app-server`. -- Optional embedded-runtime artifacts require an explicit flag and are marked - separately. -- Static, focused, integration, optional-runtime, and live installed-Codex - evidence are clearly distinguished. - -## Dependency order and orchestration - -1. Agent 1 establishes installed-Codex discovery and wrapper behavior. -2. Agents 2 and 3 can proceed in parallel against that boundary. -3. Agent 4 consumes Agents 1–3 for live reload. -4. Agent 5 provides the fallback process boundary and resume identity. -5. Agent 6 connects both transition paths to recovery and restart replay. -6. Agent 7 packages and validates the resulting companion release. - -The orchestrator should assign independent tasks to Luna Max agents where -slots permit, review shared-worktree diffs after each handoff, and run the -full companion verification before calling the product complete. +Run focused Rust tests and compile checks for the runtime, login, app-server, +TUI, and CLI. Build the release `codex` executable and smoke-test `marathon +--help` and `marathon status`. Never consume a real provider reset while +testing automatic reset. diff --git a/PLAN.md b/PLAN.md index f3e5085b..5a178258 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,200 +1,35 @@ -# CodexMarathon companion product plan +# Native CodexMarathon plan -## Non-negotiable product outcome +## Product outcome -CodexMarathon is a companion CLI for a Codex CLI that the user has already -installed. The normal CodexMarathon release contains the small Go companion -and its metadata; it does not contain, replace, or silently start a second -Codex runtime. +Ship a custom Codex Rust CLI with Marathon account management built into the +same executable. Users manage accounts with `codex marathon` or `/marathon` +without a companion process, external socket, or second authentication tool. -The companion must provide: +The native integration must provide: -1. Multiple account/profile management with protected opaque credential - snapshots. -2. Installed-Codex discovery, version checks, and an actionable compatibility - diagnostic. -3. Proactive and `UsageLimitExceeded` quota policy with fresh telemetry, - deterministic account ranking, reset waiting, and post-reset revalidation. -4. A preferred live handoff through the installed Codex supported local - control interface: wait for the runtime-owned safe boundary, reload the - selected authentication, invalidate account-bound transports, and verify - the new identity/generation. -5. A controlled fallback handoff when live control is unavailable: stop the - same installed Codex process cleanly, atomically deploy the selected - credential, relaunch that executable, resume the same conversation/thread, - and verify the target identity before continuing. -6. Exactly-once continuation using Codex's existing parked recovery turn. -7. Durable transition/recovery intent and reconciliation across process loss. -8. A small Linux and Windows companion archive with no credentials, donor - checkout, Cargo target, or embedded runtime in the default package. +1. Secret-free account aliases, status, import, login, and switching. +2. Browser-link and device-code login for local and headless machines. +3. Safe-boundary transitions through Codex's AuthManager and transport + invalidation authorities. +4. Status-line indicators for Marathon enabled state and account count. +5. Quota-aware account selection and durable transition/recovery evidence. +6. Opt-in automatic reset policy with mock-only development/test executors. -An embedded Codex-derived runtime may remain in the source tree for protocol -development and an explicitly opt-in diagnostic package. It is never a -default installation dependency. +## Runtime ownership -## Product architecture +Codex owns authentication authority, active-turn state, model transports, +conversation state, and parked recovery continuation. Marathon owns aliases, +opaque snapshots, quota policy, transition intent, and reconciliation metadata. +The source identity is verified before every transition; target credentials are +written atomically and reloaded only after Codex reaches a safe boundary. -```text -codexmarathon (Go companion) -| -+-- account registry + protected credential vault -+-- quota telemetry + policy + reset scheduler -+-- transition journal + reconciliation -+-- installed-Codex discovery + capability/version check -| -+--> preferred: supported local control interface -| safe boundary -> auth reload -> transport invalidation -> verify -| -`--> fallback: controlled process handoff - graceful stop -> atomic auth deploy -> relaunch codex - -> resume conversation -> verify identity -``` +## Verification gates -Codex remains authoritative for authentication reload, active-turn state, -model transport invalidation, conversation persistence, and recovery ordering. -The companion owns account selection, credential snapshots, transition intent, -telemetry policy, and evidence reconciliation. It must not create a second -turn counter, duplicate a recovery prompt, or mutate `auth.json` during an -active turn. - -The imported `runtime/codex-rs` and `runtime/codexmarathon-adapter` trees are -test/provenance inputs for the optional compatible control implementation. -They are not runtime dependencies of the companion archive. No donor path is -read at build or runtime. - -## Required donor-code reuse - -The controller continues to reuse and adapt the pinned `codex-switch` feature -cores behind the companion command surface: - -- profile validation, opaque snapshots, metadata, and atomic replacement; -- OAuth refresh and refreshed-token persistence; -- inactive-account quota requests, response-header parsing, retry, and cache; -- candidate ordering, threshold/cooldown policy, reset scheduling, and event - persistence; and -- account capture, list, status, activate, rename, refresh, and remove flows. - -CodexMarathon must use the installed Codex's supported authentication and -control interface for live operations. It must not shell out to `codex-switch` -or copy an unlicensed donor checkout into a release. - -The imported Codex-derived runtime remains the source for the optional local -control adapter and tests around AuthManager reload, safe boundaries, -transport invalidation, telemetry, and native recovery. The companion's -fallback process handoff must use the same installed `codex` executable that -the user selected. - -## Licensing and provenance gate - -The imported Codex-derived source retains its Apache-2.0 `LICENSE` and -`NOTICE`. `runtime/PROVENANCE.md`, `runtime/PATCH_LEDGER.md`, and those -notices remain tracked and are included as allow-listed metadata. The pinned -`codex-switch` checkout has no formal license file; its source remains outside -release archives until an explicit redistribution grant is recorded. - -## Gate 1 — Companion executable and installed-Codex boundary - -- Build the normal release from the Go controller alone. -- Resolve Codex from `--codex`, configured path, or `PATH` without searching - donor or embedded-runtime directories. -- Report version, executable path, health, and supported live-control versus - restart/resume capability without exposing credentials. -- Forward normal Codex arguments and preserve the selected process/thread - identity needed for controlled resume. -- Keep the imported runtime build behind an explicit opt-in package flag. - -Exit: a clean machine with Codex installed can run `codexmarathon doctor` and -launch Codex through the companion; a machine without Codex receives an -actionable prerequisite error. - -## Gate 2 — Native multi-account login and credential lifecycle - -- Provide account login/add, list/status, activate/use, rename, refresh, and - remove using one companion command surface. -- Capture completed login output through the installed Codex supported - interface and store it only in the protected vault. -- Preserve provider-specific fields and refreshed tokens in the correct - profile. -- Keep tokens out of metadata, journals, diagnostics, logs, and normal output. -- Leave the previous active profile intact on cancellation or failed writes. - -Exit: two accounts can be stored, inspected, refreshed, and selected without -manual `auth.json` copying or another account-switching tool. - -## Gate 3 — Quota intelligence and automatic policy - -- Feed active-account observations from installed Codex when available. -- Probe inactive profiles through supported Codex-compatible telemetry calls. -- Handle proactive thresholds and `UsageLimitExceeded` deterministically. -- Rank only fresh eligible accounts and prevent switch loops. -- Wait for the earliest trustworthy reset when the pool is exhausted, then - re-query telemetry before authorizing a switch. - -Exit: focused tests cover multi-bucket limits, partial/stale observations, -errors, cooldowns, pool exhaustion, reset ordering, and failed refreshes. - -## Gate 4 — Preferred live reload transition - -- Negotiate the installed Codex local control protocol and validate its - version/capabilities before changing state. -- Use Codex's authoritative running-turn and safe-boundary state. -- Persist Account A token refreshes before selecting Account B. -- Atomically deploy Account B's snapshot only at the safe boundary. -- Request native auth reload and account-bound transport invalidation through - the supported interface. -- Correlate transition ID and expected auth generation, then verify runtime, - disk, and process identity before committing. - -Exit: a live compatible Codex process switches accounts without losing the -conversation, and its next request cannot reuse Account A's transport. - -## Gate 5 — Controlled restart and resume fallback - -- Detect when the installed Codex lacks the compatible local control path or - loses it during a transition. -- Ask the same process to stop cleanly and persist its conversation/thread - resume identity. -- Confirm process exit before replacing the active credential. -- Atomically deploy the selected snapshot and preserve legitimate token - refreshes from Account A. -- Relaunch the same executable with original arguments and the captured resume - identity. -- Verify Account B and the expected conversation/thread before committing. - -Exit: a restart fallback preserves the same conversation and resumes only -after target identity verification; partial failures remain reconcilable. - -## Gate 6 — Exactly-once recovery and restart durability - -- Observe Codex's existing parked recovery turn after `UsageLimitExceeded`. -- Keep it parked during quota exhaustion and reset waiting. -- Release it only after either Gate 4 or Gate 5 verifies the target identity. -- Persist transition/recovery IDs and phases so controller/process restarts do - not lose or duplicate continuation. -- Reconcile lost acknowledgements, stale generations, process crashes, and - refreshed-token write-back using the same transition identity. - -Exit: an interrupted task resumes once in the same conversation on Account B, -including failure/restart at every handoff phase. - -## Gate 7 — Companion release evidence - -Required acceptance cases: - -1. Installed-Codex discovery and incompatible-version diagnostics. -2. Proactive threshold A -> B through the live control interface. -3. `UsageLimitExceeded` A -> B with exactly-once recovery. -4. Active turn prevents live credential mutation. -5. Live control unavailable -> controlled restart and same-thread resume. -6. Lost reload/stop acknowledgement reconciles by the same transition ID. -7. Stale generation, split identity, and failed deployment are rejected. -8. Exhausted pool -> earliest reset -> fresh telemetry -> transition. -9. Refreshed-token write-back survives switch and process restart. -10. Clean-machine companion packaging without donor repositories, developer - toolchains, or embedded runtime. - -The default artifact must be built by Go only and must contain one -`codexmarathon` entrypoint. The optional embedded-runtime artifact must be -explicitly requested, marked in its generated manifest, and tested separately. -Static, deterministic, and optional-runtime results are never substituted for -live installed-Codex evidence. +- Focused Rust tests cover account storage, auth transitions, quota policy, + recovery ordering, and automatic-reset idempotence. +- `cargo check` covers the Marathon runtime, login, app-server, TUI, and CLI. +- A release build produces one `codex` executable containing the native + `marathon` command and interactive `/marathon` controls. +- Live acceptance uses a disposable Codex home and never invokes a real quota + reset while the provider integration is disabled. diff --git a/README.md b/README.md index f7572e7f..bded841a 100644 --- a/README.md +++ b/README.md @@ -149,13 +149,6 @@ cargo check -p codex-tui cargo check -p codex-cli ``` -## Legacy companion - -Earlier repository versions used a separate Go `codexmarathon` controller. -It remains in the source tree for compatibility and migration work, but the -supported user workflow is the native `codex marathon` and `/marathon` -interface described above. - ## Documentation - [Features and usage](docs/features-and-usage.md) diff --git a/controller/app/app.go b/controller/app/app.go deleted file mode 100644 index f41e6aa3..00000000 --- a/controller/app/app.go +++ /dev/null @@ -1,1174 +0,0 @@ -// Package app contains the small CodexMarathon controller composition root. -// -// It wires the durable controller domains (accounts, credentials, journal, -// telemetry, policy, and reset scheduling) to a single runtime client. The -// RuntimeSupervisor in lifecycle.go owns the one-command bundled runtime -// process/IPC boundary; the domain package remains usable with an injected -// io.ReadWriteCloser for focused tests. -package app - -import ( - "context" - "errors" - "fmt" - "io" - "net" - "net/url" - "os" - "path/filepath" - goruntime "runtime" - "strings" - "sync" - "time" - - "codexmarathon/controller/internal/accounts" - "codexmarathon/controller/internal/automation" - "codexmarathon/controller/internal/credentials" - "codexmarathon/controller/internal/journal" - "codexmarathon/controller/internal/policy" - "codexmarathon/controller/internal/recovery" - "codexmarathon/controller/internal/reset" - "codexmarathon/controller/internal/runtime" - "codexmarathon/controller/internal/telemetry" - "codexmarathon/controller/internal/transitions" -) - -const ( - // DefaultTelemetryTTL is intentionally short enough that a status display - // does not present an old provider observation as current indefinitely. - DefaultTelemetryTTL = 5 * time.Minute - // DefaultConnectTimeout bounds only the initial protocol handshake. A - // caller can use a longer context for a transition or reconciliation. - DefaultConnectTimeout = 10 * time.Second -) - -var ( - ErrRuntimeNotConnected = errors.New("runtime is not connected") - ErrRuntimeAlreadySet = errors.New("runtime is already connected") - ErrRuntimeEndpoint = errors.New("unsupported runtime endpoint") - // ErrTelemetryRefetchRequired is returned for an ambiguous sparse update. - // It is an observation requiring a full provider read, not evidence that - // the account is unavailable. - ErrTelemetryRefetchRequired = errors.New("telemetry refetch required") -) - -// Config selects the controller's durable paths. Empty derived paths are -// filled from StateDir by New. Runtime connections are intentionally not part -// of Config so constructing a controller remains side-effect free; use -// ConnectRuntime for a live peer. -type Config struct { - StateDir string - RegistryPath string - VaultDir string - AuthPath string - JournalPath string - TelemetryTTL time.Duration -} - -// DefaultConfig returns paths suitable for the current user. It does not -// create any files or directories. AuthPath follows Codex's conventional -// ~/.codex/auth.json location; operators should pass an explicit path when -// running against a non-default Codex installation. -func DefaultConfig() Config { - stateDir := "" - if configDir, err := os.UserConfigDir(); err == nil && configDir != "" { - stateDir = filepath.Join(configDir, "CodexMarathon") - } - if stateDir == "" { - stateDir = filepath.Join(".", ".codexmarathon") - } - authPath := "" - if home, err := os.UserHomeDir(); err == nil && home != "" { - authPath = filepath.Join(home, ".codex", "auth.json") - } - if authPath == "" { - authPath = filepath.Join(".", ".codex", "auth.json") - } - return Config{ - StateDir: stateDir, - AuthPath: authPath, - TelemetryTTL: DefaultTelemetryTTL, - } -} - -func (c Config) withDefaults() (Config, error) { - defaults := DefaultConfig() - if strings.TrimSpace(c.StateDir) == "" { - c.StateDir = defaults.StateDir - } - if strings.TrimSpace(c.AuthPath) == "" { - c.AuthPath = defaults.AuthPath - } - if strings.TrimSpace(c.RegistryPath) == "" { - c.RegistryPath = filepath.Join(c.StateDir, "accounts.json") - } - if strings.TrimSpace(c.VaultDir) == "" { - c.VaultDir = filepath.Join(c.StateDir, "credentials") - } - if strings.TrimSpace(c.JournalPath) == "" { - c.JournalPath = filepath.Join(c.StateDir, "events.jsonl") - } - if c.TelemetryTTL == 0 { - c.TelemetryTTL = DefaultTelemetryTTL - } - if c.TelemetryTTL < 0 { - return Config{}, errors.New("telemetry TTL cannot be negative") - } - for name, path := range map[string]string{ - "state directory": c.StateDir, - "registry path": c.RegistryPath, - "vault directory": c.VaultDir, - "auth path": c.AuthPath, - "journal path": c.JournalPath, - } { - if strings.TrimSpace(path) == "" { - return Config{}, fmt.Errorf("%s is empty", name) - } - } - return c, nil -} - -// Controller is the composed controller runtime. Its fields are exposed as -// narrow read-only-in-practice accessors rather than global singletons so -// tests and later frontends can supply their own command loop. -type Controller struct { - config Config - - registry *accounts.FileRegistry - vault *credentials.FileVault - accountManager *accounts.Manager - journal *journal.FileJournal - store *telemetry.StateStore - cache *telemetry.SnapshotCache - router *telemetry.MultiAccountUsageRouter - scheduler *reset.Scheduler - policy *policy.Engine - recovery *recovery.Manager - - mu sync.RWMutex - runtime *runtime.Client - runtimeFacade *clientRuntime - coordinator *transitions.Coordinator - runtimeID string - protocolVersion int - - eventCancel context.CancelFunc - eventDone chan struct{} - eventErrors chan error - automationCancel context.CancelFunc - automationDone chan struct{} - automationEvents chan automation.Event - closeOnce sync.Once -} - -// New composes all controller domains without opening files or connecting to -// a runtime. It is safe to use for read-only status and account commands. -func New(config Config) (*Controller, error) { - config, err := config.withDefaults() - if err != nil { - return nil, err - } - registry := accounts.NewFileRegistry(config.RegistryPath) - vault := credentials.NewFileVault(config.VaultDir) - accountManager := accounts.NewManager(accounts.ManagerConfig{ - Registry: registry, - Vault: vault, - AuthPath: config.AuthPath, - }) - fileJournal := journal.NewFileJournal(config.JournalPath) - recoveryManager, err := recovery.New(recovery.Config{Journal: fileJournal}) - if err != nil { - return nil, fmt.Errorf("replay recovery journal: %w", err) - } - store := telemetry.NewStateStore() - cache := telemetry.NewSnapshotCache(config.TelemetryTTL) - router := telemetry.NewMultiAccountUsageRouter(cache, store) - scheduler := reset.NewScheduler() - return &Controller{ - config: config, - registry: registry, - vault: vault, - accountManager: accountManager, - journal: fileJournal, - store: store, - cache: cache, - router: router, - scheduler: scheduler, - policy: policy.NewEngine(scheduler), - recovery: recoveryManager, - eventErrors: make(chan error, 32), - }, nil -} - -// EnsureLayout creates only the controller-owned state directories. It never -// creates or changes Codex's auth path and never reads credential contents. -func (c *Controller) EnsureLayout() error { - if c == nil { - return errors.New("nil controller") - } - for _, path := range []string{c.config.StateDir, c.config.VaultDir} { - if err := os.MkdirAll(path, 0o700); err != nil { - return fmt.Errorf("create controller directory %q: %w", path, err) - } - if err := os.Chmod(path, 0o700); err != nil && !isPermissionMetadataError(err) { - return fmt.Errorf("tighten controller directory %q: %w", path, err) - } - } - return nil -} - -// Config returns the effective paths without exposing any credential data. -func (c *Controller) Config() Config { - if c == nil { - return Config{} - } - return c.config -} - -// Registry returns the durable account registry seam. -func (c *Controller) Registry() *accounts.FileRegistry { - if c == nil { - return nil - } - return c.registry -} - -// Vault returns the credential vault seam. Callers should prefer transition -// methods and must not print the bytes returned by Vault.Load. -func (c *Controller) Vault() *credentials.FileVault { - if c == nil { - return nil - } - return c.vault -} - -// AccountManager returns the integrated multi-account profile lifecycle seam. -// Native login and refresh are supplied by the embedded runtime through -// SetAuthService; no external Codex executable is launched by this package. -func (c *Controller) AccountManager() *accounts.Manager { - if c == nil { - return nil - } - return c.accountManager -} - -// Accounts is a concise alias for AccountManager. -func (c *Controller) Accounts() *accounts.Manager { return c.AccountManager() } - -// SetAuthService attaches the embedded runtime's native login/refresh -// implementation to the account manager. Passing nil restores the explicit -// unavailable state and never installs a shell-out fallback. -func (c *Controller) SetAuthService(service accounts.AuthService) { - if c == nil || c.accountManager == nil { - return - } - c.accountManager.SetAuthService(service) -} - -// Journal returns the append-only metadata journal. -func (c *Controller) Journal() *journal.FileJournal { - if c == nil { - return nil - } - return c.journal -} - -// Recovery returns the durable coordinator for Codext-owned parked -// UsageLimitExceeded continuations. It never exposes prompt or credential -// bytes. -func (c *Controller) Recovery() *recovery.Manager { - if c == nil { - return nil - } - return c.recovery -} - -// Store returns the normalized in-memory telemetry state store. -func (c *Controller) Store() *telemetry.StateStore { - if c == nil { - return nil - } - return c.store -} - -// Cache returns the immutable-by-convention telemetry cache. -func (c *Controller) Cache() *telemetry.SnapshotCache { - if c == nil { - return nil - } - return c.cache -} - -// Router returns the provider router used for active and inactive account -// observations. -func (c *Controller) Router() *telemetry.MultiAccountUsageRouter { - if c == nil { - return nil - } - return c.router -} - -// Scheduler returns the pool-exhaustion/reset scheduler. -func (c *Controller) Scheduler() *reset.Scheduler { - if c == nil { - return nil - } - return c.scheduler -} - -// Policy returns the decision engine composed with Scheduler. -func (c *Controller) Policy() *policy.Engine { - if c == nil { - return nil - } - return c.policy -} - -// Coordinator returns nil until a runtime connection has been attached. -func (c *Controller) Coordinator() *transitions.Coordinator { - if c == nil { - return nil - } - c.mu.RLock() - coordinator := c.coordinator - c.mu.RUnlock() - return coordinator -} - -// RuntimeClient returns the live JSON-RPC client, or nil when disconnected. -func (c *Controller) RuntimeClient() *runtime.Client { - if c == nil { - return nil - } - c.mu.RLock() - client := c.runtime - c.mu.RUnlock() - return client -} - -// RuntimeConnected reports whether a live runtime client is attached. -func (c *Controller) RuntimeConnected() bool { return c.RuntimeClient() != nil } - -// RuntimeProtocolVersion returns the negotiated Marathon protocol version, or -// zero when no runtime is attached. The value is diagnostic metadata only; -// all requests still go through the negotiated client. -func (c *Controller) RuntimeProtocolVersion() int { - if c == nil { - return 0 - } - c.mu.RLock() - version := c.protocolVersion - c.mu.RUnlock() - return version -} - -// DisconnectRuntime detaches the current runtime without closing the -// controller's durable domains. It is used by the lifecycle supervisor when -// a managed runtime exits or its IPC transport breaks; a later reconnect can -// install a fresh protocol client and perform reconciliation. -func (c *Controller) DisconnectRuntime(reason string) error { - if c == nil { - return nil - } - if strings.TrimSpace(reason) == "" { - reason = "runtime connection closed" - } - c.StopEventLoop() - return c.detachRuntimeWithReason(reason) -} - -// EventErrors exposes non-fatal protocol/telemetry errors from the event -// loop. A consumer may ignore this channel when it only needs request APIs. -func (c *Controller) EventErrors() <-chan error { - if c == nil { - return nil - } - return c.eventErrors -} - -// ConnectRuntime attaches an already-running runtime, negotiates protocol v1, -// verifies its initial state, and starts the one event fan-out loop owned by -// the controller. The transport's ownership transfers to Controller. -func (c *Controller) ConnectRuntime(ctx context.Context, conn io.ReadWriteCloser) error { - if c == nil { - return errors.New("nil controller") - } - if conn == nil { - return errors.New("runtime transport is nil") - } - if ctx == nil { - ctx = context.Background() - } - - c.mu.Lock() - if c.runtime != nil { - c.mu.Unlock() - return ErrRuntimeAlreadySet - } - client := runtime.NewClient(conn) - c.runtime = client - c.runtimeFacade = &clientRuntime{client: client} - deployer := credentials.NewAtomicDeployer(c.vault, c.config.AuthPath) - c.coordinator = transitions.NewCoordinator(transitions.Config{ - Runtime: c.runtimeFacade, - Deployer: deployer, - SnapshotWriter: c.vault, - Disk: transitions.NewFileIdentityReader(c.config.AuthPath), - Journal: c.journal, - }) - c.mu.Unlock() - - connected := false - defer func() { - if !connected { - c.StopEventLoop() - _ = c.detachRuntime() - } - }() - negotiated, err := client.NegotiateVersion(ctx, []int{runtime.ProtocolVersion}) - if err != nil { - return fmt.Errorf("negotiate runtime protocol: %w", err) - } - state, err := client.GetRuntimeState(ctx) - if err != nil { - return fmt.Errorf("read runtime state after negotiation: %w", err) - } - if state.Identity.RuntimeID == "" { - return errors.New("runtime returned an empty runtime_id") - } - c.mu.Lock() - c.runtimeID = state.Identity.RuntimeID - c.protocolVersion = negotiated.ProtocolVersion - coordinator := c.coordinator - c.mu.Unlock() - // Restore controller-owned transition intent before publishing the new - // runtime connection. Without this replay, a controller restart loses the - // transition ID/generation needed to reconcile a command that may already - // have been accepted by the runtime. - if coordinator != nil && c.journal != nil { - events, readErr := c.journal.ReadAll() - if readErr != nil { - return fmt.Errorf("read transition journal before reconnect: %w", readErr) - } - if restoreErr := coordinator.Restore(events); restoreErr != nil { - return fmt.Errorf("restore transition intent before reconnect: %w", restoreErr) - } - } - if c.recovery != nil { - c.recovery.SetRuntime(client) - } - if err := c.journal.Append(journal.Event{ - Type: journal.RuntimeConnected, - RuntimeID: state.Identity.RuntimeID, - AccountID: optionalAccountID(state.Identity.AccountID), - Reason: fmt.Sprintf("protocol v%d negotiated", negotiated.ProtocolVersion), - }); err != nil { - return fmt.Errorf("journal runtime connection: %w", err) - } - if err := c.StartEventLoop(context.Background()); err != nil { - return err - } - // Replay runtime-owned recovery state after the transport is live. A - // transient reconciliation failure is observable but must not discard a - // parked continuation or prevent the controller from reconnecting. - if c.recovery != nil { - if err := c.recovery.Reconcile(ctx); err != nil && !errors.Is(err, recovery.ErrRuntimeUnavailable) { - c.reportEventError(fmt.Errorf("reconcile parked recoveries: %w", err)) - } - } - // Attach the direct native auth service only after negotiation/state - // validation succeeds. Account login/refresh commands can then use the - // same embedded runtime connection without shelling out to Codex. - c.SetAuthService(NewRuntimeAuthService(client)) - if err := c.StartAutomationLoop(context.Background()); err != nil { - return fmt.Errorf("start quota automation loop: %w", err) - } - connected = true - return nil -} - -// ConnectRuntimeEndpoint dials a supported local endpoint and calls -// ConnectRuntime. The MVP CLI supports tcp:// (and host:port shorthand) so -// it remains usable with a small external adapter harness on Windows. Named -// pipes/Unix sockets can be supplied directly through ConnectRuntime by a -// host-specific connector without changing the controller composition. -func (c *Controller) ConnectRuntimeEndpoint(ctx context.Context, endpoint string) error { - conn, err := DialEndpoint(endpoint) - if err != nil { - return err - } - if err := c.ConnectRuntime(ctx, conn); err != nil { - _ = conn.Close() - return err - } - return nil -} - -// DialEndpoint opens the transport-neutral endpoint forms supported by the -// standard library. tcp://host:port and host:port are supported everywhere; -// unix:// is available only on platforms whose net package supports Unix -// sockets. The CLI rejects named-pipe endpoints explicitly instead of -// pretending to provide Windows IPC it cannot verify. -func DialEndpoint(endpoint string) (io.ReadWriteCloser, error) { - endpoint = strings.TrimSpace(endpoint) - if endpoint == "" { - return nil, fmt.Errorf("%w: endpoint is empty", ErrRuntimeEndpoint) - } - if !strings.Contains(endpoint, "://") { - endpoint = "tcp://" + endpoint - } - parsed, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrRuntimeEndpoint, err) - } - switch strings.ToLower(parsed.Scheme) { - case "tcp": - address := parsed.Host - if address == "" { - return nil, fmt.Errorf("%w: tcp endpoint has no host:port", ErrRuntimeEndpoint) - } - conn, err := net.DialTimeout("tcp", address, DefaultConnectTimeout) - if err != nil { - return nil, fmt.Errorf("dial runtime %q: %w", address, err) - } - return conn, nil - case "unix": - if parsed.Path == "" { - return nil, fmt.Errorf("%w: unix endpoint has no path", ErrRuntimeEndpoint) - } - conn, err := net.DialTimeout("unix", parsed.Path, DefaultConnectTimeout) - if err != nil { - return nil, fmt.Errorf("dial runtime %q: %w", parsed.Path, err) - } - return conn, nil - case "pipe", "npipe", "namedpipe": - return nil, fmt.Errorf("%w: named pipes require a host-specific connector", ErrRuntimeEndpoint) - default: - return nil, fmt.Errorf("%w: scheme %q", ErrRuntimeEndpoint, parsed.Scheme) - } -} - -// StartEventLoop consumes the client's event stream exactly once. The -// transition coordinator intentionally does not read the client's channel -// directly; this avoids races between safe-boundary delivery and telemetry -// ingestion while preserving the coordinator's state-poll fallback. -func (c *Controller) StartEventLoop(ctx context.Context) error { - if c == nil { - return errors.New("nil controller") - } - if ctx == nil { - ctx = context.Background() - } - c.mu.Lock() - client := c.runtime - if client == nil { - c.mu.Unlock() - return ErrRuntimeNotConnected - } - if c.eventDone != nil { - c.mu.Unlock() - return errors.New("runtime event loop is already running") - } - loopCtx, cancel := context.WithCancel(ctx) - done := make(chan struct{}) - c.eventCancel = cancel - c.eventDone = done - c.mu.Unlock() - go c.runEventLoop(loopCtx, client, done) - return nil -} - -// StopEventLoop stops the fan-out loop but leaves the runtime transport -// attached. Close or detachRuntime closes the transport itself. -func (c *Controller) StopEventLoop() { - if c == nil { - return - } - c.mu.Lock() - cancel, done := c.eventCancel, c.eventDone - c.eventCancel = nil - c.eventDone = nil - c.mu.Unlock() - if cancel != nil { - cancel() - } - if done != nil { - <-done - } -} - -// StartAutomationLoop starts the controller-owned quota policy loop for the -// currently attached runtime. Runtime telemetry notifications are translated -// into secret-free automation events by HandleRuntimeEvent. Only one loop is -// allowed per controller connection; a reconnect creates a fresh loop while -// the policy engine and durable account state remain shared. -func (c *Controller) StartAutomationLoop(ctx context.Context) error { - if c == nil { - return errors.New("nil controller") - } - if ctx == nil { - ctx = context.Background() - } - loop, err := c.NewAutomationLoop() - if err != nil { - return err - } - loopCtx, cancel := context.WithCancel(ctx) - events := make(chan automation.Event, 256) - done := make(chan struct{}) - c.mu.Lock() - if c.runtime == nil { - c.mu.Unlock() - cancel() - return ErrRuntimeNotConnected - } - if c.automationDone != nil { - c.mu.Unlock() - cancel() - return errors.New("quota automation loop is already running") - } - c.automationCancel = cancel - c.automationDone = done - c.automationEvents = events - c.mu.Unlock() - go func() { - defer close(done) - if runErr := loop.Run(loopCtx, events); runErr != nil && !errors.Is(runErr, context.Canceled) { - c.reportEventError(fmt.Errorf("quota automation loop: %w", runErr)) - } - }() - return nil -} - -// StopAutomationLoop stops policy evaluation without detaching the runtime. -// It is idempotent and keeps durable account/transition state intact. -func (c *Controller) StopAutomationLoop() { - if c == nil { - return - } - c.mu.Lock() - cancel, done := c.automationCancel, c.automationDone - c.automationCancel = nil - c.automationDone = nil - c.automationEvents = nil - c.mu.Unlock() - if cancel != nil { - cancel() - } - if done != nil { - <-done - } -} - -func (c *Controller) emitAutomationEvent(event runtime.Event) { - if c == nil { - return - } - automationEvent, ok := automationEventFromRuntime(event) - if !ok { - return - } - c.mu.RLock() - events := c.automationEvents - c.mu.RUnlock() - if events == nil { - return - } - select { - case events <- automationEvent: - default: - // A full wake-up queue must not block the runtime protocol reader. The - // loop also has a bounded polling/timer wake-up, so dropping a duplicate - // telemetry edge is safe; hard events carry stable IDs and remain - // visible through the diagnostic channel. - c.reportEventError(errors.New("quota automation event queue is full")) - } -} - -func automationEventFromRuntime(event runtime.Event) (automation.Event, bool) { - base := automation.Event{OccurredAt: eventTime(event.OccurredAt)} - switch event.EventType { - case runtime.EventRateLimitsSnapshot, runtime.EventRateLimitsUpdated: - base.Type = automation.EventTelemetryChanged - // Telemetry edges are intentionally not assigned an EventID. A runtime - // can emit two valid snapshots in the same second, and policy should - // re-evaluate both rather than suppressing the second one. - return base, true - case runtime.EventRecoveryParked: - decoded, err := event.DecodePayload() - if err != nil { - return automation.Event{}, false - } - parked, ok := decoded.(*runtime.RecoveryParkedEvent) - if !ok || parked.RecoveryID == "" { - return automation.Event{}, false - } - base.Type = automation.EventUsageLimitExceeded - base.ID = "recovery/" + parked.RecoveryID - base.AccountID = parked.SourceAccountID - return base, true - case runtime.EventTurnCompleted: - decoded, err := event.DecodePayload() - if err != nil { - return automation.Event{}, false - } - completed, ok := decoded.(*runtime.TurnCompletedEvent) - if !ok || !isUsageLimitEvent(completed.Outcome, completed.ErrorCode) { - return automation.Event{}, false - } - base.Type = automation.EventUsageLimitExceeded - base.ID = "turn/" + completed.TurnID - return base, true - default: - return automation.Event{}, false - } -} - -func isUsageLimitEvent(outcome string, errorCode *string) bool { - value := strings.ToLower(outcome) - if errorCode != nil { - value += " " + strings.ToLower(*errorCode) - } - return strings.Contains(value, "usage_limit") || strings.Contains(value, "usage limit") || strings.Contains(value, "usagelimit") -} - -func (c *Controller) runEventLoop(ctx context.Context, client *runtime.Client, done chan struct{}) { - defer close(done) - errorEvents := client.Errors() - disconnected := false - for { - select { - case <-ctx.Done(): - return - case event, ok := <-client.Events(): - if !ok { - disconnected = true - goto finished - } - if err := c.HandleRuntimeEvent(event); err != nil { - c.reportEventError(err) - } - case err, ok := <-errorEvents: - if !ok { - // Errors are auxiliary; continue draining events until the - // transport closes its event channel. - errorEvents = nil - continue - } - c.reportEventError(err) - } - } - -finished: - // A transport-owned disconnect reaches this goroutine without going - // through StopEventLoop. Clear the loop handles before detaching so a - // supervisor reconnect can install a fresh event loop; StopEventLoop clears - // the same fields first when cancellation is operator-initiated. - c.mu.Lock() - if c.eventDone == done { - c.eventDone = nil - c.eventCancel = nil - } - c.mu.Unlock() - // StopEventLoop cancels the loop while leaving a healthy client attached. - // Only a client whose own Done channel is closed represents an IPC loss. - if disconnected && client.Closed() { - if err := c.detachRuntimeWithReason("runtime transport disconnected"); err != nil { - c.reportEventError(err) - } - } -} - -func (c *Controller) reportEventError(err error) { - if c == nil || err == nil { - return - } - select { - case c.eventErrors <- err: - default: - // A full diagnostic channel must not stop transition or telemetry - // processing. The durable journal remains the source of transition - // evidence. - } -} - -// HandleRuntimeEvent routes one decoded event to transition correlation and -// controller telemetry. Unknown event types remain forward-compatible and are -// ignored after their common fields have been validated by runtime.Client. -func (c *Controller) HandleRuntimeEvent(event runtime.Event) error { - if c == nil { - return errors.New("nil controller") - } - if event.RuntimeID == "" { - return errors.New("runtime event has no runtime_id") - } - c.mu.RLock() - expectedRuntimeID := c.runtimeID - coordinator := c.coordinator - c.mu.RUnlock() - if expectedRuntimeID != "" && event.RuntimeID != expectedRuntimeID { - return fmt.Errorf("runtime event belongs to %q, expected %q", event.RuntimeID, expectedRuntimeID) - } - if coordinator != nil { - if err := coordinator.HandleEvent(event); err != nil && !errors.Is(err, transitions.ErrTransitionNotFound) { - return err - } - } - if c.recovery != nil { - switch event.EventType { - case runtime.EventRecoveryParked, runtime.EventRecoveryStarted, runtime.EventRecoveryCompleted: - if err := c.recovery.HandleEvent(event); err != nil { - return err - } - c.emitAutomationEvent(event) - return nil - } - } - - switch event.EventType { - case runtime.EventRateLimitsSnapshot: - decoded, err := event.DecodePayload() - if err != nil { - return err - } - payload, ok := decoded.(*runtime.RateLimitsSnapshotEvent) - if !ok { - return errors.New("rate_limits_snapshot payload type mismatch") - } - accountID := optionalAccountID(payload.AccountID) - if accountID == "" { - // An unauthenticated runtime cannot produce a usable account - // snapshot. Keep this as an observation, not a zero-quota claim. - return nil - } - observedAt := eventTime(event.OccurredAt) - snapshot := normalizeRuntimeSnapshot(accountID, payload.RateLimits, payload.RateLimitsByID, observedAt) - c.router.IngestFull(accountID, snapshot) - _ = c.router.Register(accountID, snapshotProvider{store: c.store}) - if err := c.markTelemetry(accountID, observedAt); err != nil { - return err - } - c.emitAutomationEvent(event) - return nil - - case runtime.EventRateLimitsUpdated: - decoded, err := event.DecodePayload() - if err != nil { - return err - } - payload, ok := decoded.(*runtime.RateLimitsUpdatedEvent) - if !ok { - return errors.New("rate_limits_updated payload type mismatch") - } - accountID := optionalAccountID(payload.AccountID) - if accountID == "" { - return nil - } - result := c.router.IngestSparse(accountID, telemetry.AccountRateLimitsUpdatedWire{ - RateLimits: convertRuntimeSnapshot(payload.RateLimits), - }) - if result.RefetchRequired { - return fmt.Errorf("%w for account %q: %s", ErrTelemetryRefetchRequired, accountID, result.Reason) - } - if err := c.markTelemetry(accountID, eventTime(event.OccurredAt)); err != nil { - return err - } - c.emitAutomationEvent(event) - return nil - default: - c.emitAutomationEvent(event) - return nil - } -} - -func (c *Controller) markTelemetry(accountID string, observedAt time.Time) error { - if c.registry == nil { - return nil - } - if err := c.registry.SetTelemetry(accountID, observedAt, "runtime"); err != nil { - if errors.Is(err, accounts.ErrAccountNotFound) { - // Runtime telemetry may arrive before an operator registers the - // account. StateStore remains useful and registration can happen - // later without replaying the protocol event. - return nil - } - return fmt.Errorf("mark telemetry for %q: %w", accountID, err) - } - return nil -} - -func eventTime(seconds int64) time.Time { - if seconds < 0 { - return time.Unix(0, 0).UTC() - } - return time.Unix(seconds, 0).UTC() -} - -func optionalAccountID(value *string) string { - if value == nil { - return "" - } - return *value -} - -// AccountsTelemetry returns one normalized observation per registered -// account. It is a read of state already ingested from runtime/provider -// events; missing observations are explicitly unusable and never represented -// as zero usage. Use ObserveTelemetry for a bounded provider refresh pass. -func (c *Controller) AccountsTelemetry() ([]telemetry.AccountTelemetry, error) { - if c == nil { - return nil, errors.New("nil controller") - } - registered, err := c.registry.List() - if err != nil { - return nil, err - } - observations := make([]telemetry.AccountTelemetry, 0, len(registered)) - for _, account := range registered { - if snapshot, ok := c.store.Get(account.ID); ok { - observations = append(observations, snapshot) - continue - } - observations = append(observations, telemetry.AccountTelemetry{ - AccountID: account.ID, - IsUsable: false, - Source: "missing", - }) - } - return observations, nil -} - -// ObserveTelemetry performs one deterministic active/inactive account -// observation pass. Active runtime events and inactive credential-backed -// providers share the router; a provider failure remains an unusable account -// observation so policy can evaluate the rest of the pool. -func (c *Controller) ObserveTelemetry(ctx context.Context, force bool) ([]telemetry.AccountObservation, error) { - if c == nil { - return nil, errors.New("nil controller") - } - registered, err := c.registry.List() - if err != nil { - return nil, err - } - ids := make([]string, 0, len(registered)) - for _, account := range registered { - ids = append(ids, account.ID) - } - return c.router.Observe(ctx, ids, telemetry.ObserveOptions{Force: force, MaxAge: c.config.TelemetryTTL, Now: time.Now().UTC()}) -} - -// EvaluatePolicyTrigger evaluates a threshold, hard-limit, or reset event -// against caller-supplied observations. It is the bridge for the event loop; -// no transition is started here, so the transition coordinator remains the -// sole authentication authority. -func (c *Controller) EvaluatePolicyTrigger(observations []telemetry.AccountTelemetry, activeAccountID string, trigger policy.Trigger, eventID string, now time.Time) policy.PolicyDecision { - if c == nil || c.policy == nil { - return policy.PolicyDecision{Type: policy.PolicyNoTelemetry, Trigger: trigger, Reason: "policy engine is unavailable"} - } - return c.policy.EvaluateInput(policy.EvaluationInput{ - Accounts: observations, - ActiveAccountID: activeAccountID, - Trigger: trigger, - EventID: eventID, - FreshnessTTL: c.config.TelemetryTTL, - Now: now, - }) -} - -// EvaluatePolicy applies the composed policy and reset scheduler to all -// registered accounts. It never starts a transition; callers decide whether -// to invoke RequestTransition after inspecting the returned decision. -func (c *Controller) EvaluatePolicy(now time.Time) (policy.PolicyDecision, error) { - if c == nil { - return policy.PolicyDecision{}, errors.New("nil controller") - } - observations, err := c.AccountsTelemetry() - if err != nil { - return policy.PolicyDecision{}, err - } - return c.policy.Evaluate(observations, now), nil -} - -// RequestTransition delegates one decision-approved transition to the -// coordinator. It refuses to operate until a runtime peer is connected. -func (c *Controller) RequestTransition(ctx context.Context, accountID string) (*transitions.TransitionResult, error) { - if c == nil { - return nil, errors.New("nil controller") - } - coordinator := c.Coordinator() - if coordinator == nil { - return nil, ErrRuntimeNotConnected - } - result, err := coordinator.RequestTransition(ctx, accountID) - if result != nil && result.Outcome == transitions.TransitionCommitted { - // The coordinator verifies the runtime and disk authorities, but the - // registry's active marker is a separate controller-owned authority. - // Keep it aligned before policy/recovery code observes the commit, or a - // successful switch would be evaluated again as if the old account were - // still active after a restart. - if c.registry != nil && result.TargetAccountID != "" { - if activeErr := c.registry.SetActive(result.TargetAccountID); activeErr != nil { - return result, fmt.Errorf("record committed active account %q: %w", result.TargetAccountID, activeErr) - } - } - if c.recovery != nil && result.TransitionID != "" { - if releaseErr := c.recovery.OnTransitionCommitted(ctx, recovery.TransitionCommit{ - TransitionID: result.TransitionID, - RuntimeID: result.RuntimeID, - TargetAccountID: result.TargetAccountID, - ExpectedGeneration: result.ExpectedGeneration, - FinalGeneration: result.FinalGeneration, - Outcome: string(result.Outcome), - }); releaseErr != nil { - return result, releaseErr - } - } - } - return result, err -} - -// Reconcile delegates an uncertain transition to the coordinator. -func (c *Controller) Reconcile(ctx context.Context, transitionID string) (*transitions.TransitionResult, error) { - if c == nil { - return nil, errors.New("nil controller") - } - coordinator := c.Coordinator() - if coordinator == nil { - return nil, ErrRuntimeNotConnected - } - result, err := coordinator.Reconcile(ctx, transitionID) - if result != nil && result.Outcome == transitions.TransitionCommitted { - if c.registry != nil && result.TargetAccountID != "" { - if activeErr := c.registry.SetActive(result.TargetAccountID); activeErr != nil { - return result, fmt.Errorf("record reconciled active account %q: %w", result.TargetAccountID, activeErr) - } - } - if c.recovery != nil && result.TransitionID != "" { - if releaseErr := c.recovery.OnTransitionCommitted(ctx, recovery.TransitionCommit{ - TransitionID: result.TransitionID, - RuntimeID: result.RuntimeID, - TargetAccountID: result.TargetAccountID, - ExpectedGeneration: result.ExpectedGeneration, - FinalGeneration: result.FinalGeneration, - Outcome: string(result.Outcome), - }); releaseErr != nil { - return result, releaseErr - } - } - } - return result, err -} - -// RuntimeState performs one explicit runtime read for a status or operator -// command. It does not infer identity from the deployed auth file. -func (c *Controller) RuntimeState(ctx context.Context) (runtime.RuntimeState, error) { - if c == nil { - return runtime.RuntimeState{}, errors.New("nil controller") - } - client := c.RuntimeClient() - if client == nil { - return runtime.RuntimeState{}, ErrRuntimeNotConnected - } - return client.GetRuntimeState(ctx) -} - -// Close stops event processing and closes the attached transport. It is -// idempotent and does not delete any controller state or donor checkout. -func (c *Controller) Close() error { - if c == nil { - return nil - } - var closeErr error - c.closeOnce.Do(func() { - c.StopEventLoop() - closeErr = c.detachRuntime() - }) - return closeErr -} - -func (c *Controller) detachRuntime() error { - return c.detachRuntimeWithReason("controller closed runtime connection") -} - -func (c *Controller) detachRuntimeWithReason(reason string) error { - c.StopAutomationLoop() - c.mu.Lock() - client := c.runtime - runtimeID := c.runtimeID - c.runtime = nil - c.runtimeFacade = nil - c.coordinator = nil - c.runtimeID = "" - c.protocolVersion = 0 - c.mu.Unlock() - if c.recovery != nil { - c.recovery.SetRuntime(nil) - } - // Do not leave an account manager pointing at a closed runtime client. - c.SetAuthService(nil) - if client == nil { - return nil - } - if runtimeID != "" && c.journal != nil { - if err := c.journal.Append(journal.Event{Type: journal.RuntimeDisconnected, RuntimeID: runtimeID, Reason: reason}); err != nil { - closeErr := client.Close() - return errors.Join(closeErr, err) - } - } - return client.Close() -} - -// clientRuntime delegates the transition surface while intentionally not -// implementing RuntimeEvents. Controller's single event loop calls -// Coordinator.HandleEvent, and the coordinator retains state polling as a -// fallback for transports with delayed notifications. -type clientRuntime struct{ client *runtime.Client } - -func (r *clientRuntime) GetRuntimeState(ctx context.Context) (runtime.RuntimeState, error) { - return r.client.GetRuntimeState(ctx) -} -func (r *clientRuntime) PrepareAuthTransition(ctx context.Context, params runtime.AuthTransitionParams) (runtime.TransitionResult, error) { - return r.client.PrepareAuthTransition(ctx, params) -} -func (r *clientRuntime) CommitAuthTransition(ctx context.Context, params runtime.AuthTransitionParams) (runtime.TransitionResult, error) { - return r.client.CommitAuthTransition(ctx, params) -} -func (r *clientRuntime) CancelAuthTransition(ctx context.Context, params runtime.CancelAuthTransitionParams) (runtime.TransitionResult, error) { - return r.client.CancelAuthTransition(ctx, params) -} -func (r *clientRuntime) GetIdentity(ctx context.Context) (runtime.Identity, error) { - return r.client.GetIdentity(ctx) -} -func (r *clientRuntime) GetAuthGeneration(ctx context.Context) (runtime.AuthGenerationResult, error) { - return r.client.GetAuthGeneration(ctx) -} -func (r *clientRuntime) ReadAuthSnapshot(ctx context.Context) (runtime.NativeAuthSnapshotResult, error) { - return r.client.ReadAuthSnapshot(ctx) -} - -// snapshotProvider exposes already-ingested runtime state through the common -// provider router. It never invents capacity and never performs a second -// active-account network probe. -type snapshotProvider struct{ store *telemetry.StateStore } - -func (p snapshotProvider) Snapshot(_ context.Context, accountID string) (telemetry.SnapshotResult, error) { - if p.store == nil { - err := errors.New("telemetry state store is nil") - return telemetry.UnusableSnapshot(err), err - } - snapshot, ok := p.store.Get(accountID) - if !ok { - err := fmt.Errorf("no runtime telemetry snapshot for %q", accountID) - return telemetry.UnusableSnapshot(err), err - } - return telemetry.UsableSnapshot(snapshot), nil -} - -func isPermissionMetadataError(err error) bool { - if err == nil { - return false - } - // chmod is advisory on Windows; unlike the domain packages we do not need - // platform-specific syscall handling in the composition root. - return os.IsPermission(err) && goruntime.GOOS == "windows" -} diff --git a/controller/app/auth_service.go b/controller/app/auth_service.go deleted file mode 100644 index db0d27f6..00000000 --- a/controller/app/auth_service.go +++ /dev/null @@ -1,68 +0,0 @@ -package app - -import ( - "context" - "errors" - - "codexmarathon/controller/internal/accounts" - "codexmarathon/controller/internal/credentials" - "codexmarathon/controller/internal/runtime" -) - -// RuntimeAuthService adapts the embedded Codex runtime's native login and -// AuthManager refresh calls to the controller account manager. The client -// keeps auth material as an opaque in-memory JSON-RPC value; this adapter never -// logs or exposes it and the manager persists it only in its protected vault. -type RuntimeAuthService struct { - Client *runtime.Client -} - -// NewRuntimeAuthService constructs the direct controller/runtime auth seam. -// The runtime client must already have completed protocol negotiation. -func NewRuntimeAuthService(client *runtime.Client) RuntimeAuthService { - return RuntimeAuthService{Client: client} -} - -func (s RuntimeAuthService) Login(ctx context.Context, request accounts.LoginRequest) (accounts.LoginResult, error) { - if s.Client == nil { - return accounts.LoginResult{}, accounts.ErrAuthServiceUnavailable - } - result, err := s.Client.LoginAccount(ctx, runtime.NativeLoginParams{ - AccountID: request.AccountID, - Alias: request.Alias, - Overwrite: request.Overwrite, - }) - if err != nil { - return accounts.LoginResult{}, err - } - return accounts.LoginResult{ - AccountID: result.AccountID, - Alias: result.Alias, - AuthJSON: result.AuthJSON, - Metadata: result.Metadata, - }, nil -} - -func (s RuntimeAuthService) Refresh(ctx context.Context, request accounts.RefreshRequest) (credentials.TokenSet, error) { - if s.Client == nil { - return credentials.TokenSet{}, accounts.ErrAuthServiceUnavailable - } - result, err := s.Client.RefreshAccount(ctx, runtime.NativeRefreshParams{ - AccountID: request.AccountID, - AuthJSON: request.AuthJSON, - }) - if err != nil { - return credentials.TokenSet{}, err - } - if result.AccessToken == "" && result.IDToken == "" && result.RefreshToken == "" { - return credentials.TokenSet{}, errors.New("native refresh returned no updated token fields") - } - return credentials.TokenSet{ - AccessToken: result.AccessToken, - IDToken: result.IDToken, - RefreshToken: result.RefreshToken, - AccountID: result.AccountID, - }, nil -} - -var _ accounts.AuthService = RuntimeAuthService{} diff --git a/controller/app/automation.go b/controller/app/automation.go deleted file mode 100644 index 84c2607b..00000000 --- a/controller/app/automation.go +++ /dev/null @@ -1,105 +0,0 @@ -package app - -import ( - "context" - "errors" - - "codexmarathon/controller/internal/automation" - "codexmarathon/controller/internal/policy" - "codexmarathon/controller/internal/quota" - "codexmarathon/controller/internal/telemetry" -) - -// RegisterUsageProvider attaches the quota source for one configured account. -// Runtime-backed accounts can use the snapshot provider installed by the -// runtime event path; inactive profiles should register a quota.Provider that -// reads its opaque credential snapshot from the controller vault. -func (c *Controller) RegisterUsageProvider(accountID string, provider telemetry.UsageProvider) error { - if c == nil || c.router == nil { - return errors.New("usage router is unavailable") - } - return c.router.Register(accountID, provider) -} - -// NewAutomationLoop composes the event-driven quota policy with the account -// registry and transition coordinator. The returned loop is not started; the -// caller owns its event channel and goroutine lifecycle. -func (c *Controller) NewAutomationLoop() (*automation.Loop, error) { - return c.NewAutomationLoopWithTransition(nil) -} - -// NewAutomationLoopWithTransition composes the event-driven quota policy with -// the account registry and a caller-supplied transition boundary. The -// installed-Codex companion uses this seam because its transition is owned by -// the installed app-server/process supervisor rather than the embedded -// Marathon runtime coordinator. A nil callback preserves the normal -// embedded-runtime behavior. -func (c *Controller) NewAutomationLoopWithTransition(transition automation.TransitionFunc) (*automation.Loop, error) { - if c == nil { - return nil, errors.New("controller is nil") - } - if c.registry == nil || c.router == nil || c.policy == nil { - return nil, errors.New("controller automation dependencies are unavailable") - } - if err := c.ensureInactiveQuotaProviders(); err != nil { - return nil, err - } - if transition == nil { - transition = func(ctx context.Context, accountID string) error { - _, err := c.RequestTransition(ctx, accountID) - return err - } - } - return automation.New(automation.Config{ - Router: c.router, - Policy: c.policy, - FreshnessTTL: c.config.TelemetryTTL, - Thresholds: policy.DefaultThresholds(), - Accounts: automation.AccountSource{ - IDs: func() ([]string, error) { - registered, err := c.registry.List() - if err != nil { - return nil, err - } - ids := make([]string, 0, len(registered)) - for _, account := range registered { - ids = append(ids, account.ID) - } - return automation.AccountIDs(ids), nil - }, - Active: func() (string, error) { - return c.registry.ActiveID() - }, - }, - Transition: transition, - OnError: c.reportEventError, - }), nil -} - -// ensureInactiveQuotaProviders installs the embedded donor-compatible quota -// provider for accounts that do not already have an authoritative runtime -// provider. Existing runtime snapshot providers are deliberately retained. -func (c *Controller) ensureInactiveQuotaProviders() error { - registered, err := c.registry.List() - if err != nil { - return err - } - known := make(map[string]struct{}, len(c.router.Accounts())) - for _, accountID := range c.router.Accounts() { - known[accountID] = struct{}{} - } - provider := quota.Provider{ - Source: quota.VaultCredentialSource{Vault: c.vault}, - Client: quota.Client{}, - Model: quota.DefaultModel, - } - for _, account := range registered { - if _, exists := known[account.ID]; exists { - continue - } - if err := c.router.Register(account.ID, provider); err != nil { - return err - } - } - return nil -} diff --git a/controller/app/companion.go b/controller/app/companion.go deleted file mode 100644 index 31a510c8..00000000 --- a/controller/app/companion.go +++ /dev/null @@ -1,102 +0,0 @@ -package app - -import ( - "context" - - "codexmarathon/controller/internal/companion" -) - -// CompanionConfig configures a CodexMarathon wrapper around an already -// installed Codex CLI. Command must point at that CLI; no app-server or -// Marathon runtime is started by this supervisor. -type CompanionConfig = companion.Config - -// CompanionResumeTarget identifies the thread to reopen after an auth -// transition. Set ThreadID for an exact conversation or Last to use Codex's -// native most-recent-session selection. -type CompanionResumeTarget = companion.ResumeTarget - -// CompanionStatus is a secret-free process lifecycle snapshot. -type CompanionStatus = companion.Status - -const ( - CompanionStopped = companion.StateStopped - CompanionStarting = companion.StateStarting - CompanionRunning = companion.StateRunning - CompanionStopping = companion.StateStopping - CompanionFailed = companion.StateFailed -) - -var ( - ErrCompanionAlreadyStarted = companion.ErrAlreadyStarted - ErrCompanionNotStarted = companion.ErrNotStarted - ErrCompanionResumeMissing = companion.ErrResumeTargetMissing - ErrCompanionResumeInvalid = companion.ErrResumeTargetInvalid -) - -// CompanionSupervisor manages only the Codex process it launched. It is the -// controlled-restart fallback for installations whose Codex version does not -// expose the live Marathon auth-reload protocol. -type CompanionSupervisor struct { - supervisor *companion.Supervisor -} - -// NewCompanionSupervisor validates a companion configuration without starting -// Codex. -func NewCompanionSupervisor(config CompanionConfig) (*CompanionSupervisor, error) { - supervisor, err := companion.NewSupervisor(config) - if err != nil { - return nil, err - } - return &CompanionSupervisor{supervisor: supervisor}, nil -} - -// ResolveInstalledCodex locates the user's installed Codex executable on PATH. -func ResolveInstalledCodex() (string, error) { return companion.ResolveInstalledCodex() } - -// IsCodexCommand conservatively checks an executable basename. -func IsCodexCommand(command string) bool { return companion.IsCodexCommand(command) } - -// BuildCompanionResumeCommand constructs the argv passed to the installed -// Codex CLI for a controlled conversation resume. -func BuildCompanionResumeCommand(command []string, target CompanionResumeTarget) ([]string, error) { - return companion.BuildResumeCommand(command, target) -} - -func (s *CompanionSupervisor) Start() error { - if s == nil { - return companion.ErrNilSupervisor - } - return s.supervisor.Start() -} - -func (s *CompanionSupervisor) Stop(ctx context.Context) error { - if s == nil { - return nil - } - return s.supervisor.Stop(ctx) -} - -// RestartAndResume waits for the old Codex process to exit, invokes the -// caller's atomic credential deployment hook, and launches the installed CLI -// with `resume ` or `resume --last`. -func (s *CompanionSupervisor) RestartAndResume(ctx context.Context, target CompanionResumeTarget, deployCredentials func(context.Context) error) error { - if s == nil { - return companion.ErrNilSupervisor - } - return s.supervisor.RestartAndResume(ctx, target, deployCredentials) -} - -func (s *CompanionSupervisor) Wait(ctx context.Context) error { - if s == nil { - return companion.ErrNilSupervisor - } - return s.supervisor.Wait(ctx) -} - -func (s *CompanionSupervisor) Status() CompanionStatus { - if s == nil { - return CompanionStatus{State: companion.StateFailed, LastError: companion.ErrNilSupervisor.Error()} - } - return s.supervisor.Status() -} diff --git a/controller/app/control.go b/controller/app/control.go deleted file mode 100644 index c1661603..00000000 --- a/controller/app/control.go +++ /dev/null @@ -1,597 +0,0 @@ -package app - -// This file exposes the controller-owned local control boundary used by an -// embedded Codex TUI. The TUI never receives credential bytes and never -// writes auth.json. It sends an account target to this server; the -// controller resolves aliases and delegates the complete transition to the -// existing coordinator (or to the installed-Codex session callback). - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/url" - "path/filepath" - goruntime "runtime" - "strings" - "sync" - "time" - - "codexmarathon/controller/internal/runtime" - "codexmarathon/controller/internal/transitions" -) - -const ( - // ControllerProtocolVersion is the local TUI/controller protocol version. - // It is deliberately separate from the controller/runtime protocol: the - // controller remains the only authority allowed to deploy credentials. - ControllerProtocolVersion = 1 - - // ControllerControlEnv names the optional endpoint hint inherited by a - // Codex process launched by the companion. A client may also derive the - // default endpoint from the controller state directory. - ControllerControlEnv = "CODEXMARATHON_CONTROL" - - ControllerMethodNegotiate = "protocol/negotiate" - ControllerMethodStatus = "marathon/status" - ControllerMethodSwitch = "marathon/switch" - - defaultControlMaxLineBytes = 4 << 20 - defaultControlSwitchWait = 2 * time.Minute -) - -var ( - ErrControlNotStarted = errors.New("controller control server is not started") - ErrControlAlreadyStarted = errors.New("controller control server is already started") - ErrControlProtocol = errors.New("invalid controller control protocol message") - ErrControlMethod = errors.New("unknown controller control method") - ErrControlNotNegotiated = errors.New("controller control protocol is not negotiated") - ErrControlTargetRequired = errors.New("account ID or alias is required") - ErrControlAlreadyRunning = errors.New("controller control endpoint is already in use") - ErrUnsafeControlEndpoint = errors.New("controller control endpoint is not user-scoped") - ErrControlUnsupportedOS = errors.New("controller control endpoint is unsupported on this platform") -) - -// DefaultControllerControlEndpoint returns the endpoint used by the -// companion-owned control server. It lives beside the runtime socket but has -// a separate filename and authority. -func DefaultControllerControlEndpoint(stateDir string) (string, error) { - if strings.TrimSpace(stateDir) == "" { - return "", errors.New("state directory is empty") - } - stateDir, err := filepath.Abs(stateDir) - if err != nil { - return "", fmt.Errorf("resolve state directory: %w", err) - } - if goruntime.GOOS == "windows" { - return "pipe://./pipe/" + userScopedPipeName() + "-control", nil - } - return "unix://" + filepath.Join(stateDir, "controller.sock"), nil -} - -// ValidateControllerControlEndpoint keeps the controller listener inside the -// same user-owned boundary as the runtime listener. TCP is rejected because a -// TUI request can initiate credential deployment. -func ValidateControllerControlEndpoint(endpoint, stateDir string) error { - if strings.TrimSpace(endpoint) == "" { - return fmt.Errorf("%w: endpoint is empty", ErrUnsafeControlEndpoint) - } - if goruntime.GOOS == "windows" { - parsed, err := url.Parse(endpoint) - if err != nil || (strings.ToLower(parsed.Scheme) != "pipe" && strings.ToLower(parsed.Scheme) != "npipe" && strings.ToLower(parsed.Scheme) != "namedpipe") { - return fmt.Errorf("%w: controller endpoint must be a named pipe", ErrUnsafeControlEndpoint) - } - expected := `\\.\pipe\` + userScopedPipeName() + "-control" - if namedPipePath(parsed) != expected { - return fmt.Errorf("%w: named pipe must use the current user's controller endpoint", ErrUnsafeControlEndpoint) - } - return nil - } - if err := ValidateManagedEndpoint(endpoint, stateDir); err != nil { - return fmt.Errorf("%w: %v", ErrUnsafeControlEndpoint, err) - } - return nil -} - -// ControlSwitchResult is a secret-free result suitable for a TUI status -// surface. Mode is "runtime", "live-reload", or "restart-resume". -// TransitionID is present for the Marathon runtime coordinator path and lets -// a caller reconcile an uncertain result later. -type ControlSwitchResult struct { - AccountID string `json:"account_id"` - Alias string `json:"alias,omitempty"` - Outcome string `json:"outcome"` - Mode string `json:"mode,omitempty"` - TransitionID string `json:"transition_id,omitempty"` - RuntimeID string `json:"runtime_id,omitempty"` - ExpectedGeneration uint64 `json:"expected_generation,omitempty"` - FinalGeneration uint64 `json:"final_generation,omitempty"` - IdentityVerified bool `json:"identity_verified"` - Reason string `json:"reason,omitempty"` -} - -// ControlAccount is the non-secret account view exposed to the TUI. -type ControlAccount struct { - AccountID string `json:"account_id"` - Alias string `json:"alias,omitempty"` - Active bool `json:"active"` - CredentialPresent bool `json:"credential_present"` - CredentialHealth string `json:"credential_health,omitempty"` - LastTelemetryAt *time.Time `json:"last_telemetry_at,omitempty"` - TelemetrySource string `json:"telemetry_source,omitempty"` -} - -// ControlRuntimeStatus reports only runtime identity and transition metadata. -// It intentionally has no auth snapshot or provider token fields. -type ControlRuntimeStatus struct { - Connected bool `json:"connected"` - RuntimeID string `json:"runtime_id,omitempty"` - AccountID string `json:"account_id,omitempty"` - AuthGeneration uint64 `json:"auth_generation,omitempty"` - ActiveTurnCount int `json:"active_turn_count"` - PendingTransition *runtime.PendingTransition `json:"pending_transition,omitempty"` -} - -// ControlStatus is the status response consumed by a future /marathon UI. -// Runtime is nil when the controller is running in installed-Codex mode and -// no Marathon runtime protocol peer is attached. -type ControlStatus struct { - ProtocolVersion int `json:"protocol_version"` - Endpoint string `json:"endpoint"` - RuntimeConnected bool `json:"runtime_connected"` - ActiveAccountID string `json:"active_account_id,omitempty"` - Accounts []ControlAccount `json:"accounts"` - Runtime *ControlRuntimeStatus `json:"runtime,omitempty"` - Transition *transitions.State `json:"transition,omitempty"` -} - -// ControlSwitchHandler lets the installed-Codex companion reuse the same -// local API while retaining its process ownership and restart/resume logic. -// The handler receives a canonical account ID, never an untrusted alias. -type ControlSwitchHandler func(context.Context, string) (ControlSwitchResult, error) - -// ControlStatusHandler can fill the runtime portion of status for an -// installed app-server session. It is optional; the Marathon runtime path -// derives status from Controller.RuntimeState automatically. -type ControlStatusHandler func(context.Context) (*ControlRuntimeStatus, error) - -// ControllerControlServerConfig configures the local TUI/controller server. -type ControllerControlServerConfig struct { - Endpoint string - Switch ControlSwitchHandler - Status ControlStatusHandler - MaxLineBytes int -} - -// ControllerControlServer is a user-scoped newline-delimited JSON-RPC -// endpoint. The listener is intentionally separate from the runtime socket: -// only this server can resolve aliases and initiate a controller transition. -type ControllerControlServer struct { - controller *Controller - config ControllerControlServerConfig - endpoint string - - mu sync.Mutex - listener net.Listener - cancel context.CancelFunc - done chan struct{} - closeOnce sync.Once - connWG sync.WaitGroup - closeErr error -} - -// NewControllerControlServer validates and composes a server without opening -// a socket. Start owns the listener side effect. -func NewControllerControlServer(controller *Controller, config ControllerControlServerConfig) (*ControllerControlServer, error) { - if controller == nil { - return nil, errors.New("controller is nil") - } - if strings.TrimSpace(config.Endpoint) == "" { - endpoint, err := DefaultControllerControlEndpoint(controller.Config().StateDir) - if err != nil { - return nil, err - } - config.Endpoint = endpoint - } - if err := ValidateControllerControlEndpoint(config.Endpoint, controller.Config().StateDir); err != nil { - return nil, err - } - if config.MaxLineBytes <= 0 { - config.MaxLineBytes = defaultControlMaxLineBytes - } - return &ControllerControlServer{ - controller: controller, - config: config, - endpoint: config.Endpoint, - }, nil -} - -// Endpoint returns the configured user-scoped endpoint. -func (s *ControllerControlServer) Endpoint() string { - if s == nil { - return "" - } - return s.endpoint -} - -// Start opens the listener and starts accepting TUI requests. Connections -// are handled independently, while the transition coordinator still rejects -// competing state-changing requests with its durable in-progress guard. -func (s *ControllerControlServer) Start(ctx context.Context) error { - if s == nil { - return errors.New("nil controller control server") - } - if ctx == nil { - ctx = context.Background() - } - if err := ctx.Err(); err != nil { - return err - } - s.mu.Lock() - if s.listener != nil { - s.mu.Unlock() - return ErrControlAlreadyStarted - } - listener, socketPath, err := listenControllerControlEndpoint(s.endpoint, s.controller.Config().StateDir) - if err != nil { - s.mu.Unlock() - return err - } - serverCtx, cancel := context.WithCancel(ctx) - s.listener = listener - s.cancel = cancel - s.done = make(chan struct{}) - s.mu.Unlock() - - go s.acceptLoop(serverCtx, socketPath) - return nil -} - -// Close stops the listener and waits for in-flight requests. It is safe to -// call more than once and removes only the socket created by this server. -func (s *ControllerControlServer) Close() error { - if s == nil { - return nil - } - s.closeOnce.Do(func() { - s.mu.Lock() - listener := s.listener - cancel := s.cancel - done := s.done - s.mu.Unlock() - if cancel != nil { - cancel() - } - if listener != nil { - _ = listener.Close() - } - if done != nil { - <-done - } - s.connWG.Wait() - s.mu.Lock() - s.closeErr = removeControllerControlEndpoint(s.endpoint) - s.mu.Unlock() - }) - s.mu.Lock() - err := s.closeErr - s.mu.Unlock() - return err -} - -func (s *ControllerControlServer) acceptLoop(ctx context.Context, socketPath string) { - defer func() { - s.mu.Lock() - done := s.done - s.mu.Unlock() - if done != nil { - close(done) - } - _ = socketPath // retained for platform-specific listener cleanup. - }() - s.mu.Lock() - listener := s.listener - s.mu.Unlock() - for { - conn, err := listener.Accept() - if err != nil { - if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { - return - } - continue - } - s.connWG.Add(1) - go func() { - defer s.connWG.Done() - _ = s.serveConn(ctx, conn) - }() - } -} - -func (s *ControllerControlServer) serveConn(ctx context.Context, conn net.Conn) error { - defer conn.Close() - reader := bufio.NewReader(conn) - negotiated := false - for { - line, err := readControlLine(reader, s.config.MaxLineBytes) - if len(line) > 0 { - response := s.handleLine(ctx, line, &negotiated) - if _, writeErr := conn.Write(response); writeErr != nil { - return writeErr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func readControlLine(reader *bufio.Reader, maxBytes int) ([]byte, error) { - line, err := reader.ReadBytes('\n') - if len(line) > maxBytes { - return nil, fmt.Errorf("control frame exceeds %d bytes", maxBytes) - } - return bytesTrimSpace(line), err -} - -// bytesTrimSpace avoids retaining a bufio.Reader buffer in the request and -// accepts the final JSON object without requiring a trailing newline. -func bytesTrimSpace(value []byte) []byte { - return []byte(strings.TrimSpace(string(value))) -} - -type controlRequest struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Method string `json:"method"` - Params json.RawMessage `json:"params"` -} - -type controlResponse struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Result any `json:"result,omitempty"` - Error *controlError `json:"error,omitempty"` -} - -type controlError struct { - Code int `json:"code"` - Message string `json:"message"` - Data any `json:"data,omitempty"` -} - -type controlNegotiateParams struct { - SupportedVersions []int `json:"supported_versions"` -} - -type controlNegotiateResult struct { - ProtocolVersion int `json:"protocol_version"` - ServerVersions []int `json:"server_versions"` -} - -type controlSwitchParams struct { - Target string `json:"target"` - TimeoutMS int `json:"timeout_ms,omitempty"` -} - -func (s *ControllerControlServer) handleLine(ctx context.Context, line []byte, negotiated *bool) []byte { - request := controlRequest{} - if err := json.Unmarshal(line, &request); err != nil { - return marshalControlResponse(nil, nil, &controlError{Code: -32700, Message: ErrControlProtocol.Error()}) - } - if request.JSONRPC != "2.0" || len(request.ID) == 0 || string(request.ID) == "null" || strings.TrimSpace(request.Method) == "" { - return marshalControlResponse(request.ID, nil, &controlError{Code: -32600, Message: ErrControlProtocol.Error()}) - } - if request.Method == ControllerMethodNegotiate { - var params controlNegotiateParams - if err := json.Unmarshal(request.Params, ¶ms); err != nil || !containsVersion(params.SupportedVersions, ControllerProtocolVersion) { - return marshalControlResponse(request.ID, nil, &controlError{Code: -32602, Message: "supported_versions must include controller protocol v1"}) - } - *negotiated = true - return marshalControlResponse(request.ID, controlNegotiateResult{ProtocolVersion: ControllerProtocolVersion, ServerVersions: []int{ControllerProtocolVersion}}, nil) - } - if !*negotiated { - return marshalControlResponse(request.ID, nil, &controlError{Code: -32001, Message: ErrControlNotNegotiated.Error()}) - } - - switch request.Method { - case ControllerMethodStatus: - status, err := s.status(ctx) - if err != nil { - return marshalControlResponse(request.ID, nil, controlServerError(err)) - } - return marshalControlResponse(request.ID, status, nil) - case ControllerMethodSwitch: - result, err := s.switchAccount(ctx, request.Params) - if err != nil { - return marshalControlResponse(request.ID, nil, controlServerError(err)) - } - return marshalControlResponse(request.ID, result, nil) - default: - return marshalControlResponse(request.ID, nil, &controlError{Code: -32601, Message: ErrControlMethod.Error()}) - } -} - -func controlServerError(err error) *controlError { - if err == nil { - return nil - } - return &controlError{Code: -32000, Message: err.Error()} -} - -func marshalControlResponse(id json.RawMessage, result any, err *controlError) []byte { - if len(id) == 0 { - id = json.RawMessage("null") - } - response, marshalErr := json.Marshal(controlResponse{JSONRPC: "2.0", ID: id, Result: result, Error: err}) - if marshalErr != nil { - response = []byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"failed to encode controller response"}}`) - } - return append(response, '\n') -} - -func containsVersion(versions []int, want int) bool { - for _, version := range versions { - if version == want { - return true - } - } - return false -} - -func (s *ControllerControlServer) status(ctx context.Context) (ControlStatus, error) { - if s == nil || s.controller == nil { - return ControlStatus{}, errors.New("controller is unavailable") - } - profiles, err := s.controller.AccountManager().List() - if err != nil { - return ControlStatus{}, err - } - activeID, err := s.controller.Registry().ActiveID() - if err != nil { - return ControlStatus{}, err - } - status := ControlStatus{ - ProtocolVersion: ControllerProtocolVersion, - Endpoint: s.endpoint, - RuntimeConnected: s.controller.RuntimeConnected(), - ActiveAccountID: activeID, - Accounts: make([]ControlAccount, 0, len(profiles)), - } - for _, profile := range profiles { - status.Accounts = append(status.Accounts, ControlAccount{ - AccountID: profile.AccountID, - Alias: profile.Alias, - Active: profile.Active, - CredentialPresent: profile.CredentialPresent, - CredentialHealth: string(profile.CredentialHealth), - LastTelemetryAt: profile.LastTelemetryAt, - TelemetrySource: profile.TelemetrySource, - }) - } - if s.config.Status != nil { - runtimeStatus, statusErr := s.config.Status(ctx) - if statusErr != nil { - return ControlStatus{}, statusErr - } - status.Runtime = runtimeStatus - } else if s.controller.RuntimeConnected() { - state, stateErr := s.controller.RuntimeState(ctx) - if stateErr != nil { - return ControlStatus{}, stateErr - } - runtimeStatus := &ControlRuntimeStatus{ - Connected: true, - RuntimeID: state.Identity.RuntimeID, - AccountID: optionalAccountID(state.Identity.AccountID), - AuthGeneration: state.Identity.AuthGeneration, - ActiveTurnCount: state.ActiveTurnCount, - PendingTransition: state.PendingTransition, - } - status.Runtime = runtimeStatus - } - if state, ok := s.controller.Coordinator().State(); ok { - status.Transition = &state - } - return status, nil -} - -func (s *ControllerControlServer) switchAccount(parent context.Context, raw json.RawMessage) (ControlSwitchResult, error) { - params := controlSwitchParams{} - if len(raw) != 0 && string(raw) != "null" { - if err := json.Unmarshal(raw, ¶ms); err != nil { - return ControlSwitchResult{}, fmt.Errorf("decode switch params: %w", err) - } - } - target := strings.TrimSpace(params.Target) - if target == "" { - return ControlSwitchResult{}, ErrControlTargetRequired - } - account, err := s.resolveAccountTarget(target) - if err != nil { - return ControlSwitchResult{}, err - } - handler := s.config.Switch - if handler == nil { - handler = func(ctx context.Context, accountID string) (ControlSwitchResult, error) { - transition, requestErr := s.controller.RequestTransition(ctx, accountID) - if transition == nil { - return ControlSwitchResult{}, requestErr - } - result := ControlSwitchResult{ - AccountID: accountID, - Outcome: string(transition.Outcome), - Mode: "runtime", - TransitionID: transition.TransitionID, - RuntimeID: transition.RuntimeID, - ExpectedGeneration: transition.ExpectedGeneration, - FinalGeneration: transition.FinalGeneration, - IdentityVerified: transition.Outcome == transitions.TransitionCommitted, - Reason: transition.Reason, - } - if requestErr != nil && transition.Outcome != transitions.TransitionUncertain && transition.Outcome != transitions.TransitionRejected { - return result, requestErr - } - return result, nil - } - } - requestCtx := parent - if requestCtx == nil { - requestCtx = context.Background() - } - wait := defaultControlSwitchWait - if params.TimeoutMS > 0 { - wait = time.Duration(params.TimeoutMS) * time.Millisecond - } - if wait <= 0 { - return ControlSwitchResult{}, errors.New("switch timeout must be positive") - } - requestCtx, cancel := context.WithTimeout(requestCtx, wait) - defer cancel() - result, err := handler(requestCtx, account.AccountID) - if result.AccountID == "" { - result.AccountID = account.AccountID - } - result.Alias = account.Alias - return result, err -} - -func (s *ControllerControlServer) resolveAccountTarget(target string) (ControlAccount, error) { - profiles, err := s.controller.AccountManager().List() - if err != nil { - return ControlAccount{}, err - } - var match *ControlAccount - for _, profile := range profiles { - candidate := ControlAccount{ - AccountID: profile.AccountID, - Alias: profile.Alias, - Active: profile.Active, - CredentialPresent: profile.CredentialPresent, - CredentialHealth: string(profile.CredentialHealth), - LastTelemetryAt: profile.LastTelemetryAt, - TelemetrySource: profile.TelemetrySource, - } - if profile.AccountID == target { - return candidate, nil - } - if profile.Alias == target { - if match != nil { - return ControlAccount{}, fmt.Errorf("account alias %q is ambiguous", target) - } - copy := candidate - match = © - } - } - if match == nil { - return ControlAccount{}, fmt.Errorf("account %q was not found", target) - } - return *match, nil -} diff --git a/controller/app/control_test.go b/controller/app/control_test.go deleted file mode 100644 index c6de88dc..00000000 --- a/controller/app/control_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package app - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "net" - "path/filepath" - "strings" - "testing" - "time" - - "codexmarathon/controller/internal/accounts" -) - -func TestControllerControlServerNegotiatesStatusAndResolvesAlias(t *testing.T) { - stateDir := t.TempDir() - controller, err := New(Config{ - StateDir: stateDir, - AuthPath: filepath.Join(stateDir, "auth.json"), - }) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if err := controller.EnsureLayout(); err != nil { - t.Fatalf("EnsureLayout() error = %v", err) - } - for _, account := range []struct { - id string - alias string - token string - }{ - {id: "acct-a", alias: "personal", token: "token-a"}, - {id: "acct-b", alias: "work", token: "token-b"}, - } { - _, err := controller.AccountManager().Import(context.Background(), accounts.ImportRequest{ - AccountID: account.id, - Alias: account.alias, - AuthJSON: []byte(fmt.Sprintf(`{"tokens":{"account_id":%q,"access_token":%q}}`, account.id, account.token)), - }) - if err != nil { - t.Fatalf("Import(%s) error = %v", account.id, err) - } - } - - var switchedTo string - server, err := NewControllerControlServer(controller, ControllerControlServerConfig{ - Switch: func(_ context.Context, accountID string) (ControlSwitchResult, error) { - switchedTo = accountID - return ControlSwitchResult{AccountID: accountID, Outcome: "committed", Mode: "test", IdentityVerified: true}, nil - }, - }) - if err != nil { - t.Fatalf("NewControllerControlServer() error = %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := server.Start(ctx); err != nil { - t.Fatalf("Start() error = %v", err) - } - defer func() { _ = server.Close() }() - - path := strings.TrimPrefix(server.Endpoint(), "unix://") - conn, err := net.Dial("unix", path) - if err != nil { - t.Fatalf("dial controller endpoint: %v", err) - } - defer conn.Close() - reader := bufio.NewReader(conn) - - writeControlRequest(t, conn, `{"jsonrpc":"2.0","id":"1","method":"protocol/negotiate","params":{"supported_versions":[1]}}`) - var negotiated struct { - Result controlNegotiateResult `json:"result"` - } - readControlResponse(t, conn, reader, &negotiated) - if negotiated.Result.ProtocolVersion != ControllerProtocolVersion { - t.Fatalf("protocol version = %d, want %d", negotiated.Result.ProtocolVersion, ControllerProtocolVersion) - } - - writeControlRequest(t, conn, `{"jsonrpc":"2.0","id":"2","method":"marathon/status","params":{}}`) - var status struct { - Result ControlStatus `json:"result"` - } - readControlResponse(t, conn, reader, &status) - if status.Result.ActiveAccountID != "acct-a" { - t.Fatalf("active account = %q, want acct-a", status.Result.ActiveAccountID) - } - if len(status.Result.Accounts) != 2 || status.Result.Accounts[1].Alias != "work" { - t.Fatalf("status accounts = %#v", status.Result.Accounts) - } - - writeControlRequest(t, conn, `{"jsonrpc":"2.0","id":"3","method":"marathon/switch","params":{"target":"work","timeout_ms":1000}}`) - var switched struct { - Result ControlSwitchResult `json:"result"` - } - readControlResponse(t, conn, reader, &switched) - if switchedTo != "acct-b" { - t.Fatalf("switch target = %q, want acct-b", switchedTo) - } - if switched.Result.AccountID != "acct-b" || switched.Result.Alias != "work" || switched.Result.Outcome != "committed" { - t.Fatalf("switch result = %#v", switched.Result) - } -} - -func TestControllerControlServerRequiresNegotiation(t *testing.T) { - controller, err := New(Config{StateDir: t.TempDir(), AuthPath: filepath.Join(t.TempDir(), "auth.json")}) - if err != nil { - t.Fatal(err) - } - server, err := NewControllerControlServer(controller, ControllerControlServerConfig{}) - if err != nil { - t.Fatal(err) - } - response := server.handleLine(context.Background(), []byte(`{"jsonrpc":"2.0","id":"1","method":"marathon/status","params":{}}`), new(bool)) - var decoded struct { - Error *controlError `json:"error"` - } - if err := json.Unmarshal(response, &decoded); err != nil { - t.Fatal(err) - } - if decoded.Error == nil || decoded.Error.Code != -32001 { - t.Fatalf("response error = %#v, want not-negotiated", decoded.Error) - } -} - -func TestControllerControlEndpointRejectsNetworkAndOutsidePaths(t *testing.T) { - stateDir := t.TempDir() - if err := ValidateControllerControlEndpoint("tcp://127.0.0.1:1", stateDir); err == nil { - t.Fatal("TCP controller endpoint was accepted") - } - outside := filepath.Join(t.TempDir(), "controller.sock") - if err := ValidateControllerControlEndpoint("unix://"+outside, stateDir); err == nil { - t.Fatal("controller endpoint outside state directory was accepted") - } - if _, err := DefaultControllerControlEndpoint(stateDir); err != nil { - t.Fatalf("DefaultControllerControlEndpoint() error = %v", err) - } -} - -func writeControlRequest(t *testing.T, conn net.Conn, request string) { - t.Helper() - if _, err := fmt.Fprintf(conn, "%s\n", request); err != nil { - t.Fatalf("write control request: %v", err) - } -} - -func readControlResponse(t *testing.T, conn net.Conn, reader *bufio.Reader, out any) { - t.Helper() - _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - line, err := reader.ReadBytes('\n') - if err != nil { - t.Fatalf("read control response: %v", err) - } - if err := json.Unmarshal(line, out); err != nil { - t.Fatalf("decode control response: %v", err) - } -} diff --git a/controller/app/control_unix.go b/controller/app/control_unix.go deleted file mode 100644 index 27752364..00000000 --- a/controller/app/control_unix.go +++ /dev/null @@ -1,78 +0,0 @@ -//go:build !windows - -package app - -import ( - "errors" - "fmt" - "net" - "net/url" - "os" - "path/filepath" - "strings" - "time" -) - -func listenControllerControlEndpoint(endpoint, stateDir string) (net.Listener, string, error) { - if err := ValidateControllerControlEndpoint(endpoint, stateDir); err != nil { - return nil, "", err - } - parsed, err := url.Parse(endpoint) - if err != nil { - return nil, "", fmt.Errorf("parse controller endpoint: %w", err) - } - if strings.ToLower(parsed.Scheme) != "unix" { - return nil, "", fmt.Errorf("%w: only Unix sockets are available on this platform", ErrControlUnsupportedOS) - } - path := filepath.Clean(filepath.FromSlash(parsed.Path)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, "", fmt.Errorf("create controller socket directory: %w", err) - } - if err := os.Chmod(filepath.Dir(path), 0o700); err != nil && !isPermissionMetadataError(err) { - return nil, "", fmt.Errorf("tighten controller socket directory: %w", err) - } - if info, statErr := os.Lstat(path); statErr == nil { - if info.Mode()&os.ModeSymlink != 0 || info.Mode()&os.ModeSocket == 0 { - return nil, "", fmt.Errorf("%w: existing endpoint is not a Unix socket", ErrUnsafeControlEndpoint) - } - probe, probeErr := net.DialTimeout("unix", path, 150*time.Millisecond) - if probeErr == nil { - _ = probe.Close() - return nil, "", ErrControlAlreadyRunning - } - if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { - return nil, "", fmt.Errorf("remove stale controller endpoint: %w", removeErr) - } - } else if !errors.Is(statErr, os.ErrNotExist) { - return nil, "", fmt.Errorf("inspect controller endpoint: %w", statErr) - } - listener, err := net.Listen("unix", path) - if err != nil { - return nil, "", fmt.Errorf("listen on controller endpoint %q: %w", path, err) - } - if err := os.Chmod(path, 0o600); err != nil { - _ = listener.Close() - _ = os.Remove(path) - return nil, "", fmt.Errorf("tighten controller endpoint permissions: %w", err) - } - return listener, path, nil -} - -func removeControllerControlEndpoint(endpoint string) error { - parsed, err := url.Parse(endpoint) - if err != nil || strings.ToLower(parsed.Scheme) != "unix" { - return nil - } - path := filepath.Clean(filepath.FromSlash(parsed.Path)) - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return err - } - if info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("%w: refusing to remove non-socket endpoint", ErrUnsafeControlEndpoint) - } - return os.Remove(path) -} diff --git a/controller/app/control_windows.go b/controller/app/control_windows.go deleted file mode 100644 index 7f646b7b..00000000 --- a/controller/app/control_windows.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build windows - -package app - -import ( - "fmt" - "net" -) - -// Named-pipe serving is intentionally left behind the same platform seam as -// the runtime connector. The Rust runtime owns the protected Windows pipe; -// a future TUI/controller build can add a DACL-backed implementation here -// without weakening the Linux Unix-socket boundary. -func listenControllerControlEndpoint(endpoint, stateDir string) (net.Listener, string, error) { - return nil, "", fmt.Errorf("%w: controller named-pipe serving is not implemented", ErrControlUnsupportedOS) -} - -func removeControllerControlEndpoint(endpoint string) error { return nil } diff --git a/controller/app/installed_runtime.go b/controller/app/installed_runtime.go deleted file mode 100644 index 143d77a0..00000000 --- a/controller/app/installed_runtime.go +++ /dev/null @@ -1,449 +0,0 @@ -package app - -// This file is the controller-side adapter for the user's installed Codex -// app-server. The custom Marathon protocol remains available through -// ConnectRuntime for the optional embedded/runtime fixture, but the product -// path uses these methods to talk to the process the user already installed. - -import ( - "context" - "crypto/sha256" - "encoding/json" - "errors" - "fmt" - "os" - "strings" - "time" - - "codexmarathon/controller/internal/automation" - "codexmarathon/controller/internal/credentials" - installedruntime "codexmarathon/controller/internal/runtime" - "codexmarathon/controller/internal/telemetry" -) - -var ( - // ErrInstalledAuthNotApplied means the installed app-server acknowledged - // account/read but did not report that the newly deployed auth was adopted. - // Callers must treat this as a failed live transition and may select the - // owned process restart fallback. - ErrInstalledAuthNotApplied = errors.New("installed Codex did not apply the new authentication") - // ErrInstalledIdentityMismatch means the installed process reported an - // identity different from the selected profile after reload. - ErrInstalledIdentityMismatch = errors.New("installed Codex identity does not match the selected account") - // ErrInstalledFallbackUnavailable means a live control operation is not - // available and no Codex process owned by the companion was supplied for a - // safe restart/resume. - ErrInstalledFallbackUnavailable = errors.New("controlled Codex restart requires a process launched by CodexMarathon") -) - -// InstalledTransitionMode records which installed-Codex boundary completed. -type InstalledTransitionMode string - -const ( - InstalledTransitionLive InstalledTransitionMode = "live-reload" - InstalledTransitionRestart InstalledTransitionMode = "restart-resume" -) - -// InstalledTransitionResult contains only transition metadata. It never -// retains auth JSON or provider token fields. -type InstalledTransitionResult struct { - Mode InstalledTransitionMode `json:"mode"` - AccountID string `json:"account_id"` - PreviousAccountID string `json:"previous_account_id,omitempty"` - Version string `json:"codex_version,omitempty"` - AuthChanged bool `json:"auth_changed"` - IdentityVerified bool `json:"identity_verified"` - ResumedThread string `json:"resumed_thread,omitempty"` - ResumedLast bool `json:"resumed_last,omitempty"` -} - -// DiscoverInstalledCodex exposes the read-only installed-Codex discovery -// used by the command layer. It runs --version/--help only and never reads -// auth.json. -func DiscoverInstalledCodex(ctx context.Context, options installedruntime.DiscoveryOptions) (installedruntime.Installation, error) { - return installedruntime.DiscoverInstalled(ctx, options) -} - -// ConnectInstalledCodex attaches to the user's private app-server control -// socket and performs the standard initialize/initialized handshake. -func ConnectInstalledCodex(ctx context.Context, installation installedruntime.Installation) (*installedruntime.InstalledClient, error) { - return installedruntime.ConnectInstalled(ctx, installation) -} - -// ConnectInstalledCodexEndpoint is the explicit endpoint form used by local -// test harnesses and operators. Network WebSockets are limited to loopback by -// the runtime client. -func ConnectInstalledCodexEndpoint(ctx context.Context, endpoint string) (*installedruntime.InstalledClient, error) { - return installedruntime.ConnectInstalledEndpoint(ctx, endpoint) -} - -// NewInstalledQuotaProvider returns a controller usage provider backed by the -// installed Codex app-server. The getter is evaluated for every observation so -// an owned restart can replace the app-server connection without leaving the -// automation loop attached to a closed client. -func NewInstalledQuotaProvider(getClient func() *installedruntime.InstalledClient) telemetry.UsageProvider { - return telemetry.ProviderFunc(func(ctx context.Context, accountID string) (telemetry.SnapshotResult, error) { - if getClient == nil { - err := errors.New("installed Codex app-server client is unavailable") - return telemetry.UnusableSnapshot(err), err - } - client := getClient() - if client == nil { - err := installedruntime.ErrAppServerUnavailable - return telemetry.UnusableSnapshot(err), err - } - raw, err := client.ReadRateLimits(ctx) - if err != nil { - return telemetry.UnusableSnapshot(err), err - } - response, err := telemetry.DecodeRateLimitsResponse(raw) - if err != nil { - wrapped := fmt.Errorf("decode installed Codex rate limits: %w", err) - return telemetry.UnusableSnapshot(wrapped), wrapped - } - snapshot := telemetry.NormalizeRateLimits(accountID, response, time.Now().UTC()) - if len(snapshot.WindowList()) == 0 { - err := errors.New("installed Codex rate-limit response has no usage windows") - return telemetry.UnusableSnapshot(err), err - } - return telemetry.UsableSnapshot(snapshot), nil - }) -} - -// IngestInstalledRateLimitsUpdate merges one standard sparse rolling update -// into controller telemetry. Ambiguous or out-of-order updates invalidate the -// cache and force a complete account/rateLimits/read through the registered -// installed provider, so policy never evaluates an older cached snapshot as -// if it were the new server state. -func (c *Controller) IngestInstalledRateLimitsUpdate(ctx context.Context, accountID string, params json.RawMessage) error { - if c == nil || c.router == nil { - return errors.New("usage router is unavailable") - } - accountID = strings.TrimSpace(accountID) - if accountID == "" { - return errors.New("installed rate-limit update has no active account") - } - var update telemetry.AccountRateLimitsUpdatedWire - if err := json.Unmarshal(params, &update); err != nil { - if c.cache != nil { - c.cache.Invalidate(accountID) - } - return fmt.Errorf("decode installed Codex rate-limit update: %w", err) - } - result := c.router.IngestSparse(accountID, update) - if !result.RefetchRequired { - return nil - } - if c.cache != nil { - c.cache.Invalidate(accountID) - } - if _, err := c.router.Refresh(ctx, accountID); err != nil { - return fmt.Errorf("refresh installed Codex rate limits after update: %w", err) - } - return nil -} - -// VerifyInstalledIdentity reads the standard account object after a process -// restart and compares it with both the selected profile and the atomically -// deployed auth snapshot. Callers should invoke this before treating a -// restart/resume fallback as committed; the generic transition method returns -// IdentityVerified=false until its caller performs this post-reconnect check. -func (c *Controller) VerifyInstalledIdentity(ctx context.Context, client *installedruntime.InstalledClient, accountID string) (bool, error) { - if c == nil { - return false, errors.New("nil controller") - } - if client == nil { - return false, installedruntime.ErrAppServerUnavailable - } - accountID = strings.TrimSpace(accountID) - if accountID == "" { - return false, errors.New("target account id is required") - } - account, err := client.ReadAccount(ctx) - if err != nil { - return false, err - } - return verifyInstalledIdentity(c.Config().AuthPath, accountID, account.Account) -} - -// InstalledNotificationAutomationEvent maps stable standard app-server -// notifications into secret-free automation wake-ups. It also returns the -// most recent thread ID because the installed Codex protocol carries that -// resume target on turn lifecycle notifications. -// -// Standard Codex app-server versions do not expose Marathon's parked -// continuation methods. A completed turn whose error identifies a usage or -// rate-limit exhaustion therefore wakes account policy; the caller can run a -// live account/read transition or the companion-owned restart/resume fallback. -func InstalledNotificationAutomationEvent(notification installedruntime.InstalledNotification, accountID string, now time.Time) (automation.Event, string, bool) { - if now.IsZero() { - now = time.Now().UTC() - } - now = now.UTC() - method := strings.TrimSpace(notification.Method) - threadID, turnID := installedNotificationIDs(notification.Params) - event := automation.Event{Type: automation.EventTelemetryChanged, AccountID: strings.TrimSpace(accountID), OccurredAt: now} - switch method { - case "account/rateLimits/updated", "account/updated": - return event, threadID, true - case "turn/completed": - if !installedUsageLimitPayload(notification.Params) { - return automation.Event{}, threadID, false - } - event.Type = automation.EventUsageLimitExceeded - if turnID != "" { - event.ID = "turn/" + turnID - } else { - digest := sha256.Sum256(notification.Params) - event.ID = fmt.Sprintf("turn/%x", digest[:8]) - } - return event, threadID, true - default: - return automation.Event{}, threadID, false - } -} - -func installedNotificationIDs(raw json.RawMessage) (threadID, turnID string) { - if len(raw) == 0 || string(raw) == "null" { - return "", "" - } - var params map[string]json.RawMessage - if json.Unmarshal(raw, ¶ms) != nil { - return "", "" - } - threadID = jsonStringField(params, "threadId", "thread_id", "threadID") - if turn, ok := params["turn"]; ok { - var turnObject map[string]json.RawMessage - if json.Unmarshal(turn, &turnObject) == nil { - turnID = jsonStringField(turnObject, "id", "turnId", "turn_id", "turnID") - if threadID == "" { - threadID = jsonStringField(turnObject, "threadId", "thread_id", "threadID") - } - } - } - return strings.TrimSpace(threadID), strings.TrimSpace(turnID) -} - -func jsonStringField(object map[string]json.RawMessage, keys ...string) string { - for _, key := range keys { - candidate, ok := object[key] - if !ok { - continue - } - var value string - if json.Unmarshal(candidate, &value) == nil && strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func installedUsageLimitPayload(raw json.RawMessage) bool { - value := strings.ToLower(string(raw)) - for _, marker := range []string{ - "usage_limit", - "usage-limit", - "usage limit", - "ratelimit", - "rate_limit", - "rate-limit", - "rate limit", - "quota_exceeded", - "quota-exceeded", - "quota exceeded", - } { - if strings.Contains(value, marker) { - return true - } - } - return false -} - -// TransitionInstalled performs the preferred live transition. The account -// manager atomically deploys the selected opaque snapshot only after a -// successful safe-boundary preflight. The installed app-server then reloads -// auth through account/read, invalidates its own account-bound transports, -// and reports the resulting identity. -func (c *Controller) TransitionInstalled(ctx context.Context, client *installedruntime.InstalledClient, accountID string) (InstalledTransitionResult, error) { - if c == nil { - return InstalledTransitionResult{}, errors.New("nil controller") - } - if client == nil { - return InstalledTransitionResult{}, installedruntime.ErrAppServerUnavailable - } - if ctx == nil { - ctx = context.Background() - } - accountID = strings.TrimSpace(accountID) - if accountID == "" { - return InstalledTransitionResult{}, errors.New("target account id is required") - } - if !client.HasCapability(installedruntime.CapabilityAuthReload) { - return InstalledTransitionResult{}, fmt.Errorf("%w: account/read reloadAuthFromStorage is unavailable", installedruntime.ErrCapabilityUnsupported) - } - if _, err := c.AccountManager().Status(accountID); err != nil { - return InstalledTransitionResult{}, err - } - previousID, err := c.Registry().ActiveID() - if err != nil { - return InstalledTransitionResult{}, err - } - previousAuth, hadPreviousAuth, err := readOptionalAuth(c.Config().AuthPath) - if err != nil { - return InstalledTransitionResult{}, err - } - - // This request is intentionally made before changing auth.json. The - // installed app-server is authoritative for active turns; a stale local - // notification counter cannot authorize a write during a running turn. - if _, err := client.ReloadAuth(ctx); err != nil { - return InstalledTransitionResult{}, err - } - if err := contextError(ctx); err != nil { - return InstalledTransitionResult{}, err - } - if _, err := c.AccountManager().Activate(ctx, accountID); err != nil { - return InstalledTransitionResult{}, err - } - - reloaded, reloadErr := client.ReloadAuth(ctx) - if reloadErr != nil { - return InstalledTransitionResult{}, c.rollbackInstalledTransition(accountID, previousID, previousAuth, hadPreviousAuth, reloadErr) - } - if accountID != previousID && !reloaded.AuthChanged { - return InstalledTransitionResult{}, c.rollbackInstalledTransition(accountID, previousID, previousAuth, hadPreviousAuth, ErrInstalledAuthNotApplied) - } - - verified, verifyErr := verifyInstalledIdentity(c.Config().AuthPath, accountID, reloaded.Account) - if verifyErr != nil { - return InstalledTransitionResult{}, c.rollbackInstalledTransition(accountID, previousID, previousAuth, hadPreviousAuth, verifyErr) - } - return InstalledTransitionResult{ - Mode: InstalledTransitionLive, - AccountID: accountID, - PreviousAccountID: previousID, - Version: client.Version(), - AuthChanged: reloaded.AuthChanged, - IdentityVerified: verified, - }, nil -} - -// TransitionInstalledOrRestart prefers TransitionInstalled and selects the -// controlled process boundary only when the installed app-server explicitly -// lacks live reload. The fallback never signals a process that the companion -// did not launch. -func (c *Controller) TransitionInstalledOrRestart(ctx context.Context, client *installedruntime.InstalledClient, supervisor *CompanionSupervisor, accountID string, resume CompanionResumeTarget) (InstalledTransitionResult, error) { - result, err := c.TransitionInstalled(ctx, client, accountID) - if err == nil { - return result, nil - } - if errors.Is(err, installedruntime.ErrActiveTurn) { - return result, err - } - if !errors.Is(err, installedruntime.ErrCapabilityUnsupported) && !errors.Is(err, installedruntime.ErrAppServerUnavailable) { - return result, err - } - if supervisor == nil { - return result, errors.Join(err, ErrInstalledFallbackUnavailable) - } - if resume.ThreadID == "" && !resume.Last { - resume.Last = true - } - if client != nil { - _ = client.Close() - } - if err := supervisor.RestartAndResume(ctx, resume, func(deployCtx context.Context) error { - _, activateErr := c.AccountManager().Activate(deployCtx, accountID) - return activateErr - }); err != nil { - return InstalledTransitionResult{}, err - } - return InstalledTransitionResult{ - Mode: InstalledTransitionRestart, - AccountID: accountID, - IdentityVerified: false, - ResumedThread: strings.TrimSpace(resume.ThreadID), - ResumedLast: resume.Last, - }, nil -} - -// rollbackInstalledTransition restores both controller authorities after a -// live reload failure. Account manager activation is deliberately not used -// here: it would make a second deployment look like a new transition. -func (c *Controller) rollbackInstalledTransition(accountID, previousID string, previousAuth []byte, hadPreviousAuth bool, cause error) error { - var restoreErr error - if hadPreviousAuth { - restoreErr = credentials.WriteAtomically(c.Config().AuthPath, previousAuth) - } else if err := os.Remove(c.Config().AuthPath); err != nil && !errors.Is(err, os.ErrNotExist) { - restoreErr = err - } - if previousID == "" { - if err := c.Registry().ClearActive(); err != nil { - restoreErr = errors.Join(restoreErr, err) - } - } else if err := c.Registry().SetActive(previousID); err != nil { - restoreErr = errors.Join(restoreErr, err) - } - return errors.Join(cause, restoreErr) -} - -func readOptionalAuth(path string) ([]byte, bool, error) { - if strings.TrimSpace(path) == "" { - return nil, false, errors.New("auth path is empty") - } - raw, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return nil, false, nil - } - if err != nil { - return nil, false, err - } - return raw, true, nil -} - -func verifyInstalledIdentity(authPath, accountID string, reported json.RawMessage) (bool, error) { - raw, err := os.ReadFile(authPath) - if err != nil { - return false, fmt.Errorf("read deployed Codex auth identity: %w", err) - } - deployedID, err := credentials.ExtractAccountID(raw) - if err != nil { - return false, err - } - if deployedID != "" && deployedID != accountID { - return false, fmt.Errorf("%w: deployed=%q target=%q", ErrInstalledIdentityMismatch, deployedID, accountID) - } - if reportedID := extractReportedAccountID(reported); reportedID != "" && reportedID != accountID { - return false, fmt.Errorf("%w: reported=%q target=%q", ErrInstalledIdentityMismatch, reportedID, accountID) - } - return true, nil -} - -func extractReportedAccountID(raw json.RawMessage) string { - if len(raw) == 0 || string(raw) == "null" { - return "" - } - var object map[string]json.RawMessage - if json.Unmarshal(raw, &object) != nil || object == nil { - return "" - } - for _, key := range []string{"id", "accountId", "account_id", "accountID"} { - var value string - if candidate, ok := object[key]; ok && json.Unmarshal(candidate, &value) == nil && strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func contextError(ctx context.Context) error { - if ctx == nil { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - default: - return nil - } -} diff --git a/controller/app/ipc_unix.go b/controller/app/ipc_unix.go deleted file mode 100644 index 786e3a61..00000000 --- a/controller/app/ipc_unix.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build !windows - -package app - -import ( - "context" - "errors" - "fmt" - "io" - "net" -) - -// dialUserNamedPipe is kept behind the platform boundary so the common -// lifecycle code never attempts to emulate Windows IPC with a TCP socket. -func dialUserNamedPipe(_ context.Context, _ string) (io.ReadWriteCloser, error) { - return nil, errors.New("Windows named pipes are unavailable on this platform") -} - -func dialUserScopedUnix(ctx context.Context, path string) (io.ReadWriteCloser, error) { - if ctx == nil { - ctx = context.Background() - } - var dialer net.Dialer - conn, err := dialer.DialContext(ctx, "unix", path) - if err != nil { - return nil, fmt.Errorf("dial runtime Unix socket %q: %w", path, err) - } - return conn, nil -} diff --git a/controller/app/ipc_windows.go b/controller/app/ipc_windows.go deleted file mode 100644 index 8750b4db..00000000 --- a/controller/app/ipc_windows.go +++ /dev/null @@ -1,47 +0,0 @@ -//go:build windows - -package app - -import ( - "context" - "errors" - "fmt" - "io" - "os" -) - -// dialUserScopedUnix is deliberately unavailable on Windows. The product -// endpoint is a named pipe there; silently falling back to TCP would remove -// the user-scoped authentication boundary. -func dialUserScopedUnix(_ context.Context, _ string) (io.ReadWriteCloser, error) { - return nil, errors.New("Unix sockets are unavailable on Windows") -} - -// dialUserNamedPipe opens the canonical \\.\pipe\... path emitted by -// DefaultRuntimeEndpoint. The runtime creates the pipe with a DACL limited -// to the current user; the client never exposes a network listener. -func dialUserNamedPipe(ctx context.Context, path string) (io.ReadWriteCloser, error) { - if ctx == nil { - ctx = context.Background() - } - result := make(chan struct { - file *os.File - err error - }, 1) - go func() { - file, err := os.OpenFile(path, os.O_RDWR, 0) - result <- struct { - file *os.File - err error - }{file: file, err: err} - }() - select { - case <-ctx.Done(): - return nil, ctx.Err() - case opened := <-result: - if opened.err != nil { - return nil, fmt.Errorf("open runtime named pipe %q: %w", path, opened.err) - } - return opened.file, nil - } -} diff --git a/controller/app/lifecycle.go b/controller/app/lifecycle.go deleted file mode 100644 index f10fde32..00000000 --- a/controller/app/lifecycle.go +++ /dev/null @@ -1,941 +0,0 @@ -package app - -// This file owns the operational boundary of CodexMarathon. The controller -// domains remain usable in tests and libraries, while RuntimeSupervisor is -// the one-command product entry point: it starts the bundled runtime, dials a -// user-scoped local IPC endpoint, negotiates the protocol, watches health, and -// reconnects/reconciles after a runtime loss. - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/url" - "os" - "os/exec" - "os/user" - "path/filepath" - "runtime" - "strings" - "sync" - "time" - - "codexmarathon/controller/internal/transitions" -) - -var ( - ErrSupervisorAlreadyStarted = errors.New("runtime supervisor is already started") - ErrSupervisorNotStarted = errors.New("runtime supervisor is not started") - ErrBundledRuntimeMissing = errors.New("bundled CodexMarathon runtime is not installed") - ErrUnauthenticatedTCP = errors.New("unauthenticated TCP runtime endpoints are disabled") - ErrUnsafeRuntimeEndpoint = errors.New("runtime endpoint is not user-scoped") -) - -const ( - defaultHealthInterval = 5 * time.Second - defaultConnectTimeout = 15 * time.Second - defaultReconnectInterval = 750 * time.Millisecond - defaultReconnectMax = 8 * time.Second - defaultShutdownTimeout = 10 * time.Second -) - -// RuntimeConnector is injectable for integration fixtures. Production uses -// DialManagedEndpoint, which only permits authenticated-by-OS local IPC. -type RuntimeConnector func(context.Context, string) (io.ReadWriteCloser, error) - -// SupervisorConfig controls the bundled runtime process and its local IPC. -// RuntimeCommand is argv-shaped (the first element is the executable), never -// a shell command. The supervisor injects the stable -// CODEXMARATHON_LISTEN= environment contract into the bundled -// runtime. Using an environment contract lets the unmodified Codex app -// server continue to own its normal command-line transport while the -// Marathon listener is an additional local control boundary. -type SupervisorConfig struct { - Endpoint string - RuntimeCommand []string - RuntimeArgs []string - RuntimeDir string - RuntimeEnv []string - ProtocolVersions []int - ConnectTimeout time.Duration - HealthInterval time.Duration - ReconnectInterval time.Duration - ReconnectMax time.Duration - ShutdownTimeout time.Duration - MaxRestarts int - DiagnosticsPath string - Connector RuntimeConnector - DisableAutoRestart bool -} - -func (c SupervisorConfig) withDefaults(controller *Controller) (SupervisorConfig, error) { - if controller == nil { - return SupervisorConfig{}, errors.New("controller is nil") - } - if strings.TrimSpace(c.Endpoint) == "" { - endpoint, err := DefaultRuntimeEndpoint(controller.Config().StateDir) - if err != nil { - return SupervisorConfig{}, err - } - c.Endpoint = endpoint - } - if err := ValidateManagedEndpoint(c.Endpoint, controller.Config().StateDir); err != nil { - return SupervisorConfig{}, err - } - if len(c.ProtocolVersions) == 0 { - c.ProtocolVersions = []int{1} - } - versionOne := false - for _, version := range c.ProtocolVersions { - if version == 1 { - versionOne = true - } - } - if !versionOne { - return SupervisorConfig{}, fmt.Errorf("runtime supervisor supports protocol v1; configured versions=%v", c.ProtocolVersions) - } - if c.ConnectTimeout <= 0 { - c.ConnectTimeout = defaultConnectTimeout - } - if c.HealthInterval <= 0 { - c.HealthInterval = defaultHealthInterval - } - if c.ReconnectInterval <= 0 { - c.ReconnectInterval = defaultReconnectInterval - } - if c.ReconnectMax <= 0 { - c.ReconnectMax = defaultReconnectMax - } - if c.ShutdownTimeout <= 0 { - c.ShutdownTimeout = defaultShutdownTimeout - } - externalConnector := c.Connector != nil - if c.Connector == nil { - c.Connector = DialManagedEndpoint - } - if strings.TrimSpace(c.DiagnosticsPath) == "" { - c.DiagnosticsPath = filepath.Join(controller.Config().StateDir, "diagnostics.json") - } - if len(c.RuntimeCommand) == 0 && (controller.RuntimeConnected() || externalConnector) { - // A caller may supervise a runtime it owns outside this process while - // still benefiting from health/reconciliation. The product CLI always - // supplies/derives a bundled command below. - return c, nil - } - if len(c.RuntimeCommand) == 0 { - command, err := ResolveBundledRuntimeCommand() - if err != nil { - return SupervisorConfig{}, err - } - c.RuntimeCommand = []string{command} - } - if strings.TrimSpace(c.RuntimeCommand[0]) == "" { - return SupervisorConfig{}, errors.New("runtime command executable is empty") - } - return c, nil -} - -// LifecycleState is intentionally small so status output is stable and easy -// for scripts to consume. -type LifecycleState string - -const ( - LifecycleStopped LifecycleState = "stopped" - LifecycleStarting LifecycleState = "starting" - LifecycleReady LifecycleState = "ready" - LifecycleDegraded LifecycleState = "degraded" - LifecycleStopping LifecycleState = "stopping" - LifecycleFailed LifecycleState = "failed" -) - -// LifecycleStatus is safe to serialize: it contains no auth material, token, -// or environment value. -type LifecycleStatus struct { - State LifecycleState `json:"state"` - Endpoint string `json:"endpoint"` - RuntimeCommand string `json:"runtime_command,omitempty"` - PID int `json:"pid,omitempty"` - RuntimeID string `json:"runtime_id,omitempty"` - ProtocolVersion int `json:"protocol_version,omitempty"` - RuntimeConnected bool `json:"runtime_connected"` - RestartCount int `json:"restart_count"` - StartedAt time.Time `json:"started_at,omitempty"` - ReadyAt time.Time `json:"ready_at,omitempty"` - LastHealthAt time.Time `json:"last_health_at,omitempty"` - LastDisconnectAt time.Time `json:"last_disconnect_at,omitempty"` - LastReconcileAt time.Time `json:"last_reconcile_at,omitempty"` - LastError string `json:"last_error,omitempty"` - LastExit string `json:"last_exit,omitempty"` - ReconciliationErr string `json:"reconciliation_error,omitempty"` -} - -// RuntimeSupervisor owns the runtime process lifecycle. It is safe to call -// Status and WriteDiagnostics while Start/monitor/reconnect are in progress. -type RuntimeSupervisor struct { - controller *Controller - config SupervisorConfig - - mu sync.RWMutex - status LifecycleStatus - started bool - stopping bool - cancel context.CancelFunc - monitorDone chan struct{} - process *exec.Cmd - processDone chan error - processWaited bool -} - -// NewRuntimeSupervisor validates the secure endpoint and composes a process -// supervisor. It performs no process or socket side effects. -func NewRuntimeSupervisor(controller *Controller, config SupervisorConfig) (*RuntimeSupervisor, error) { - if controller == nil { - return nil, errors.New("controller is nil") - } - normalized, err := config.withDefaults(controller) - if err != nil { - return nil, err - } - return &RuntimeSupervisor{ - controller: controller, - config: normalized, - status: LifecycleStatus{ - State: LifecycleStopped, - Endpoint: normalized.Endpoint, - }, - }, nil -} - -// Start starts the bundled runtime (when not already attached), waits for -// protocol readiness, and then starts health/reconnect monitoring. -func (s *RuntimeSupervisor) Start(ctx context.Context) error { - if s == nil || s.controller == nil { - return errors.New("runtime supervisor is nil") - } - if ctx == nil { - ctx = context.Background() - } - s.mu.Lock() - if s.started { - s.mu.Unlock() - return ErrSupervisorAlreadyStarted - } - s.started = true - s.stopping = false - s.status.State = LifecycleStarting - s.status.StartedAt = time.Now().UTC() - s.status.LastError = "" - loopCtx, cancel := context.WithCancel(ctx) - s.cancel = cancel - s.monitorDone = make(chan struct{}) - s.mu.Unlock() - - if err := s.controller.EnsureLayout(); err != nil { - return s.failStart(err) - } - if err := s.startManagedProcess(loopCtx); err != nil { - return s.failStart(err) - } - if err := s.connectWithRetry(loopCtx, s.config.ConnectTimeout); err != nil { - return s.failStart(err) - } - s.markReady() - go s.monitor(loopCtx) - _ = s.WriteDiagnostics("") - return nil -} - -// Run is the convenient one-command blocking form used by the CLI. -func (s *RuntimeSupervisor) Run(ctx context.Context) error { - if err := s.Start(ctx); err != nil { - return err - } - if ctx == nil { - ctx = context.Background() - } - s.mu.RLock() - monitorDone := s.monitorDone - s.mu.RUnlock() - select { - case <-ctx.Done(): - return s.Stop() - case <-monitorDone: - status := s.Status() - if status.State == LifecycleFailed { - return fmt.Errorf("runtime supervisor stopped: %s", status.LastError) - } - return nil - } -} - -// Stop performs an orderly disconnect and then asks the managed process to -// exit. A stubborn process is force-terminated after ShutdownTimeout. -func (s *RuntimeSupervisor) Stop() error { - if s == nil { - return nil - } - s.mu.Lock() - if !s.started { - s.mu.Unlock() - return nil - } - s.stopping = true - s.status.State = LifecycleStopping - cancel := s.cancel - monitorDone := s.monitorDone - s.mu.Unlock() - if cancel != nil { - cancel() - } - if s.controller != nil { - _ = s.controller.DisconnectRuntime("runtime supervisor stopping") - } - if monitorDone != nil { - <-monitorDone - } - // The monitor may have consumed processDone while Stop was waiting for it. - // Re-read ownership after the monitor exits so we never wait on a channel - // whose process-exit value was already consumed by another goroutine. - s.mu.RLock() - process := s.process - processDone := s.processDone - processWaited := s.processWaited - s.mu.RUnlock() - var err error - if !processWaited { - err = s.stopProcess(process, processDone) - } - s.mu.Lock() - s.started = false - s.cancel = nil - s.monitorDone = nil - s.process = nil - s.processDone = nil - s.status.State = LifecycleStopped - s.status.RuntimeConnected = false - s.status.PID = 0 - if err != nil { - s.status.LastError = err.Error() - } - s.mu.Unlock() - _ = s.WriteDiagnostics("") - return err -} - -// Wait blocks until the monitor stops. It is useful to embed the supervisor -// in a host that owns signal handling itself. -func (s *RuntimeSupervisor) Wait() error { - if s == nil { - return nil - } - s.mu.RLock() - done := s.monitorDone - s.mu.RUnlock() - if done == nil { - return ErrSupervisorNotStarted - } - <-done - return nil -} - -// Status returns a race-free operational snapshot. -func (s *RuntimeSupervisor) Status() LifecycleStatus { - if s == nil { - return LifecycleStatus{State: LifecycleFailed, LastError: "runtime supervisor is nil"} - } - s.mu.RLock() - status := s.status - status.RuntimeConnected = s.controller != nil && s.controller.RuntimeConnected() - if s.controller != nil { - status.ProtocolVersion = s.controller.RuntimeProtocolVersion() - } - s.mu.RUnlock() - return status -} - -// WriteDiagnostics writes a redacted atomic status document. An empty path -// uses the configured state-dir/diagnostics.json location. -func (s *RuntimeSupervisor) WriteDiagnostics(path string) error { - if s == nil { - return errors.New("runtime supervisor is nil") - } - if strings.TrimSpace(path) == "" { - path = s.config.DiagnosticsPath - } - if strings.TrimSpace(path) == "" { - return errors.New("diagnostics path is empty") - } - status := s.Status() - payload, err := json.MarshalIndent(status, "", " ") - if err != nil { - return fmt.Errorf("encode runtime diagnostics: %w", err) - } - path, err = filepath.Abs(path) - if err != nil { - return fmt.Errorf("resolve diagnostics path: %w", err) - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("create diagnostics directory: %w", err) - } - temp, err := os.CreateTemp(filepath.Dir(path), ".diagnostics-*.tmp") - if err != nil { - return fmt.Errorf("create diagnostics file: %w", err) - } - tempName := temp.Name() - defer func() { _ = os.Remove(tempName) }() - if err := temp.Chmod(0o600); err != nil && runtime.GOOS != "windows" { - _ = temp.Close() - return fmt.Errorf("protect diagnostics file: %w", err) - } - if _, err := temp.Write(append(payload, '\n')); err != nil { - _ = temp.Close() - return fmt.Errorf("write runtime diagnostics: %w", err) - } - if err := temp.Sync(); err != nil { - _ = temp.Close() - return fmt.Errorf("flush runtime diagnostics: %w", err) - } - if err := temp.Close(); err != nil { - return fmt.Errorf("close runtime diagnostics: %w", err) - } - if err := os.Rename(tempName, path); err != nil { - if runtime.GOOS != "windows" { - return fmt.Errorf("publish runtime diagnostics: %w", err) - } - // Windows does not replace an existing destination on every supported - // filesystem. Remove only the exact diagnostics target, then publish - // the already-fsynced temporary file; no broad cleanup is attempted. - if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { - return fmt.Errorf("publish runtime diagnostics: %w", err) - } - if renameErr := os.Rename(tempName, path); renameErr != nil { - return fmt.Errorf("publish runtime diagnostics: %w", renameErr) - } - } - return nil -} - -func (s *RuntimeSupervisor) failStart(err error) error { - if err == nil { - err = errors.New("runtime supervisor failed to start") - } - s.mu.Lock() - s.status.State = LifecycleFailed - s.status.LastError = err.Error() - s.started = false - cancel := s.cancel - process := s.process - processDone := s.processDone - s.cancel = nil - s.process = nil - s.processDone = nil - s.monitorDone = nil - s.mu.Unlock() - if cancel != nil { - cancel() - } - if s.controller != nil { - _ = s.controller.DisconnectRuntime("runtime supervisor startup failed") - } - _ = s.stopProcess(process, processDone) - _ = s.WriteDiagnostics("") - return err -} - -func (s *RuntimeSupervisor) markReady() { - s.mu.Lock() - s.status.State = LifecycleReady - s.status.ReadyAt = time.Now().UTC() - s.status.LastError = "" - s.status.RuntimeConnected = s.controller.RuntimeConnected() - s.status.ProtocolVersion = s.controller.RuntimeProtocolVersion() - s.mu.Unlock() -} - -func (s *RuntimeSupervisor) setError(err error, state LifecycleState) { - if err == nil { - return - } - s.mu.Lock() - s.status.State = state - s.status.LastError = err.Error() - s.mu.Unlock() -} - -func (s *RuntimeSupervisor) startManagedProcess(_ context.Context) error { - s.mu.RLock() - alreadyConnected := s.controller.RuntimeConnected() - command := append([]string(nil), s.config.RuntimeCommand...) - args := append([]string(nil), s.config.RuntimeArgs...) - runtimeDir := s.config.RuntimeDir - env := append([]string(nil), s.config.RuntimeEnv...) - endpoint := s.config.Endpoint - s.mu.RUnlock() - if alreadyConnected || len(command) == 0 { - return nil - } - if runtimeDir == "" { - runtimeDir = filepath.Dir(command[0]) - } - // The embedded app-server reads this variable after it has constructed its - // native AuthManager/ThreadManager. Do not pass a Marathon-specific CLI - // flag to a Codex binary that does not know that flag; the supervisor owns - // the integration boundary and the runtime remains a normal Codex binary. - env = append(env, "CODEXMARATHON_LISTEN="+endpoint) - controlEndpoint, err := DefaultControllerControlEndpoint(s.controller.Config().StateDir) - if err != nil { - return fmt.Errorf("resolve controller control endpoint: %w", err) - } - env = append(env, ControllerControlEnv+"="+controlEndpoint) - if isCodexAppServer(command[0]) && !hasFlag(args, "--listen") { - // The supervisor owns the process over local IPC. Inheriting the - // app-server's stdio transport would make it exit when the child has no - // interactive stdin, so explicitly disable that transport. - args = append(args, "--listen", "off") - } - // Do not bind the child directly to loopCtx: cancelling that context is - // also how the supervisor asks its monitor to stop, and CommandContext - // would send an unconditional kill before stopProcess can deliver the - // graceful interrupt/timeout sequence. - cmd := exec.Command(command[0], append(command[1:], args...)...) - cmd.Dir = runtimeDir - if len(env) > 0 { - cmd.Env = append(os.Environ(), env...) - } - // Runtime stderr may contain provider diagnostics. It is intentionally - // discarded by the supervisor so tokens cannot leak into CLI logs; callers - // can inspect the redacted lifecycle status instead. - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - if err := cmd.Start(); err != nil { - return fmt.Errorf("start bundled CodexMarathon runtime %q: %w", command[0], err) - } - processDone := make(chan error, 1) - go func() { processDone <- cmd.Wait() }() - s.mu.Lock() - s.process = cmd - s.processDone = processDone - s.processWaited = false - s.status.PID = cmd.Process.Pid - s.status.RuntimeCommand = command[0] - s.mu.Unlock() - return nil -} - -func (s *RuntimeSupervisor) connectWithRetry(ctx context.Context, timeout time.Duration) error { - if ctx == nil { - ctx = context.Background() - } - if timeout <= 0 { - timeout = s.config.ConnectTimeout - } - connectCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - var lastErr error - delay := s.config.ReconnectInterval - for { - if s.controller.RuntimeConnected() { - return nil - } - conn, err := s.config.Connector(connectCtx, s.config.Endpoint) - if err == nil && conn == nil { - err = errors.New("runtime connector returned a nil transport") - } - if err == nil { - attachCtx, attachCancel := context.WithTimeout(connectCtx, s.config.ConnectTimeout) - err = s.controller.ConnectRuntime(attachCtx, conn) - attachCancel() - if err == nil { - if reconcileErr := s.reconcileAfterReconnect(connectCtx); reconcileErr != nil { - s.mu.Lock() - s.status.ReconciliationErr = reconcileErr.Error() - s.mu.Unlock() - } - return nil - } - _ = conn.Close() - } - lastErr = err - if lastErr == nil { - lastErr = errors.New("runtime connection failed") - } - select { - case <-connectCtx.Done(): - return fmt.Errorf("runtime did not become ready at %s: %w (last error: %v)", s.config.Endpoint, connectCtx.Err(), lastErr) - case <-time.After(delay): - } - if delay < s.config.ReconnectMax { - delay *= 2 - if delay > s.config.ReconnectMax { - delay = s.config.ReconnectMax - } - } - } -} - -func (s *RuntimeSupervisor) reconcileAfterReconnect(ctx context.Context) error { - if !s.controller.RuntimeConnected() { - return ErrRuntimeNotConnected - } - s.mu.Lock() - s.status.LastReconcileAt = time.Now().UTC() - s.mu.Unlock() - // Reconcile is deliberately best-effort here. A clean runtime with no - // pending transition returns ErrTransitionNotFound; an actual uncertain - // transition remains visible in diagnostics for the operator or the durable - // recovery coordinator to inspect. - _, err := s.controller.Reconcile(ctx, "") - if errors.Is(err, transitions.ErrTransitionNotFound) { - err = nil - } - if err != nil { - return err - } - // Recovery reconciliation is independent of transition lookup. It reads - // Codext's runtime-owned recovery map and retries only durable, already - // identity-verified release intents. - if manager := s.controller.Recovery(); manager != nil { - if recoveryErr := manager.Reconcile(ctx); recoveryErr != nil { - return recoveryErr - } - } - return nil -} - -func (s *RuntimeSupervisor) monitor(ctx context.Context) { - defer close(s.monitorDone) - ticker := time.NewTicker(s.config.HealthInterval) - defer ticker.Stop() - var processDone <-chan error - s.mu.RLock() - if s.processDone != nil { - processDone = s.processDone - } - s.mu.RUnlock() - for { - select { - case <-ctx.Done(): - return - case err := <-processDone: - if processDone == nil { - continue - } - s.handleProcessExit(err) - processDone = nil - if s.config.DisableAutoRestart || s.isStopping() { - s.setError(errors.New("managed runtime exited"), LifecycleFailed) - return - } - if err := s.restart(ctx); err != nil { - s.setError(err, LifecycleFailed) - if s.config.MaxRestarts > 0 && s.Status().RestartCount >= s.config.MaxRestarts { - return - } - } - s.mu.RLock() - processDone = s.processDone - s.mu.RUnlock() - case <-ticker.C: - s.healthCheck(ctx) - } - } -} - -func (s *RuntimeSupervisor) healthCheck(ctx context.Context) { - if s.isStopping() { - return - } - if !s.controller.RuntimeConnected() { - s.setError(errors.New("runtime is disconnected"), LifecycleDegraded) - if !s.hasLiveProcess() && !s.config.DisableAutoRestart { - if err := s.restart(ctx); err != nil { - s.setError(err, LifecycleDegraded) - return - } - } - if err := s.connectWithRetry(ctx, s.config.ReconnectMax); err != nil { - s.setError(err, LifecycleDegraded) - return - } - s.markReady() - _ = s.WriteDiagnostics("") - return - } - healthCtx, cancel := context.WithTimeout(ctx, s.config.ConnectTimeout) - _, err := s.controller.RuntimeState(healthCtx) - cancel() - s.mu.Lock() - s.status.LastHealthAt = time.Now().UTC() - s.mu.Unlock() - if err == nil { - return - } - s.mu.Lock() - s.status.LastDisconnectAt = time.Now().UTC() - s.mu.Unlock() - _ = s.controller.DisconnectRuntime("runtime health check failed: " + err.Error()) - s.setError(fmt.Errorf("runtime health check: %w", err), LifecycleDegraded) - if reconnectErr := s.connectWithRetry(ctx, s.config.ReconnectMax); reconnectErr != nil { - s.setError(reconnectErr, LifecycleDegraded) - return - } - s.markReady() - _ = s.WriteDiagnostics("") -} - -func (s *RuntimeSupervisor) hasLiveProcess() bool { - s.mu.RLock() - live := s.process != nil && !s.processWaited - s.mu.RUnlock() - return live -} - -func (s *RuntimeSupervisor) restart(ctx context.Context) error { - s.mu.Lock() - s.status.RestartCount++ - s.mu.Unlock() - if err := s.startManagedProcess(ctx); err != nil { - return err - } - if err := s.connectWithRetry(ctx, s.config.ConnectTimeout); err != nil { - return err - } - s.markReady() - return nil -} - -func (s *RuntimeSupervisor) handleProcessExit(err error) { - s.mu.Lock() - s.process = nil - s.processDone = nil - s.processWaited = true - s.status.PID = 0 - if err != nil { - s.status.LastExit = err.Error() - } else { - s.status.LastExit = "runtime exited" - } - s.mu.Unlock() - _ = s.controller.DisconnectRuntime("managed runtime exited") -} - -func (s *RuntimeSupervisor) isStopping() bool { - s.mu.RLock() - stopping := s.stopping - s.mu.RUnlock() - return stopping -} - -func (s *RuntimeSupervisor) stopProcess(process *exec.Cmd, processDone <-chan error) error { - if process == nil { - return nil - } - if processDone == nil { - return nil - } - select { - case err := <-processDone: - return normalizeProcessExit(err) - default: - } - if process.Process != nil { - if runtime.GOOS == "windows" { - _ = process.Process.Kill() - } else { - _ = process.Process.Signal(os.Interrupt) - } - } - timer := time.NewTimer(s.config.ShutdownTimeout) - defer timer.Stop() - select { - case err := <-processDone: - return normalizeProcessExit(err) - case <-timer.C: - if process.Process != nil { - _ = process.Process.Kill() - } - return fmt.Errorf("managed runtime did not exit within %s", s.config.ShutdownTimeout) - } -} - -func normalizeProcessExit(err error) error { - if err == nil { - return nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 0 { - return nil - } - return err -} - -func hasFlag(args []string, name string) bool { - for _, arg := range args { - if arg == name || strings.HasPrefix(arg, name+"=") { - return true - } - } - return false -} - -func isCodexAppServer(command string) bool { - name := strings.ToLower(filepath.Base(command)) - return name == "codex-app-server" || name == "codex-app-server.exe" -} - -// DefaultRuntimeEndpoint returns the secure, user-scoped local endpoint. No -// product default is TCP. The state directory is also the trust boundary for -// Unix sockets and is created with mode 0700 by Controller.EnsureLayout. -func DefaultRuntimeEndpoint(stateDir string) (string, error) { - if strings.TrimSpace(stateDir) == "" { - return "", errors.New("state directory is empty") - } - stateDir, err := filepath.Abs(stateDir) - if err != nil { - return "", fmt.Errorf("resolve state directory: %w", err) - } - if runtime.GOOS == "windows" { - return "pipe://./pipe/" + userScopedPipeName(), nil - } - return "unix://" + filepath.Join(stateDir, "runtime.sock"), nil -} - -func userScopedPipeName() string { - identity := os.Getenv("USERNAME") - if identity == "" { - identity = os.Getenv("USER") - } - if identity == "" { - if current, err := user.Current(); err == nil { - identity = current.Username - } - } - if identity == "" { - identity = "unknown" - } - hash := sha256.Sum256([]byte(identity)) - return "codexmarathon-" + hex.EncodeToString(hash[:8]) -} - -// ValidateManagedEndpoint rejects network transports and paths outside the -// controller's user-owned state directory. This function is called before a -// managed process starts, so an accidental `tcp://0.0.0.0` can never become a -// product listener. -func ValidateManagedEndpoint(endpoint, stateDir string) error { - endpoint = strings.TrimSpace(endpoint) - if endpoint == "" { - return fmt.Errorf("%w: endpoint is empty", ErrUnsafeRuntimeEndpoint) - } - if !strings.Contains(endpoint, "://") { - return fmt.Errorf("%w: use unix:// or pipe://; bare host:port is not allowed", ErrUnsafeRuntimeEndpoint) - } - parsed, err := url.Parse(endpoint) - if err != nil { - return fmt.Errorf("%w: %v", ErrUnsafeRuntimeEndpoint, err) - } - if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return fmt.Errorf("%w: runtime endpoints must not include user-info, query, or fragment data", ErrUnsafeRuntimeEndpoint) - } - scheme := strings.ToLower(parsed.Scheme) - switch scheme { - case "tcp": - return fmt.Errorf("%w: %s", ErrUnauthenticatedTCP, parsed.Host) - case "unix": - if runtime.GOOS == "windows" { - return fmt.Errorf("%w: Unix sockets are not supported on Windows", ErrUnsafeRuntimeEndpoint) - } - if parsed.Host != "" { - return fmt.Errorf("%w: Unix socket endpoint must not include a host", ErrUnsafeRuntimeEndpoint) - } - path := filepath.Clean(filepath.FromSlash(parsed.Path)) - if !filepath.IsAbs(path) { - return fmt.Errorf("%w: Unix socket path must be absolute", ErrUnsafeRuntimeEndpoint) - } - stateAbs, err := filepath.Abs(stateDir) - if err != nil { - return fmt.Errorf("resolve state directory: %w", err) - } - rel, err := filepath.Rel(filepath.Clean(stateAbs), path) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("%w: Unix socket must live below state directory", ErrUnsafeRuntimeEndpoint) - } - if path == filepath.Clean(stateAbs) { - return fmt.Errorf("%w: endpoint cannot be the state directory", ErrUnsafeRuntimeEndpoint) - } - return nil - case "pipe", "npipe", "namedpipe": - if runtime.GOOS != "windows" { - return fmt.Errorf("%w: named pipes are Windows-only", ErrUnsafeRuntimeEndpoint) - } - if parsed.Host == "" && parsed.Path == "" { - return fmt.Errorf("%w: named-pipe name is empty", ErrUnsafeRuntimeEndpoint) - } - expected := `\\.\pipe\` + userScopedPipeName() - if namedPipePath(parsed) != expected { - return fmt.Errorf("%w: named pipe must use the current user's CodexMarathon endpoint", ErrUnsafeRuntimeEndpoint) - } - return nil - default: - return fmt.Errorf("%w: unsupported scheme %q", ErrUnsafeRuntimeEndpoint, parsed.Scheme) - } -} - -// DialManagedEndpoint is the only connector used by RuntimeSupervisor. -// Explicit TCP remains available through the legacy diagnostic DialEndpoint, -// but cannot be selected through this product path. -func DialManagedEndpoint(ctx context.Context, endpoint string) (io.ReadWriteCloser, error) { - endpoint = strings.TrimSpace(endpoint) - if endpoint == "" { - return nil, fmt.Errorf("%w: endpoint is empty", ErrUnsafeRuntimeEndpoint) - } - parsed, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnsafeRuntimeEndpoint, err) - } - switch strings.ToLower(parsed.Scheme) { - case "unix": - return dialUserScopedUnix(ctx, filepath.Clean(filepath.FromSlash(parsed.Path))) - case "pipe", "npipe", "namedpipe": - path := namedPipePath(parsed) - return dialUserNamedPipe(ctx, path) - case "tcp": - return nil, fmt.Errorf("%w: %s", ErrUnauthenticatedTCP, parsed.Host) - default: - return nil, fmt.Errorf("%w: unsupported scheme %q", ErrUnsafeRuntimeEndpoint, parsed.Scheme) - } -} - -func namedPipePath(parsed *url.URL) string { - name := strings.Trim(parsed.Host+parsed.Path, "/") - name = strings.TrimPrefix(name, "./") - name = strings.TrimPrefix(name, ".\\") - name = strings.ReplaceAll(name, "/", `\`) - name = strings.TrimPrefix(name, `pipe\`) - if strings.HasPrefix(name, `\\.\pipe\`) { - return name - } - return `\\.\pipe\` + name -} - -// ResolveBundledRuntimeCommand finds the runtime shipped next to the -// CodexMarathon launcher. It never searches PATH for an unrelated external -// donor executable. -func ResolveBundledRuntimeCommand() (string, error) { - current, err := os.Executable() - if err != nil { - return "", fmt.Errorf("resolve codexmarathon executable: %w", err) - } - dir := filepath.Dir(current) - names := []string{"codexmarathon-runtime", "codexmarathon-runtime.exe", "codex-app-server", "codex-app-server.exe"} - for _, name := range names { - candidate := filepath.Join(dir, name) - if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() { - return candidate, nil - } - } - return "", fmt.Errorf("%w: expected codexmarathon-runtime beside %q; build the bundled runtime package first", ErrBundledRuntimeMissing, current) -} diff --git a/controller/app/telemetry_wire.go b/controller/app/telemetry_wire.go deleted file mode 100644 index 02c27eda..00000000 --- a/controller/app/telemetry_wire.go +++ /dev/null @@ -1,58 +0,0 @@ -package app - -import ( - "time" - - runtimeapi "codexmarathon/controller/internal/runtime" - "codexmarathon/controller/internal/telemetry" -) - -// normalizeRuntimeSnapshot bridges the runtime transport's wire types to the -// telemetry domain's equivalent wire types. Keeping the transport and domain -// packages separate prevents the JSON-RPC client from depending on policy -// state, while this composition boundary owns the conversion. -func normalizeRuntimeSnapshot(accountID string, aggregate runtimeapi.RateLimitSnapshotWire, byID map[string]runtimeapi.RateLimitSnapshotWire, observedAt time.Time) telemetry.AccountTelemetry { - converted := make(map[string]telemetry.RateLimitSnapshotWire, len(byID)) - for id, snapshot := range byID { - converted[id] = convertRuntimeSnapshot(snapshot) - } - return telemetry.NormalizeSnapshot(accountID, convertRuntimeSnapshot(aggregate), converted, observedAt) -} - -func convertRuntimeSnapshot(snapshot runtimeapi.RateLimitSnapshotWire) telemetry.RateLimitSnapshotWire { - return telemetry.RateLimitSnapshotWire{ - LimitID: cloneStringPointer(snapshot.LimitID), - LimitName: cloneStringPointer(snapshot.LimitName), - PlanType: cloneStringPointer(snapshot.PlanType), - RateLimitReachedType: cloneStringPointer(snapshot.RateLimitReachedType), - Primary: convertRuntimeWindow(snapshot.Primary), - Secondary: convertRuntimeWindow(snapshot.Secondary), - } -} - -func convertRuntimeWindow(window *runtimeapi.RateLimitWindowWire) *telemetry.RateLimitWindowWire { - if window == nil { - return nil - } - return &telemetry.RateLimitWindowWire{ - UsedPercent: window.UsedPercent, - WindowDurationMins: cloneInt64Pointer(window.WindowDurationMins), - ResetsAt: cloneInt64Pointer(window.ResetsAt), - } -} - -func cloneStringPointer(value *string) *string { - if value == nil { - return nil - } - copy := *value - return © -} - -func cloneInt64Pointer(value *int64) *int64 { - if value == nil { - return nil - } - copy := *value - return © -} diff --git a/controller/cmd/codexmarathon/accounts_commands.go b/controller/cmd/codexmarathon/accounts_commands.go deleted file mode 100644 index 9c8447af..00000000 --- a/controller/cmd/codexmarathon/accounts_commands.go +++ /dev/null @@ -1,352 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "strings" - "time" - - "codexmarathon/controller/app" - "codexmarathon/controller/internal/accounts" -) - -func runAccounts(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 || args[0] == "-h" || args[0] == "--help" { - _, _ = io.WriteString(stdout, accountsUsage) - return 0 - } - switch args[0] { - case "list": - return runAccountsList(args[1:], stdout, stderr) - case "login", "add": - return runAccountsLogin(args[1:], stdout, stderr) - case "import": - return runAccountsImport(args[1:], stdout, stderr) - case "status": - return runAccountsStatus(args[1:], stdout, stderr) - case "rename": - return runAccountsRename(args[1:], stdout, stderr) - case "activate", "use": - return runAccountsActivate(args[1:], stdout, stderr) - case "refresh": - return runAccountsRefresh(args[1:], stdout, stderr) - case "remove": - return runAccountsRemove(args[1:], stdout, stderr) - default: - return usageError(stderr, fmt.Sprintf("unknown accounts command %q", args[0])) - } -} - -const accountsUsage = `CodexMarathon account manager - -Usage: - codexmarathon accounts list [options] - codexmarathon accounts login [options] - codexmarathon accounts add [options] - codexmarathon accounts import [options] - codexmarathon accounts status [account-id] [options] - codexmarathon accounts rename --name [options] - codexmarathon accounts activate [options] - codexmarathon accounts use [options] - codexmarathon accounts refresh --runtime [options] - codexmarathon accounts remove [options] - -` - -func runAccountsImport(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts import", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - fromAuth := fs.String("from-auth", "", "existing Codex auth.json to import (default: --auth path)") - accountID := fs.String("id", "", "optional stable account ID when the auth snapshot has no account_id") - alias := fs.String("name", "", "local profile alias") - activate := fs.Bool("activate", false, "make this profile active after import") - overwrite := fs.Bool("overwrite", false, "replace an existing profile with the same account ID") - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 0 { - return usageError(stderr, "accounts import does not accept positional arguments; use --from-auth, --id, and --name") - } - source := strings.TrimSpace(*fromAuth) - if source == "" { - source = paths.authPath - } - raw, err := os.ReadFile(source) - if err != nil { - return printError(stderr, fmt.Errorf("read auth snapshot %q: %w", source, err)) - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - outcome, err := controller.AccountManager().Import(context.Background(), accounts.ImportRequest{ - AccountID: *accountID, - Alias: *alias, - AuthJSON: raw, - Activate: *activate, - Overwrite: *overwrite, - }) - if err != nil { - return printError(stderr, err) - } - if *jsonOutput { - return writeJSON(stdout, outcome) - } - if outcome.Activated { - _, _ = fmt.Fprintf(stdout, "imported and activated account: %s\n", outcome.AccountID) - } else { - _, _ = fmt.Fprintf(stdout, "imported account: %s\n", outcome.AccountID) - } - return 0 -} - -func runAccountsLogin(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts login", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - accountID := fs.String("id", "", "optional stable account ID (native login identity is used by default)") - alias := fs.String("name", "", "local profile alias") - activate := fs.Bool("activate", false, "make this profile active after login") - overwrite := fs.Bool("overwrite", false, "replace an existing profile with the same account ID") - runtimeEndpoint := fs.String("runtime", "", "embedded runtime endpoint for native login") - timeout := fs.Duration("timeout", 10*time.Minute, "maximum login duration") - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 0 { - return usageError(stderr, "accounts login does not accept positional arguments; use --id and --name") - } - if strings.TrimSpace(*runtimeEndpoint) == "" { - return usageError(stderr, "accounts login requires --runtime so the integrated Codex login service can be used") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - if err := attachAuthRuntime(ctx, controller, *runtimeEndpoint); err != nil { - return printError(stderr, err) - } - outcome, err := controller.AccountManager().Login(ctx, accounts.LoginRequest{ - AccountID: *accountID, - Alias: *alias, - Activate: *activate, - Overwrite: *overwrite, - }) - if err != nil { - return printError(stderr, err) - } - if *jsonOutput { - return writeJSON(stdout, outcome) - } - if outcome.Activated { - _, _ = fmt.Fprintf(stdout, "saved and activated account: %s\n", outcome.AccountID) - } else { - _, _ = fmt.Fprintf(stdout, "saved account: %s\n", outcome.AccountID) - } - return 0 -} - -func runAccountsStatus(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts status", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() > 1 { - return usageError(stderr, "accounts status accepts at most one account ID") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - if fs.NArg() == 1 { - status, err := controller.AccountManager().Status(fs.Arg(0)) - if err != nil { - return printError(stderr, err) - } - if *jsonOutput { - return writeJSON(stdout, status) - } - printProfileStatus(stdout, status) - return 0 - } - statuses, err := controller.AccountManager().List() - if err != nil { - return printError(stderr, err) - } - if *jsonOutput { - return writeJSON(stdout, statuses) - } - if len(statuses) == 0 { - _, _ = fmt.Fprintln(stdout, "no accounts configured") - return 0 - } - for _, status := range statuses { - printProfileStatus(stdout, status) - } - return 0 -} - -func runAccountsRename(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts rename", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - alias := fs.String("name", "", "new local profile alias") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 1 { - return usageError(stderr, "accounts rename requires exactly one account ID") - } - if strings.TrimSpace(*alias) == "" { - return usageError(stderr, "accounts rename requires --name ") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - if err := controller.AccountManager().Rename(fs.Arg(0), *alias); err != nil { - return printError(stderr, err) - } - _, _ = fmt.Fprintf(stdout, "renamed account: %s\n", fs.Arg(0)) - return 0 -} - -func runAccountsActivate(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts activate", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 1 { - return usageError(stderr, "accounts activate requires exactly one account ID") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - outcome, err := controller.AccountManager().Activate(context.Background(), fs.Arg(0)) - if err != nil { - return printError(stderr, err) - } - if *jsonOutput { - return writeJSON(stdout, outcome) - } - _, _ = fmt.Fprintf(stdout, "active account: %s\n", outcome.AccountID) - return 0 -} - -func runAccountsRefresh(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts refresh", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - runtimeEndpoint := fs.String("runtime", "", "embedded runtime endpoint for native refresh") - timeout := fs.Duration("timeout", 2*time.Minute, "maximum refresh duration") - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 1 { - return usageError(stderr, "accounts refresh requires exactly one account ID") - } - if strings.TrimSpace(*runtimeEndpoint) == "" { - return usageError(stderr, "accounts refresh requires --runtime so the integrated Codex AuthManager can be used") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - if err := attachAuthRuntime(ctx, controller, *runtimeEndpoint); err != nil { - return printError(stderr, err) - } - outcome, err := controller.AccountManager().Refresh(ctx, fs.Arg(0)) - if err != nil { - return printError(stderr, err) - } - if *jsonOutput { - return writeJSON(stdout, outcome) - } - _, _ = fmt.Fprintf(stdout, "refreshed account: %s at %s\n", outcome.AccountID, outcome.RefreshedAt.UTC().Format(time.RFC3339)) - return 0 -} - -func runAccountsRemove(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts remove", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - force := fs.Bool("force", false, "remove an active profile and clear its controller marker") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 1 { - return usageError(stderr, "accounts remove requires exactly one account ID") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - if err := controller.AccountManager().Remove(fs.Arg(0), *force); err != nil { - return printError(stderr, err) - } - _, _ = fmt.Fprintf(stdout, "removed account: %s\n", fs.Arg(0)) - return 0 -} - -func attachAuthRuntime(ctx context.Context, controller *app.Controller, endpoint string) error { - if controller == nil { - return errors.New("controller is nil") - } - if err := controller.ConnectRuntimeEndpoint(ctx, endpoint); err != nil { - return err - } - controller.SetAuthService(app.NewRuntimeAuthService(controller.RuntimeClient())) - return nil -} - -func printProfileStatus(out io.Writer, status accounts.ProfileStatus) { - active := "" - if status.Active { - active = " active" - } - credential := "missing" - if status.CredentialPresent { - credential = "present" - } - _, _ = fmt.Fprintf(out, "%s\t%s\tcredential=%s health=%s%s", status.AccountID, valueOr(status.Alias, "-"), credential, valueOr(string(status.CredentialHealth), "unknown"), active) - if status.SavedAt != nil { - _, _ = fmt.Fprintf(out, " saved=%s", status.SavedAt.UTC().Format(time.RFC3339)) - } - if status.TelemetrySource != "" { - _, _ = fmt.Fprintf(out, " telemetry=%s", status.TelemetrySource) - } - _, _ = fmt.Fprintln(out) -} diff --git a/controller/cmd/codexmarathon/accounts_commands_test.go b/controller/cmd/codexmarathon/accounts_commands_test.go deleted file mode 100644 index 5ef53124..00000000 --- a/controller/cmd/codexmarathon/accounts_commands_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestAccountsImportUsesExistingAuthWithoutRuntime(t *testing.T) { - root := t.TempDir() - authPath := filepath.Join(root, "codex", "auth.json") - if err := os.MkdirAll(filepath.Dir(authPath), 0o700); err != nil { - t.Fatal(err) - } - const secret = "opaque-stock-token" - if err := os.WriteFile(authPath, []byte(`{"auth_mode":"chatgpt","tokens":{"account_id":"stock-account","access_token":"`+secret+`"}}`), 0o600); err != nil { - t.Fatal(err) - } - - var stdout, stderr bytes.Buffer - code := run([]string{ - "accounts", "import", - "--state-dir", filepath.Join(root, "state"), - "--auth", authPath, - "--name", "personal", - }, &stdout, &stderr) - if code != 0 { - t.Fatalf("accounts import exit code = %d, stderr = %s", code, stderr.String()) - } - if got := stdout.String(); !strings.Contains(got, "imported and activated account: stock-account") { - t.Fatalf("accounts import output = %q", got) - } - if strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret) { - t.Fatalf("accounts import exposed auth material: stdout=%q stderr=%q", stdout.String(), stderr.String()) - } - if _, err := os.Stat(filepath.Join(root, "state", "credentials", "stock-account.json")); err != nil { - t.Fatalf("imported vault snapshot missing: %v", err) - } -} diff --git a/controller/cmd/codexmarathon/main.go b/controller/cmd/codexmarathon/main.go deleted file mode 100644 index 76eb3232..00000000 --- a/controller/cmd/codexmarathon/main.go +++ /dev/null @@ -1,486 +0,0 @@ -// Command codexmarathon is the decision-first operator CLI and one-command -// launcher for the user's installed Codex CLI. -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "strings" - "time" - - "codexmarathon/controller/app" - "codexmarathon/controller/internal/journal" - "codexmarathon/controller/internal/policy" - installedruntime "codexmarathon/controller/internal/runtime" - "codexmarathon/controller/internal/transitions" -) - -const commandUsage = `CodexMarathon controller - -Usage: - codexmarathon run [options] - codexmarathon start [options] - codexmarathon init [options] - codexmarathon status [options] - codexmarathon accounts list [options] - codexmarathon accounts login [options] - codexmarathon accounts add [options] - codexmarathon accounts import [options] - codexmarathon accounts status [account-id] [options] - codexmarathon accounts rename --name [options] - codexmarathon accounts activate [options] - codexmarathon accounts use [options] - codexmarathon accounts refresh --runtime [options] - codexmarathon accounts remove [options] - codexmarathon transition [options] - codexmarathon switch [options] - codexmarathon reconcile [transition-id] --runtime [options] - -Options are command-local. The launcher discovers the installed Codex CLI from ---codex, CODEX_BIN, or PATH. It prefers the installed app-server's user-scoped -local control socket and uses controlled restart/resume when live reload is not -available. A bundled Marathon runtime is used only with explicit runtime flags. -` - -func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) -} - -func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 || args[0] == "-h" || args[0] == "--help" { - _, _ = io.WriteString(stdout, commandUsage) - return 0 - } - switch args[0] { - case "run", "start": - return runCommand(args[1:], stdout, stderr) - case "init": - return runInit(args[1:], stdout, stderr) - case "status": - return runStatus(args[1:], stdout, stderr) - case "accounts": - return runAccounts(args[1:], stdout, stderr) - case "transition": - return runTransition(args[1:], stdout, stderr) - case "switch": - return runTransition(args[1:], stdout, stderr) - case "reconcile": - return runReconcile(args[1:], stdout, stderr) - default: - return usageError(stderr, fmt.Sprintf("unknown command %q", args[0])) - } -} - -type pathFlags struct { - stateDir string - registryPath string - vaultDir string - authPath string - journalPath string -} - -func (p *pathFlags) bind(fs *flag.FlagSet) { - defaults := app.DefaultConfig() - fs.StringVar(&p.stateDir, "state-dir", defaults.StateDir, "controller state directory") - fs.StringVar(&p.registryPath, "registry", "", "account registry path (default: state-dir/accounts.json)") - fs.StringVar(&p.vaultDir, "vault", "", "credential vault directory (default: state-dir/credentials)") - fs.StringVar(&p.authPath, "auth", defaults.AuthPath, "Codex auth.json path used for deployment/reconciliation") - fs.StringVar(&p.journalPath, "journal", "", "journal path (default: state-dir/events.jsonl)") -} - -func (p pathFlags) config() app.Config { - return app.Config{ - StateDir: p.stateDir, - RegistryPath: p.registryPath, - VaultDir: p.vaultDir, - AuthPath: p.authPath, - JournalPath: p.journalPath, - } -} - -func runInit(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("init", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 0 { - return usageError(stderr, "init does not accept positional arguments") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - if err := controller.EnsureLayout(); err != nil { - return printError(stderr, err) - } - _, _ = fmt.Fprintf(stdout, "initialized controller state at %s\n", controller.Config().StateDir) - return 0 -} - -func runStatus(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("status", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - runtimeEndpoint := fs.String("runtime", "", "optional runtime endpoint to query") - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 0 { - return usageError(stderr, "status does not accept positional arguments") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - if *runtimeEndpoint != "" { - ctx, cancel := context.WithTimeout(context.Background(), app.DefaultConnectTimeout) - err = controller.ConnectRuntimeEndpoint(ctx, *runtimeEndpoint) - cancel() - if err != nil { - return printError(stderr, err) - } - } - accounts, err := controller.Registry().List() - if err != nil { - return printError(stderr, err) - } - telemetry, err := controller.AccountsTelemetry() - if err != nil { - return printError(stderr, err) - } - decision, err := controller.EvaluatePolicy(time.Now().UTC()) - if err != nil { - return printError(stderr, err) - } - active, activeSet, err := controller.Registry().Active() - if err != nil { - return printError(stderr, err) - } - status := statusView{ - StateDir: controller.Config().StateDir, - RegistryPath: controller.Config().RegistryPath, - VaultDir: controller.Config().VaultDir, - AuthPath: controller.Config().AuthPath, - JournalPath: controller.Config().JournalPath, - AccountCount: len(accounts), - TelemetryAccounts: len(telemetry), - RuntimeConnected: controller.RuntimeConnected(), - Policy: makeDecisionView(decision), - } - if activeSet { - status.ActiveAccountID = active.ID - } - if controller.RuntimeConnected() { - state, stateErr := controller.RuntimeState(context.Background()) - if stateErr == nil { - status.Runtime = &runtimeView{ - RuntimeID: state.Identity.RuntimeID, - AccountID: optionalID(state.Identity.AccountID), - AuthGeneration: state.Identity.AuthGeneration, - ActiveTurnCount: state.ActiveTurnCount, - PendingTransition: state.PendingTransition != nil, - } - } else { - status.RuntimeError = stateErr.Error() - } - } - if *jsonOutput { - return writeJSON(stdout, status) - } - _, _ = fmt.Fprintf(stdout, "state: %s\naccounts: %d\nactive: %s\ntelemetry observations: %d\npolicy: %s\n", status.StateDir, status.AccountCount, valueOr(status.ActiveAccountID, "none"), status.TelemetryAccounts, status.Policy.Type) - if status.Runtime != nil { - _, _ = fmt.Fprintf(stdout, "runtime: %s account=%s generation=%d turns=%d pending=%t\n", status.Runtime.RuntimeID, valueOr(status.Runtime.AccountID, "none"), status.Runtime.AuthGeneration, status.Runtime.ActiveTurnCount, status.Runtime.PendingTransition) - } else if status.RuntimeError != "" { - _, _ = fmt.Fprintf(stdout, "runtime: unavailable (%s)\n", status.RuntimeError) - } else { - _, _ = fmt.Fprintln(stdout, "runtime: not connected") - } - if status.Policy.Reason != "" { - _, _ = fmt.Fprintf(stdout, "policy reason: %s\n", status.Policy.Reason) - } - return 0 -} - -func runAccountsList(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("accounts list", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 0 { - return usageError(stderr, "accounts list does not accept positional arguments") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - accounts, err := controller.Registry().List() - if err != nil { - return printError(stderr, err) - } - if *jsonOutput { - return writeJSON(stdout, accounts) - } - if len(accounts) == 0 { - _, _ = fmt.Fprintln(stdout, "no accounts configured") - return 0 - } - for _, account := range accounts { - health := string(account.CredentialHealth) - _, _ = fmt.Fprintf(stdout, "%s\t%s\tcredential=%s\ttelemetry=%s\n", account.ID, valueOr(account.Alias, "-"), valueOr(health, "unknown"), valueOr(account.TelemetrySource, "none")) - } - return 0 -} - -func runTransition(args []string, stdout, stderr io.Writer) int { - if !hasRunFlag(args, "--runtime") { - return runInstalledTransition(args, stdout, stderr) - } - return runManagedTransition(args, stdout, stderr) -} - -// runManagedTransition retains the explicit legacy Marathon protocol path. -// It is selected only when --runtime is supplied; normal transition/switch -// commands use the installed Codex app-server below. -func runManagedTransition(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("transition", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - runtimeEndpoint := fs.String("runtime", "", "runtime endpoint (tcp://host:port or host:port)") - timeout := fs.Duration("timeout", 2*time.Minute, "maximum transition duration") - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 1 { - return usageError(stderr, "transition requires exactly one target account id") - } - if strings.TrimSpace(*runtimeEndpoint) == "" { - return usageError(stderr, "transition requires --runtime") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - if err := controller.ConnectRuntimeEndpoint(ctx, *runtimeEndpoint); err != nil { - return printError(stderr, err) - } - result, err := controller.RequestTransition(ctx, fs.Arg(0)) - if result != nil && *jsonOutput { - _ = writeJSON(stdout, result) - } else if result != nil { - _, _ = fmt.Fprintf(stdout, "transition %s: %s account=%s generation=%d\n", result.TransitionID, result.Outcome, valueOr(result.AdoptedAccountID, "unknown"), result.FinalGeneration) - } - if err != nil { - return printError(stderr, err) - } - return 0 -} - -func runInstalledTransition(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("transition", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - codexPath := fs.String("codex", "", "installed Codex executable (default: codex on PATH or CODEX_BIN)") - codexHome := fs.String("codex-home", "", "Codex home containing the app-server control socket") - controlEndpoint := fs.String("app-server-endpoint", "", "explicit installed app-server endpoint") - timeout := fs.Duration("timeout", 2*time.Minute, "maximum transition duration") - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 1 { - return usageError(stderr, "transition requires exactly one target account id") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - installation, err := app.DiscoverInstalledCodex(ctx, installedDiscoveryOptions(*codexPath, *codexHome)) - if err != nil { - return printError(stderr, err) - } - endpoint := strings.TrimSpace(*controlEndpoint) - if endpoint == "" { - endpoint = installation.ControlSocket - } - client, err := app.ConnectInstalledCodexEndpoint(ctx, endpoint) - if err != nil { - return printError(stderr, fmt.Errorf("connect installed Codex app-server at %s: %w; start Codex through `codexmarathon run` to enable controlled restart/resume when live reload is unavailable", endpoint, err)) - } - defer func() { _ = client.Close() }() - result, err := controller.TransitionInstalled(ctx, client, fs.Arg(0)) - if result.AccountID != "" && *jsonOutput { - _ = writeJSON(stdout, result) - } else if result.AccountID != "" { - _, _ = fmt.Fprintf(stdout, "transition: mode=%s account=%s verified=%t\n", result.Mode, result.AccountID, result.IdentityVerified) - } - if err != nil { - return printError(stderr, err) - } - return 0 -} - -func installedDiscoveryOptions(codexPath, codexHome string) installedruntime.DiscoveryOptions { - return installedruntime.DiscoveryOptions{Executable: codexPath, CodexHome: codexHome} -} - -func runReconcile(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("reconcile", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - runtimeEndpoint := fs.String("runtime", "", "runtime endpoint (tcp://host:port or host:port)") - timeout := fs.Duration("timeout", 30*time.Second, "maximum reconciliation duration") - jsonOutput := fs.Bool("json", false, "print machine-readable JSON") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() > 1 { - return usageError(stderr, "reconcile accepts at most one transition id") - } - if strings.TrimSpace(*runtimeEndpoint) == "" { - return usageError(stderr, "reconcile requires --runtime") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - if err := controller.ConnectRuntimeEndpoint(ctx, *runtimeEndpoint); err != nil { - return printError(stderr, err) - } - transitionID := "" - if fs.NArg() == 1 { - transitionID = fs.Arg(0) - } - result, err := controller.Reconcile(ctx, transitionID) - if result != nil && *jsonOutput { - _ = writeJSON(stdout, result) - } else if result != nil { - _, _ = fmt.Fprintf(stdout, "reconciliation %s: %s account=%s generation=%d\n", result.TransitionID, result.Outcome, valueOr(result.AdoptedAccountID, "unknown"), result.FinalGeneration) - } - if err != nil { - return printError(stderr, err) - } - return 0 -} - -type statusView struct { - StateDir string `json:"state_dir"` - RegistryPath string `json:"registry_path"` - VaultDir string `json:"vault_dir"` - AuthPath string `json:"auth_path"` - JournalPath string `json:"journal_path"` - AccountCount int `json:"account_count"` - ActiveAccountID string `json:"active_account_id,omitempty"` - TelemetryAccounts int `json:"telemetry_accounts"` - RuntimeConnected bool `json:"runtime_connected"` - Runtime *runtimeView `json:"runtime,omitempty"` - RuntimeError string `json:"runtime_error,omitempty"` - Policy decisionView `json:"policy"` -} - -type runtimeView struct { - RuntimeID string `json:"runtime_id"` - AccountID string `json:"account_id,omitempty"` - AuthGeneration uint64 `json:"auth_generation"` - ActiveTurnCount int `json:"active_turn_count"` - PendingTransition bool `json:"pending_transition"` -} - -type decisionView struct { - Type string `json:"type"` - AccountID string `json:"account_id,omitempty"` - CandidateAccountID string `json:"candidate_account_id,omitempty"` - LimitID string `json:"limit_id,omitempty"` - DurationMins int `json:"duration_mins,omitempty"` - ResetAt string `json:"reset_at,omitempty"` - RefetchRequired bool `json:"refetch_required"` - Reason string `json:"reason,omitempty"` -} - -func makeDecisionView(decision policy.PolicyDecision) decisionView { - view := decisionView{ - Type: decision.Type.String(), - AccountID: decision.AccountID, - CandidateAccountID: decision.CandidateAccountID, - LimitID: decision.LimitID, - DurationMins: decision.DurationMins, - RefetchRequired: decision.RefetchRequired, - Reason: decision.Reason, - } - if !decision.ResetAt.IsZero() { - view.ResetAt = decision.ResetAt.UTC().Format(time.RFC3339) - } - return view -} - -func optionalID(value *string) string { - if value == nil { - return "" - } - return *value -} - -func valueOr(value, fallback string) string { - if strings.TrimSpace(value) == "" { - return fallback - } - return value -} - -func writeJSON(writer io.Writer, value any) int { - encoder := json.NewEncoder(writer) - encoder.SetIndent("", " ") - if err := encoder.Encode(value); err != nil { - return printError(os.Stderr, err) - } - return 0 -} - -func usageError(stderr io.Writer, message string) int { - _, _ = fmt.Fprintf(stderr, "error: %s\n\n%s", message, commandUsage) - return 2 -} - -func printError(stderr io.Writer, err error) int { - if err == nil { - return 0 - } - _, _ = fmt.Fprintf(stderr, "error: %s\n", err) - if errors.Is(err, transitions.ErrReconciliationNeeded) { - _, _ = fmt.Fprintln(stderr, "hint: inspect runtime/disk state and retry `reconcile` after reconnecting the same runtime") - } - return 1 -} - -// Keep the journal package in the command's static dependency graph even when -// a build strips optional status output. This compile-time assertion also -// documents that RuntimeConnected/Disconnected records use the shared type. -var _ journal.EventType = journal.RuntimeConnected diff --git a/controller/cmd/codexmarathon/run_command.go b/controller/cmd/codexmarathon/run_command.go deleted file mode 100644 index 214d2b90..00000000 --- a/controller/cmd/codexmarathon/run_command.go +++ /dev/null @@ -1,629 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "os/signal" - "strings" - "sync" - "time" - - "codexmarathon/controller/app" - "codexmarathon/controller/internal/automation" - installedruntime "codexmarathon/controller/internal/runtime" -) - -// runCommand is the one-command operational entry point. The default path is -// the user's installed `codex` CLI. The legacy bundled-runtime path remains -// available only when an explicit runtime endpoint/command is supplied, so a -// normal companion install can never accidentally start a second Codex. -func runCommand(args []string, stdout, stderr io.Writer) int { - if hasRunFlag(args, "--runtime-command") || hasRunFlag(args, "--runtime-endpoint") || hasRunFlag(args, "--runtime") || hasRunFlag(args, "--runtime-arg") { - return runManagedRuntimeCommand(args, stdout, stderr) - } - return runInstalledCompanionCommand(args, stdout, stderr) -} - -// runManagedRuntimeCommand is the explicit opt-in path for the imported -// Marathon protocol/runtime fixture. It is intentionally not the product -// default and is kept separate from installed-Codex argument handling. -func runManagedRuntimeCommand(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("run", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - endpoint := fs.String("runtime-endpoint", "", "managed local runtime endpoint (default: user-scoped Unix socket or Windows named pipe)") - fs.StringVar(endpoint, "runtime", "", "alias for --runtime-endpoint") - runtimeCommand := fs.String("runtime-command", "", "bundled runtime executable override") - runtimeDir := fs.String("runtime-dir", "", "working directory for the bundled runtime") - diagnostics := fs.String("diagnostics", "", "redacted lifecycle diagnostics path") - healthInterval := fs.Duration("health-interval", 5*time.Second, "runtime health-check interval") - connectTimeout := fs.Duration("connect-timeout", 15*time.Second, "runtime readiness timeout") - reconnectInterval := fs.Duration("reconnect-interval", 750*time.Millisecond, "initial reconnect delay") - reconnectMax := fs.Duration("reconnect-max", 8*time.Second, "maximum reconnect delay/attempt window") - shutdownTimeout := fs.Duration("shutdown-timeout", 10*time.Second, "graceful runtime shutdown timeout") - noAutoRestart := fs.Bool("no-auto-restart", false, "leave the command stopped after an unexpected runtime exit") - var runtimeArgs stringListFlag - fs.Var(&runtimeArgs, "runtime-arg", "argument passed to the bundled runtime (repeatable)") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 0 { - return usageError(stderr, "run does not accept positional arguments; use --runtime-arg for bundled runtime arguments") - } - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - config := app.SupervisorConfig{ - Endpoint: *endpoint, - RuntimeArgs: append([]string(nil), runtimeArgs...), - RuntimeDir: *runtimeDir, - HealthInterval: *healthInterval, - ConnectTimeout: *connectTimeout, - ReconnectInterval: *reconnectInterval, - ReconnectMax: *reconnectMax, - ShutdownTimeout: *shutdownTimeout, - DiagnosticsPath: *diagnostics, - DisableAutoRestart: *noAutoRestart, - } - if strings.TrimSpace(*runtimeCommand) != "" { - config.RuntimeCommand = []string{*runtimeCommand} - } - supervisor, err := app.NewRuntimeSupervisor(controller, config) - if err != nil { - return printError(stderr, err) - } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - controlServer, err := app.NewControllerControlServer(controller, app.ControllerControlServerConfig{}) - if err != nil { - return printError(stderr, err) - } - if err := controlServer.Start(ctx); err != nil { - return printError(stderr, err) - } - defer func() { _ = controlServer.Close() }() - if err := supervisor.Start(ctx); err != nil { - return printError(stderr, err) - } - status := supervisor.Status() - _, _ = fmt.Fprintf(stdout, "codexmarathon runtime ready: endpoint=%s protocol=%d pid=%d control=%s\n", status.Endpoint, status.ProtocolVersion, status.PID, controlServer.Endpoint()) - monitorDone := make(chan struct{}) - go func() { - _ = supervisor.Wait() - close(monitorDone) - }() - select { - case <-ctx.Done(): - if err := supervisor.Stop(); err != nil { - return printError(stderr, err) - } - return 0 - case <-monitorDone: - status := supervisor.Status() - if status.State == app.LifecycleFailed { - return printError(stderr, fmt.Errorf("runtime supervisor stopped: %s", status.LastError)) - } - } - return 0 -} - -// runInstalledCompanionCommand discovers and attaches to the existing Codex -// app-server when it is already running. If no control socket is available it -// launches the same installed executable under CompanionSupervisor; that -// establishes ownership for the controlled restart/resume fallback. -func runInstalledCompanionCommand(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("run", flag.ContinueOnError) - fs.SetOutput(stderr) - var paths pathFlags - paths.bind(fs) - codexPath := fs.String("codex", "", "installed Codex executable (default: codex on PATH or CODEX_BIN)") - codexHome := fs.String("codex-home", "", "Codex home containing the app-server control socket (default: CODEX_HOME or ~/.codex)") - workingDir := fs.String("codex-dir", "", "working directory for the installed Codex process") - controlEndpoint := fs.String("app-server-endpoint", "", "explicit installed app-server endpoint (default: Codex home control socket)") - attachOnly := fs.Bool("attach-only", false, "fail when no running installed app-server can be attached") - connectTimeout := fs.Duration("connect-timeout", 15*time.Second, "maximum installed app-server discovery/launch wait") - shutdownTimeout := fs.Duration("shutdown-timeout", 10*time.Second, "graceful installed Codex shutdown timeout") - var codexArgs stringListFlag - fs.Var(&codexArgs, "codex-arg", "argument passed to installed Codex (repeatable; positional arguments are also forwarded)") - if err := fs.Parse(args); err != nil { - return 2 - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - controller, err := app.New(paths.config()) - if err != nil { - return printError(stderr, err) - } - defer func() { _ = controller.Close() }() - if err := controller.EnsureLayout(); err != nil { - return printError(stderr, err) - } - installation, err := app.DiscoverInstalledCodex(ctx, installedruntime.DiscoveryOptions{ - Executable: *codexPath, - CodexHome: *codexHome, - }) - if err != nil { - return printError(stderr, err) - } - - endpoint := strings.TrimSpace(*controlEndpoint) - if endpoint == "" { - endpoint = installation.ControlSocket - } - launchConnectTimeout := *connectTimeout - if launchConnectTimeout <= 0 { - launchConnectTimeout = 15 * time.Second - } - initialAttachTimeout := 500 * time.Millisecond - if launchConnectTimeout < initialAttachTimeout { - initialAttachTimeout = launchConnectTimeout - } - attachCtx, attachCancel := context.WithTimeout(ctx, initialAttachTimeout) - client, attachErr := connectInstalledUntil(attachCtx, installation, endpoint) - attachCancel() - if attachErr == nil { - _, _ = fmt.Fprintf(stdout, "codexmarathon attached to installed codex: executable=%s version=%s mode=live-reload endpoint=%s\n", installation.Executable, valueOr(installation.Version, "unknown"), endpoint) - return runInstalledCompanionSession(ctx, controller, installation, endpoint, client, nil, launchConnectTimeout, stdout, stderr) - } - if *attachOnly { - return printError(stderr, fmt.Errorf("attach to installed Codex app-server at %s: %w", endpoint, attachErr)) - } - - command := []string{installation.Executable} - command = append(command, codexArgs...) - command = append(command, fs.Args()...) - controlEndpointHint, err := app.DefaultControllerControlEndpoint(controller.Config().StateDir) - if err != nil { - return printError(stderr, err) - } - environment := []string{ - "CODEX_HOME=" + installation.CodexHome, - app.ControllerControlEnv + "=" + controlEndpointHint, - } - supervisor, err := app.NewCompanionSupervisor(app.CompanionConfig{ - Command: command, - WorkingDir: *workingDir, - Environment: environment, - ShutdownTimeout: *shutdownTimeout, - }) - if err != nil { - return printError(stderr, err) - } - if err := supervisor.Start(); err != nil { - return printError(stderr, err) - } - status := supervisor.Status() - _, _ = fmt.Fprintf(stdout, "codexmarathon started installed codex: executable=%s version=%s pid=%d mode=restart-resume-until-control-available\n", installation.Executable, valueOr(installation.Version, "unknown"), status.PID) - - // App-server startup is independent of the interactive CLI process. Give - // it a bounded opportunity to expose the live control socket, but keep the - // process usable when this Codex version has no local control interface. - readyCtx, readyCancel := context.WithTimeout(ctx, launchConnectTimeout) - client, readyErr := connectInstalledUntilProcess(readyCtx, installation, endpoint, supervisor) - readyCancel() - if readyErr == nil { - _, _ = fmt.Fprintf(stdout, "codexmarathon installed app-server ready: version=%s mode=live-reload\n", valueOr(client.Version(), valueOr(installation.Version, "unknown"))) - } else { - _, _ = fmt.Fprintf(stdout, "codexmarathon installed app-server unavailable: %v; controlled restart/resume fallback remains available\n", readyErr) - } - return runInstalledCompanionSession(ctx, controller, installation, endpoint, client, supervisor, launchConnectTimeout, stdout, stderr) -} - -// runInstalledCompanionSession owns the user-facing automation boundary for -// an installed Codex process. Notifications feed the policy loop, and its -// transition callback chooses account/read live reload first, then the -// companion-owned restart/resume supervisor when the app-server cannot reload -// credentials in place. -func runInstalledCompanionSession(ctx context.Context, controller *app.Controller, installation installedruntime.Installation, endpoint string, initial *installedruntime.InstalledClient, supervisor *app.CompanionSupervisor, connectTimeout time.Duration, stdout, stderr io.Writer) int { - if ctx == nil { - ctx = context.Background() - } - if controller == nil { - return printError(stderr, errors.New("controller is unavailable for installed Codex automation")) - } - if connectTimeout <= 0 { - connectTimeout = 15 * time.Second - } - holder := &installedSessionState{client: initial} - defer func() { - _ = holder.Close() - if supervisor != nil { - _ = supervisor.Stop(context.Background()) - } - }() - provider := app.NewInstalledQuotaProvider(holder.Client) - activeID, err := controller.Registry().ActiveID() - if err != nil { - return printError(stderr, err) - } - if activeID != "" { - if err := controller.RegisterUsageProvider(activeID, provider); err != nil { - return printError(stderr, err) - } - } - - // A restart briefly transitions Stopping -> Stopped -> Starting. The - // lifecycle monitor takes this mutex before treating a process exit as the - // end of the user-facing command, so an intentional fallback restart cannot - // race the old process's Wait result. - var transitionMu sync.Mutex - switchTarget := func(transitionCtx context.Context, accountID string) (app.ControlSwitchResult, error) { - transitionMu.Lock() - defer transitionMu.Unlock() - threadID := holder.ThreadID() - resume := app.CompanionResumeTarget{ThreadID: threadID, Last: threadID == ""} - result, err := controller.TransitionInstalledOrRestart(transitionCtx, holder.Client(), supervisor, accountID, resume) - if err != nil { - return app.ControlSwitchResult{}, err - } - if err := controller.RegisterUsageProvider(accountID, provider); err != nil { - return app.ControlSwitchResult{}, err - } - if cache := controller.Cache(); cache != nil { - cache.Invalidate(accountID) - } - controlResult := app.ControlSwitchResult{ - AccountID: result.AccountID, - Outcome: "committed", - Mode: string(result.Mode), - RuntimeID: "installed-codex", - IdentityVerified: result.IdentityVerified, - } - if result.Mode != app.InstalledTransitionRestart { - return controlResult, nil - } - - connectCtx, cancel := context.WithTimeout(transitionCtx, connectTimeout) - next, connectErr := connectInstalledUntil(connectCtx, installation, endpoint) - cancel() - if connectErr != nil { - return app.ControlSwitchResult{}, fmt.Errorf("reconnect installed Codex app-server after restart: %w", connectErr) - } - // The initialize handshake proves the control endpoint is alive. Read - // and compare the deployed auth identity before replacing the - // provider's connection or confirming the policy transition. - if _, verifyErr := controller.VerifyInstalledIdentity(transitionCtx, next, accountID); verifyErr != nil { - _ = next.Close() - return app.ControlSwitchResult{}, fmt.Errorf("verify resumed installed Codex identity: %w", verifyErr) - } - holder.SetClient(next) - controlResult.IdentityVerified = true - return controlResult, nil - } - transition := func(transitionCtx context.Context, accountID string) error { - _, err := switchTarget(transitionCtx, accountID) - return err - } - - controlServer, err := app.NewControllerControlServer(controller, app.ControllerControlServerConfig{ - Switch: switchTarget, - }) - if err != nil { - return printError(stderr, err) - } - if err := controlServer.Start(ctx); err != nil { - return printError(stderr, err) - } - defer func() { _ = controlServer.Close() }() - _, _ = fmt.Fprintf(stdout, "codexmarathon controller control ready: endpoint=%s\n", controlServer.Endpoint()) - - loop, err := controller.NewAutomationLoopWithTransition(transition) - if err != nil { - return printError(stderr, err) - } - events := make(chan automation.Event, 256) - loopCtx, cancelLoop := context.WithCancel(ctx) - defer cancelLoop() - loopDone := make(chan struct{}) - go func() { - defer close(loopDone) - if loopErr := loop.Run(loopCtx, events); loopErr != nil && !errors.Is(loopErr, context.Canceled) { - _, _ = fmt.Fprintf(stderr, "codexmarathon installed automation stopped: %v\n", loopErr) - } - }() - - // Evaluate once at startup so a configured active account gets a fresh - // snapshot and a first-run installation can select an eligible account. - events <- automation.Event{Type: automation.EventTelemetryChanged, OccurredAt: time.Now().UTC()} - go pumpInstalledNotifications(loopCtx, holder, controller, events) - - // Keep non-fatal provider/policy errors visible while preserving Codex's - // interactive stdout/stderr streams. Errors are already redacted by the - // controller's event boundary. - errorDone := make(chan struct{}) - go func() { - defer close(errorDone) - diagnostics := controller.EventErrors() - for { - select { - case err := <-diagnostics: - if err != nil { - _, _ = fmt.Fprintf(stderr, "codexmarathon installed automation: %v\n", err) - } - case <-loopCtx.Done(): - return - } - } - }() - - var waitDone chan error - if supervisor != nil { - waitDone = make(chan error, 1) - go func() { waitDone <- supervisor.Wait(context.Background()) }() - } - for { - select { - case <-ctx.Done(): - cancelLoop() - _ = holder.Close() - if supervisor != nil { - transitionMu.Lock() - stopErr := supervisor.Stop(context.Background()) - transitionMu.Unlock() - if stopErr != nil { - return printError(stderr, stopErr) - } - } - <-loopDone - <-errorDone - return 0 - case waitErr := <-waitDone: - // A controlled restart consumes the old Wait result. Once the - // transition callback releases the mutex, inspect the new state - // before deciding whether the command really ended. - transitionMu.Lock() - status := supervisor.Status() - transitionMu.Unlock() - if status.State == app.CompanionRunning || status.State == app.CompanionStarting { - waitDone = make(chan error, 1) - go func() { waitDone <- supervisor.Wait(context.Background()) }() - continue - } - cancelLoop() - _ = holder.Close() - <-loopDone - <-errorDone - if waitErr != nil { - return printError(stderr, fmt.Errorf("installed Codex stopped: %w", waitErr)) - } - return 0 - } - } -} - -type installedSessionState struct { - mu sync.RWMutex - client *installedruntime.InstalledClient - threadID string -} - -func (s *installedSessionState) Client() *installedruntime.InstalledClient { - if s == nil { - return nil - } - s.mu.RLock() - client := s.client - s.mu.RUnlock() - return client -} - -func (s *installedSessionState) SetClient(client *installedruntime.InstalledClient) { - if s == nil { - return - } - s.mu.Lock() - s.client = client - s.mu.Unlock() -} - -func (s *installedSessionState) ClearClient(client *installedruntime.InstalledClient) { - if s == nil { - return - } - s.mu.Lock() - if s.client == client { - s.client = nil - } - s.mu.Unlock() -} - -func (s *installedSessionState) ThreadID() string { - if s == nil { - return "" - } - s.mu.RLock() - threadID := s.threadID - s.mu.RUnlock() - return threadID -} - -func (s *installedSessionState) SetThreadID(threadID string) { - if s == nil || strings.TrimSpace(threadID) == "" { - return - } - s.mu.Lock() - s.threadID = strings.TrimSpace(threadID) - s.mu.Unlock() -} - -func (s *installedSessionState) Close() error { - if s == nil { - return nil - } - client := s.Client() - if client == nil { - return nil - } - return client.Close() -} - -func pumpInstalledNotifications(ctx context.Context, holder *installedSessionState, controller *app.Controller, events chan<- automation.Event) { - if ctx == nil { - ctx = context.Background() - } - for { - select { - case <-ctx.Done(): - return - default: - } - client := holder.Client() - if client == nil { - select { - case <-ctx.Done(): - return - case <-time.After(100 * time.Millisecond): - } - continue - } - notifications := client.Notifications() - if notifications == nil { - holder.ClearClient(client) - continue - } - notificationLoop: - for { - select { - case <-ctx.Done(): - return - case notification, ok := <-notifications: - if !ok { - holder.ClearClient(client) - continue notificationLoop - } - accountID := "" - if controller != nil && controller.Registry() != nil { - accountID, _ = controller.Registry().ActiveID() - } - if notification.Method == "account/rateLimits/updated" && accountID != "" && controller != nil { - // Preserve the event even when a future server sends an - // ambiguous sparse shape; the automation loop will report the - // provider failure instead of authorizing a stale switch. - _ = controller.IngestInstalledRateLimitsUpdate(ctx, accountID, notification.Params) - } - event, threadID, emit := app.InstalledNotificationAutomationEvent(notification, accountID, time.Now().UTC()) - if threadID != "" { - holder.SetThreadID(threadID) - } - if !emit { - continue - } - select { - case events <- event: - case <-ctx.Done(): - return - } - } - } - } -} - -func connectInstalledUntil(ctx context.Context, installation installedruntime.Installation, endpoint string) (*installedruntime.InstalledClient, error) { - if ctx == nil { - ctx = context.Background() - } - if strings.TrimSpace(endpoint) == "" { - endpoint = installation.ControlSocket - } - var lastErr error - delay := 100 * time.Millisecond - for { - client, err := app.ConnectInstalledCodexEndpoint(ctx, endpoint) - if err == nil { - return client, nil - } - lastErr = err - select { - case <-ctx.Done(): - if lastErr != nil { - return nil, fmt.Errorf("%w: %v", ctx.Err(), lastErr) - } - return nil, ctx.Err() - case <-time.After(delay): - } - if delay < time.Second { - delay *= 2 - if delay > time.Second { - delay = time.Second - } - } - } -} - -// connectInstalledUntilProcess is the launch variant of the bounded connect -// loop. A CLI that exits before exposing a control socket should be reported -// immediately rather than making the companion wait for the entire readiness -// timeout. It only reads supervisor status; process ownership remains with the -// session lifecycle below. -func connectInstalledUntilProcess(ctx context.Context, installation installedruntime.Installation, endpoint string, supervisor *app.CompanionSupervisor) (*installedruntime.InstalledClient, error) { - if supervisor == nil { - return connectInstalledUntil(ctx, installation, endpoint) - } - if ctx == nil { - ctx = context.Background() - } - var lastErr error - delay := 100 * time.Millisecond - for { - status := supervisor.Status() - if status.State == app.CompanionStopped || status.State == app.CompanionFailed { - if lastErr == nil { - lastErr = errors.New("installed Codex exited before its app-server became available") - } - return nil, fmt.Errorf("%w: %v", installedruntime.ErrAppServerUnavailable, lastErr) - } - client, err := app.ConnectInstalledCodexEndpoint(ctx, endpoint) - if err == nil { - return client, nil - } - lastErr = err - select { - case <-ctx.Done(): - if lastErr != nil { - return nil, fmt.Errorf("%w: %v", ctx.Err(), lastErr) - } - return nil, ctx.Err() - case <-time.After(delay): - } - if delay < time.Second { - delay *= 2 - if delay > time.Second { - delay = time.Second - } - } - } -} - -func hasRunFlag(args []string, name string) bool { - for _, arg := range args { - if arg == name || strings.HasPrefix(arg, name+"=") { - return true - } - } - return false -} - -type stringListFlag []string - -func (f *stringListFlag) String() string { - if f == nil { - return "" - } - return strings.Join(*f, " ") -} - -func (f *stringListFlag) Set(value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("runtime argument must not be empty") - } - *f = append(*f, value) - return nil -} diff --git a/controller/cmd/codexmarathon/run_command_test.go b/controller/cmd/codexmarathon/run_command_test.go deleted file mode 100644 index 5dcfc037..00000000 --- a/controller/cmd/codexmarathon/run_command_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "testing" - "time" - - "codexmarathon/controller/app" - "codexmarathon/controller/internal/automation" - "codexmarathon/controller/internal/policy" - installedruntime "codexmarathon/controller/internal/runtime" - "codexmarathon/controller/internal/telemetry" -) - -func TestInstalledNotificationEventsDriveAccountTransitions(t *testing.T) { - now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC) - for _, test := range []struct { - name string - notification installedruntime.InstalledNotification - wantType automation.EventType - wantThread string - }{ - { - name: "rate limit update", - notification: installedruntime.InstalledNotification{ - Method: "account/rateLimits/updated", - Params: json.RawMessage(`{"rateLimits":{"primary":{"usedPercent":95}}}`), - }, - wantType: automation.EventTelemetryChanged, - }, - { - name: "usage limit turn completion", - notification: installedruntime.InstalledNotification{ - Method: "turn/completed", - Params: json.RawMessage(`{"threadId":"thread-1","turn":{"id":"turn-1","status":"failed","error":{"code":"usage_limit_exceeded"}}}`), - }, - wantType: automation.EventUsageLimitExceeded, - wantThread: "thread-1", - }, - } { - t.Run(test.name, func(t *testing.T) { - event, threadID, ok := app.InstalledNotificationAutomationEvent(test.notification, "account-a", now) - if !ok { - t.Fatal("notification was not converted into an automation event") - } - if event.Type != test.wantType { - t.Fatalf("event type = %q, want %q", event.Type, test.wantType) - } - if threadID != test.wantThread { - t.Fatalf("thread ID = %q, want %q", threadID, test.wantThread) - } - - router := telemetry.NewMultiAccountUsageRouter(nil, telemetry.NewStateStore(func() time.Time { return now })) - if err := router.Register("account-a", telemetry.ProviderFunc(func(context.Context, string) (telemetry.SnapshotResult, error) { - usage := 10.0 - if test.wantType == automation.EventTelemetryChanged { - usage = 95 - } - return telemetry.UsableSnapshot(installedTestAccount("account-a", usage, now)), nil - })); err != nil { - t.Fatal(err) - } - if err := router.Register("account-b", telemetry.ProviderFunc(func(context.Context, string) (telemetry.SnapshotResult, error) { - return telemetry.UsableSnapshot(installedTestAccount("account-b", 10, now)), nil - })); err != nil { - t.Fatal(err) - } - - var transitionedTo string - loop := automation.New(automation.Config{ - Router: router, - Policy: policy.NewConfiguredEngine(policy.EngineConfig{FreshnessTTL: time.Hour}, nil, func() time.Time { return now }), - Accounts: automation.AccountSource{ - IDs: func() ([]string, error) { return []string{"account-a", "account-b"}, nil }, - Active: func() (string, error) { return "account-a", nil }, - }, - Transition: func(_ context.Context, accountID string) error { - transitionedTo = accountID - return nil - }, - Thresholds: policy.DefaultThresholds(), - FreshnessTTL: time.Hour, - Now: func() time.Time { return now }, - }) - result, err := loop.Evaluate(context.Background(), event) - if err != nil { - t.Fatal(err) - } - if !result.TransitionRan || transitionedTo != "account-b" { - t.Fatalf("event did not execute replacement transition: result=%#v target=%q", result, transitionedTo) - } - }) - } -} - -func installedTestAccount(accountID string, usage float64, observedAt time.Time) telemetry.AccountTelemetry { - return telemetry.AccountTelemetry{ - AccountID: accountID, - IsUsable: true, - ObservedAt: observedAt, - Limits: map[string]telemetry.LimitTelemetry{ - "codex": { - LimitID: "codex", - Windows: []telemetry.WindowTelemetry{{ - Kind: telemetry.PrimaryWindow, - UsedPercent: usage, - ObservedAt: observedAt, - Freshness: telemetry.WindowFresh, - }}, - }, - }, - } -} diff --git a/controller/go.mod b/controller/go.mod deleted file mode 100644 index 09b7a6ee..00000000 --- a/controller/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module codexmarathon/controller - -go 1.22 diff --git a/controller/internal/accounts/accounts.go b/controller/internal/accounts/accounts.go deleted file mode 100644 index cc85d394..00000000 --- a/controller/internal/accounts/accounts.go +++ /dev/null @@ -1,670 +0,0 @@ -// Package accounts owns the durable controller-side account registry. -// -// An Account is identity and metadata only. Credential material belongs to -// credentials.FileVault and is referenced by CredentialRef; keeping these -// domains separate prevents registry reads and journal entries from becoming -// accidental token stores. -package accounts - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -const registryVersion = 1 - -var ( - ErrInvalidAccountID = errors.New("invalid account id") - ErrAccountNotFound = errors.New("account not found") - ErrAccountExists = errors.New("account already exists") - ErrActiveAccount = errors.New("account is active") - ErrInvalidRegistry = errors.New("invalid account registry") -) - -// CredentialHealth records the controller's knowledge about the referenced -// snapshot. It is deliberately not a credential validity claim made by the -// runtime; it is a small operator-facing state marker. -type CredentialHealth string - -const ( - CredentialHealthUnknown CredentialHealth = "unknown" - CredentialHealthHealthy CredentialHealth = "healthy" - CredentialHealthStale CredentialHealth = "stale" - CredentialHealthInvalid CredentialHealth = "invalid" -) - -// Account is the non-secret registry record for one configured identity. -// Metadata values must be descriptive only; raw auth JSON and token fields are -// intentionally not representable here. -type Account struct { - ID string `json:"id"` - Alias string `json:"alias,omitempty"` - CredentialRef string `json:"credential_ref,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - LastTelemetryAt *time.Time `json:"last_telemetry_at,omitempty"` - TelemetrySource string `json:"telemetry_source,omitempty"` - CredentialHealth CredentialHealth `json:"credential_health,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// RegistryState is the on-disk representation. The map is private to -// FileRegistry operations; callers receive sorted copies instead. -type RegistryState struct { - Version int `json:"version"` - ActiveAccountID string `json:"active_account_id,omitempty"` - Accounts map[string]Account `json:"accounts"` -} - -// FileRegistry is a JSON-backed account registry. Each operation reloads the -// file while holding the process mutex, so multiple FileRegistry instances in -// one process do not operate on stale state. Cross-process writers are outside -// this MVP; atomic replacement keeps readers from observing partial JSON. -type FileRegistry struct { - path string - mu sync.Mutex - now func() time.Time -} - -// NewFileRegistry constructs a registry at path without creating it. -func NewFileRegistry(path string) *FileRegistry { - return &FileRegistry{path: path, now: time.Now} -} - -// NewRegistry is the concise constructor used by controller wiring. -func NewRegistry(path string) *FileRegistry { - return NewFileRegistry(path) -} - -// Path returns the registry file path and does not expose account contents. -func (r *FileRegistry) Path() string { - if r == nil { - return "" - } - return r.path -} - -// SetClock injects a clock for deterministic tests. -func (r *FileRegistry) SetClock(now func() time.Time) { - if r == nil { - return - } - r.mu.Lock() - defer r.mu.Unlock() - if now == nil { - r.now = time.Now - return - } - r.now = now -} - -// Register creates a new account and rejects duplicate IDs. -func (r *FileRegistry) Register(account Account) error { - if r == nil { - return errors.New("nil account registry") - } - if err := ValidateAccount(account); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - if _, exists := state.Accounts[account.ID]; exists { - return fmt.Errorf("%w: %s", ErrAccountExists, account.ID) - } - now := r.clockNow() - account = normalizeAccount(account, now, false) - state.Accounts[account.ID] = account - return r.saveUnlocked(state) -} - -// Upsert creates or updates an account. Existing CreatedAt and omitted -// CredentialRef/health values are preserved on update so a metadata refresh -// cannot accidentally unlink credentials. -func (r *FileRegistry) Upsert(account Account) error { - if r == nil { - return errors.New("nil account registry") - } - if err := ValidateAccount(account); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - existing, exists := state.Accounts[account.ID] - if exists { - if account.CredentialRef == "" { - account.CredentialRef = existing.CredentialRef - } - if account.CredentialHealth == "" { - account.CredentialHealth = existing.CredentialHealth - } - account.CreatedAt = existing.CreatedAt - if account.LastTelemetryAt == nil { - account.LastTelemetryAt = existing.LastTelemetryAt - } - if account.TelemetrySource == "" { - account.TelemetrySource = existing.TelemetrySource - } - } - account = normalizeAccount(account, r.clockNow(), exists) - state.Accounts[account.ID] = account - return r.saveUnlocked(state) -} - -// Rename updates the operator-facing alias for one account without changing -// its credential reference, telemetry markers, or active selection. The -// operation is intentionally registry-only: credential files are keyed by the -// stable account ID and therefore never move during a rename. -func (r *FileRegistry) Rename(accountID, alias string) error { - if r == nil { - return errors.New("nil account registry") - } - if err := ValidateAccountID(accountID); err != nil { - return err - } - if err := ValidateAlias(alias); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - account, ok := state.Accounts[accountID] - if !ok { - return fmt.Errorf("%w: %s", ErrAccountNotFound, accountID) - } - account.Alias = alias - account.UpdatedAt = r.clockNow() - state.Accounts[accountID] = account - return r.saveUnlocked(state) -} - -// Save is an alias for Upsert. -func (r *FileRegistry) Save(account Account) error { - return r.Upsert(account) -} - -// Get returns a defensive account copy. -func (r *FileRegistry) Get(accountID string) (Account, error) { - if r == nil { - return Account{}, errors.New("nil account registry") - } - if err := ValidateAccountID(accountID); err != nil { - return Account{}, err - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return Account{}, err - } - account, ok := state.Accounts[accountID] - if !ok { - return Account{}, fmt.Errorf("%w: %s", ErrAccountNotFound, accountID) - } - return cloneAccount(account), nil -} - -// Lookup returns a boolean instead of ErrAccountNotFound for callers that -// treat absent optional accounts as normal. -func (r *FileRegistry) Lookup(accountID string) (Account, bool, error) { - account, err := r.Get(accountID) - if errors.Is(err, ErrAccountNotFound) { - return Account{}, false, nil - } - if err != nil { - return Account{}, false, err - } - return account, true, nil -} - -// List returns accounts in stable ID order. -func (r *FileRegistry) List() ([]Account, error) { - if r == nil { - return nil, errors.New("nil account registry") - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return nil, err - } - ids := make([]string, 0, len(state.Accounts)) - for id := range state.Accounts { - ids = append(ids, id) - } - sort.Strings(ids) - accounts := make([]Account, 0, len(ids)) - for _, id := range ids { - accounts = append(accounts, cloneAccount(state.Accounts[id])) - } - return accounts, nil -} - -// SetActive marks a configured account as the desired active identity. -func (r *FileRegistry) SetActive(accountID string) error { - if r == nil { - return errors.New("nil account registry") - } - if err := ValidateAccountID(accountID); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - if _, ok := state.Accounts[accountID]; !ok { - return fmt.Errorf("%w: %s", ErrAccountNotFound, accountID) - } - if state.ActiveAccountID == accountID { - return nil - } - state.ActiveAccountID = accountID - return r.saveUnlocked(state) -} - -// ClearActive removes the active identity marker. -func (r *FileRegistry) ClearActive() error { - if r == nil { - return errors.New("nil account registry") - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - if state.ActiveAccountID == "" { - return nil - } - state.ActiveAccountID = "" - return r.saveUnlocked(state) -} - -// ActiveID returns the desired active account identifier. -func (r *FileRegistry) ActiveID() (string, error) { - if r == nil { - return "", errors.New("nil account registry") - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return "", err - } - return state.ActiveAccountID, nil -} - -// Active returns the active account and false when no account is selected. -func (r *FileRegistry) Active() (Account, bool, error) { - if r == nil { - return Account{}, false, errors.New("nil account registry") - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return Account{}, false, err - } - if state.ActiveAccountID == "" { - return Account{}, false, nil - } - account, ok := state.Accounts[state.ActiveAccountID] - if !ok { - return Account{}, false, fmt.Errorf("%w: active account %s", ErrInvalidRegistry, state.ActiveAccountID) - } - return cloneAccount(account), true, nil -} - -// Remove deletes an account. By default removing the active account is -// rejected; pass force=true only when the caller intentionally clears active -// state as part of the same operation. -func (r *FileRegistry) Remove(accountID string, force ...bool) error { - if r == nil { - return errors.New("nil account registry") - } - if err := ValidateAccountID(accountID); err != nil { - return err - } - forced := len(force) > 0 && force[0] - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - if _, ok := state.Accounts[accountID]; !ok { - return fmt.Errorf("%w: %s", ErrAccountNotFound, accountID) - } - if state.ActiveAccountID == accountID && !forced { - return fmt.Errorf("%w: %s", ErrActiveAccount, accountID) - } - delete(state.Accounts, accountID) - if state.ActiveAccountID == accountID { - state.ActiveAccountID = "" - } - return r.saveUnlocked(state) -} - -// Delete is an explicit spelling for Remove. -func (r *FileRegistry) Delete(accountID string) error { - return r.Remove(accountID) -} - -// SetTelemetry updates only the non-secret telemetry marker for an account. -func (r *FileRegistry) SetTelemetry(accountID string, observedAt time.Time, source string) error { - if r == nil { - return errors.New("nil account registry") - } - if err := ValidateAccountID(accountID); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - account, ok := state.Accounts[accountID] - if !ok { - return fmt.Errorf("%w: %s", ErrAccountNotFound, accountID) - } - observedAt = observedAt.UTC() - account.LastTelemetryAt = &observedAt - account.TelemetrySource = strings.TrimSpace(source) - account.UpdatedAt = r.clockNow() - state.Accounts[accountID] = account - return r.saveUnlocked(state) -} - -// SetCredentialHealth updates the controller marker for an account without -// touching credential bytes. -func (r *FileRegistry) SetCredentialHealth(accountID string, health CredentialHealth) error { - if r == nil { - return errors.New("nil account registry") - } - if err := ValidateAccountID(accountID); err != nil { - return err - } - if !validCredentialHealth(health) { - return fmt.Errorf("%w: unknown credential health %q", ErrInvalidRegistry, health) - } - r.mu.Lock() - defer r.mu.Unlock() - state, err := r.loadUnlocked() - if err != nil { - return err - } - account, ok := state.Accounts[accountID] - if !ok { - return fmt.Errorf("%w: %s", ErrAccountNotFound, accountID) - } - account.CredentialHealth = health - account.UpdatedAt = r.clockNow() - state.Accounts[accountID] = account - return r.saveUnlocked(state) -} - -// ValidateAccount validates an account record and rejects fields that could -// make its identity ambiguous or unsafe as a vault reference. -func ValidateAccount(account Account) error { - if err := ValidateAccountID(account.ID); err != nil { - return err - } - if err := ValidateAlias(account.Alias); err != nil { - return err - } - if account.CredentialRef != "" { - if err := ValidateAccountID(account.CredentialRef); err != nil { - return fmt.Errorf("%w: credential ref: %v", ErrInvalidRegistry, err) - } - } - if account.CredentialHealth != "" && !validCredentialHealth(account.CredentialHealth) { - return fmt.Errorf("%w: unknown credential health %q", ErrInvalidRegistry, account.CredentialHealth) - } - for key, value := range account.Metadata { - if strings.TrimSpace(key) == "" || strings.ContainsAny(key, "\r\n") || strings.ContainsRune(key, 0) { - return fmt.Errorf("%w: invalid metadata key", ErrInvalidRegistry) - } - if strings.ContainsRune(value, 0) || strings.ContainsAny(value, "\r\n") { - return fmt.Errorf("%w: invalid metadata value", ErrInvalidRegistry) - } - if looksSensitiveMetadata(key) { - return fmt.Errorf("%w: sensitive metadata key %q", ErrInvalidRegistry, key) - } - } - return nil -} - -// ValidateAlias enforces the non-secret, operator-facing profile label -// contract. Empty aliases are allowed so a provider identity or account ID can -// be used as the display fallback, but a supplied alias must be a single-line -// value without surrounding whitespace or NUL bytes. -func ValidateAlias(alias string) error { - if strings.TrimSpace(alias) != alias { - return fmt.Errorf("%w: alias has surrounding whitespace", ErrInvalidRegistry) - } - if strings.ContainsRune(alias, 0) || strings.ContainsAny(alias, "\r\n") { - return fmt.Errorf("%w: invalid alias", ErrInvalidRegistry) - } - if len(alias) > 256 { - return fmt.Errorf("%w: alias is too long", ErrInvalidRegistry) - } - return nil -} - -// ValidateAccountID ensures an ID is safe to use as a vault filename -// component. Account IDs can be provider IDs or local stable identifiers, so -// this validation intentionally permits spaces and punctuation except path and -// control characters. -func ValidateAccountID(accountID string) error { - if accountID == "" || accountID == "." || accountID == ".." || len(accountID) > 240 { - return ErrInvalidAccountID - } - if strings.ContainsAny(accountID, `/\\<>:"|?*`) || strings.ContainsRune(accountID, 0) { - return ErrInvalidAccountID - } - if strings.HasSuffix(accountID, ".") || strings.HasSuffix(accountID, " ") { - return ErrInvalidAccountID - } - for _, r := range accountID { - if r < 0x20 || r == 0x7f { - return ErrInvalidAccountID - } - } - base := strings.ToUpper(strings.SplitN(accountID, ".", 2)[0]) - switch base { - case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": - return ErrInvalidAccountID - } - return nil -} - -func (r *FileRegistry) loadUnlocked() (RegistryState, error) { - state := RegistryState{Version: registryVersion, Accounts: map[string]Account{}} - if r.path == "" { - return RegistryState{}, errors.New("account registry path is empty") - } - if err := rejectRegistrySymlink(r.path); err != nil { - return RegistryState{}, err - } - raw, err := os.ReadFile(r.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return state, nil - } - return RegistryState{}, err - } - if len(raw) == 0 { - return state, nil - } - if err := json.Unmarshal(raw, &state); err != nil { - return RegistryState{}, fmt.Errorf("%w: decode: %v", ErrInvalidRegistry, err) - } - if state.Version == 0 { - state.Version = registryVersion - } - if state.Version != registryVersion { - return RegistryState{}, fmt.Errorf("%w: unsupported version %d", ErrInvalidRegistry, state.Version) - } - if state.Accounts == nil { - state.Accounts = map[string]Account{} - } - for id, account := range state.Accounts { - if id != account.ID { - return RegistryState{}, fmt.Errorf("%w: map key %q does not match account id %q", ErrInvalidRegistry, id, account.ID) - } - if err := ValidateAccount(account); err != nil { - return RegistryState{}, fmt.Errorf("%w: account %q: %v", ErrInvalidRegistry, id, err) - } - } - if state.ActiveAccountID != "" { - if err := ValidateAccountID(state.ActiveAccountID); err != nil { - return RegistryState{}, fmt.Errorf("%w: active id: %v", ErrInvalidRegistry, err) - } - if _, ok := state.Accounts[state.ActiveAccountID]; !ok { - return RegistryState{}, fmt.Errorf("%w: active account %q is not registered", ErrInvalidRegistry, state.ActiveAccountID) - } - } - return state, nil -} - -func (r *FileRegistry) saveUnlocked(state RegistryState) error { - if r.path == "" { - return errors.New("account registry path is empty") - } - if state.Accounts == nil { - state.Accounts = map[string]Account{} - } - state.Version = registryVersion - data, err := json.MarshalIndent(state, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - dir := filepath.Dir(r.path) - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - if err := os.Chmod(dir, 0o700); err != nil && !isPermissionMetadataError(err) { - return err - } - if err := rejectRegistrySymlink(r.path); err != nil { - return err - } - return writeRegistryAtomically(r.path, data) -} - -func (r *FileRegistry) clockNow() time.Time { - now := r.now - if now == nil { - now = time.Now - } - return now().UTC() -} - -func normalizeAccount(account Account, now time.Time, existing bool) Account { - if account.CredentialRef == "" { - account.CredentialRef = account.ID - } - if account.CredentialHealth == "" { - account.CredentialHealth = CredentialHealthUnknown - } - if account.CreatedAt.IsZero() { - account.CreatedAt = now - } - // UpdatedAt is controller-owned evidence of the write, even when a - // caller supplies a stale timestamp. The existing flag remains in the - // signature for readability at call sites and future migration hooks. - _ = existing - account.UpdatedAt = now - account.CreatedAt = account.CreatedAt.UTC() - account.UpdatedAt = account.UpdatedAt.UTC() - account.Metadata = cloneMetadata(account.Metadata) - if account.LastTelemetryAt != nil { - observedAt := account.LastTelemetryAt.UTC() - account.LastTelemetryAt = &observedAt - } - return account -} - -func cloneAccount(account Account) Account { - account.Metadata = cloneMetadata(account.Metadata) - if account.LastTelemetryAt != nil { - observedAt := account.LastTelemetryAt.UTC() - account.LastTelemetryAt = &observedAt - } - return account -} - -func cloneMetadata(metadata map[string]string) map[string]string { - if metadata == nil { - return nil - } - clone := make(map[string]string, len(metadata)) - for key, value := range metadata { - clone[key] = value - } - return clone -} - -func validCredentialHealth(health CredentialHealth) bool { - switch health { - case CredentialHealthUnknown, CredentialHealthHealthy, CredentialHealthStale, CredentialHealthInvalid: - return true - default: - return false - } -} - -func isPermissionMetadataError(error) bool { - return false -} - -func looksSensitiveMetadata(key string) bool { - lower := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), " ", "_")) - for _, marker := range []string{ - "access_token", "id_token", "refresh_token", "authorization", "api_key", - "password", "secret", "auth_json", "credential", "private_key", - } { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - -func rejectRegistrySymlink(path string) error { - info, err := os.Lstat(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("%w: registry path is a symbolic link", ErrInvalidRegistry) - } - if info.IsDir() { - return fmt.Errorf("%w: registry path is a directory", ErrInvalidRegistry) - } - return nil -} diff --git a/controller/internal/accounts/accounts_test.go b/controller/internal/accounts/accounts_test.go deleted file mode 100644 index 7421df08..00000000 --- a/controller/internal/accounts/accounts_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package accounts - -import ( - "errors" - "os" - "path/filepath" - "testing" - "time" -) - -func TestFileRegistryLifecycleAndDefensiveCopies(t *testing.T) { - path := filepath.Join(t.TempDir(), "state", "accounts.json") - registry := NewFileRegistry(path) - clock := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - registry.SetClock(func() time.Time { return clock }) - account := Account{ID: "acct-b", Alias: "Work", Metadata: map[string]string{"plan": "plus"}} - if err := registry.Register(account); err != nil { - t.Fatalf("Register() error = %v", err) - } - if err := registry.Register(account); !errors.Is(err, ErrAccountExists) { - t.Fatalf("duplicate Register() error = %v, want ErrAccountExists", err) - } - if err := registry.Upsert(Account{ID: "acct-a", Alias: "Personal"}); err != nil { - t.Fatalf("Upsert() error = %v", err) - } - if err := registry.SetActive("acct-b"); err != nil { - t.Fatalf("SetActive() error = %v", err) - } - active, ok, err := registry.Active() - if err != nil || !ok { - t.Fatalf("Active() = %#v, %v, %v", active, ok, err) - } - if active.CredentialRef != "acct-b" || active.CredentialHealth != CredentialHealthUnknown { - t.Fatalf("active account defaults = %#v", active) - } - active.Metadata["mutated"] = "caller" - loaded, err := registry.Get("acct-b") - if err != nil { - t.Fatalf("Get() error = %v", err) - } - if _, exists := loaded.Metadata["mutated"]; exists { - t.Fatal("Get() returned mutable registry metadata") - } - accounts, err := registry.List() - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(accounts) != 2 || accounts[0].ID != "acct-a" || accounts[1].ID != "acct-b" { - t.Fatalf("List() = %#v, want ID-sorted accounts", accounts) - } - if !loaded.CreatedAt.Equal(clock) || !loaded.UpdatedAt.Equal(clock) { - t.Fatalf("timestamps = %v, %v, want %v", loaded.CreatedAt, loaded.UpdatedAt, clock) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("registry Stat() error = %v", err) - } - if info.Mode().Perm()&0o077 != 0 { - t.Fatalf("registry permissions = %o, want owner-only", info.Mode().Perm()) - } - dirInfo, err := os.Stat(filepath.Dir(path)) - if err != nil { - t.Fatalf("registry directory Stat() error = %v", err) - } - if dirInfo.Mode().Perm()&0o077 != 0 { - t.Fatalf("registry directory permissions = %o, want owner-only", dirInfo.Mode().Perm()) - } -} - -func TestFileRegistryActiveRemovalAndTelemetryMarkers(t *testing.T) { - registry := NewFileRegistry(filepath.Join(t.TempDir(), "accounts.json")) - if err := registry.Register(Account{ID: "acct-1"}); err != nil { - t.Fatal(err) - } - if err := registry.SetActive("acct-1"); err != nil { - t.Fatal(err) - } - if err := registry.Remove("acct-1"); !errors.Is(err, ErrActiveAccount) { - t.Fatalf("Remove(active) error = %v, want ErrActiveAccount", err) - } - observed := time.Date(2026, 9, 4, 13, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60)) - if err := registry.SetTelemetry("acct-1", observed, "runtime"); err != nil { - t.Fatalf("SetTelemetry() error = %v", err) - } - if err := registry.SetCredentialHealth("acct-1", CredentialHealthHealthy); err != nil { - t.Fatalf("SetCredentialHealth() error = %v", err) - } - account, err := registry.Get("acct-1") - if err != nil { - t.Fatal(err) - } - if account.LastTelemetryAt == nil || !account.LastTelemetryAt.Equal(observed.UTC()) || account.TelemetrySource != "runtime" || account.CredentialHealth != CredentialHealthHealthy { - t.Fatalf("markers = %#v", account) - } - if err := registry.Remove("acct-1", true); err != nil { - t.Fatalf("forced Remove() error = %v", err) - } - if _, ok, err := registry.Lookup("acct-1"); err != nil || ok { - t.Fatalf("Lookup(deleted) = ok %v, error %v", ok, err) - } - if _, err := registry.ActiveID(); err != nil { - t.Fatal(err) - } -} - -func TestFileRegistryRenamePreservesStableIdentityAndSelection(t *testing.T) { - registry := NewFileRegistry(filepath.Join(t.TempDir(), "accounts.json")) - if err := registry.Register(Account{ID: "acct-1", Alias: "old", CredentialRef: "acct-1"}); err != nil { - t.Fatal(err) - } - if err := registry.SetActive("acct-1"); err != nil { - t.Fatal(err) - } - if err := registry.Rename("acct-1", "new"); err != nil { - t.Fatalf("Rename() error = %v", err) - } - account, err := registry.Get("acct-1") - if err != nil { - t.Fatal(err) - } - if account.ID != "acct-1" || account.Alias != "new" || account.CredentialRef != "acct-1" { - t.Fatalf("renamed account = %#v", account) - } - activeID, err := registry.ActiveID() - if err != nil || activeID != "acct-1" { - t.Fatalf("active ID after rename = %q, error %v", activeID, err) - } -} - -func TestValidateAccountRejectsUnsafeIDsAndMetadata(t *testing.T) { - for _, id := range []string{"", ".", "..", "../escape", `nested\name`, "nested/name", "line\nfeed"} { - if err := ValidateAccount(Account{ID: id}); !errors.Is(err, ErrInvalidAccountID) { - t.Fatalf("ValidateAccount(%q) error = %v, want ErrInvalidAccountID", id, err) - } - } - if err := ValidateAccount(Account{ID: "acct", Metadata: map[string]string{"bad\nkey": "x"}}); !errors.Is(err, ErrInvalidRegistry) { - t.Fatalf("invalid metadata error = %v, want ErrInvalidRegistry", err) - } -} diff --git a/controller/internal/accounts/atomic_registry.go b/controller/internal/accounts/atomic_registry.go deleted file mode 100644 index 6aa3ab0b..00000000 --- a/controller/internal/accounts/atomic_registry.go +++ /dev/null @@ -1,43 +0,0 @@ -package accounts - -import ( - "os" - "path/filepath" -) - -func writeRegistryAtomically(path string, data []byte) error { - dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, ".codexmarathon-registry-*.tmp") - if err != nil { - return err - } - tmpName := tmp.Name() - cleanup := func() { - _ = tmp.Close() - _ = os.Remove(tmpName) - } - if err := tmp.Chmod(0o600); err != nil { - cleanup() - return err - } - if _, err := tmp.Write(data); err != nil { - cleanup() - return err - } - if err := tmp.Sync(); err != nil { - cleanup() - return err - } - if err := tmp.Close(); err != nil { - cleanup() - return err - } - if err := replaceRegistryFile(tmpName, path); err != nil { - cleanup() - return err - } - if err := syncRegistryDirectory(dir); err != nil { - return err - } - return nil -} diff --git a/controller/internal/accounts/atomic_registry_unix.go b/controller/internal/accounts/atomic_registry_unix.go deleted file mode 100644 index 161b4028..00000000 --- a/controller/internal/accounts/atomic_registry_unix.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !windows - -package accounts - -import "os" - -func replaceRegistryFile(source, destination string) error { - return os.Rename(source, destination) -} - -func syncRegistryDirectory(path string) error { - dir, err := os.Open(path) - if err != nil { - return err - } - defer dir.Close() - return dir.Sync() -} diff --git a/controller/internal/accounts/atomic_registry_windows.go b/controller/internal/accounts/atomic_registry_windows.go deleted file mode 100644 index 1d4dd8a0..00000000 --- a/controller/internal/accounts/atomic_registry_windows.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build windows - -package accounts - -import ( - "fmt" - "syscall" - "unsafe" -) - -var ( - kernel32RegistryMoveFile = syscall.NewLazyDLL("kernel32.dll") - registryMoveFileEx = kernel32RegistryMoveFile.NewProc("MoveFileExW") -) - -const ( - registryMoveFileReplaceExisting = 0x00000001 - registryMoveFileWriteThrough = 0x00000008 -) - -func replaceRegistryFile(source, destination string) error { - sourcePtr, err := syscall.UTF16PtrFromString(source) - if err != nil { - return err - } - destinationPtr, err := syscall.UTF16PtrFromString(destination) - if err != nil { - return err - } - result, _, callErr := registryMoveFileEx.Call( - uintptr(unsafe.Pointer(sourcePtr)), - uintptr(unsafe.Pointer(destinationPtr)), - registryMoveFileReplaceExisting|registryMoveFileWriteThrough, - ) - if result == 0 { - if callErr != nil && callErr != syscall.Errno(0) { - return callErr - } - return fmt.Errorf("MoveFileExW failed") - } - return nil -} - -func syncRegistryDirectory(string) error { - return nil -} diff --git a/controller/internal/accounts/manager.go b/controller/internal/accounts/manager.go deleted file mode 100644 index 1c022e7a..00000000 --- a/controller/internal/accounts/manager.go +++ /dev/null @@ -1,816 +0,0 @@ -package accounts - -// This file is the controller-side account/profile manager. It adapts the -// profile, refresh, atomic-write, and active-profile rules from the pinned -// humeo/codex-switch donor to CodexMarathon's registry/vault abstractions. -// Authentication itself is deliberately supplied by AuthService: the -// integrated Codex runtime owns OAuth, token parsing, browser/device login, -// and provider-specific refresh. No shell command or external Codex -// installation is used here. - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "sync" - "time" - - "codexmarathon/controller/internal/credentials" -) - -var ( - // ErrAuthServiceUnavailable means the runtime has not yet supplied its - // native Codex login/refresh implementation. - ErrAuthServiceUnavailable = errors.New("native Codex authentication service is unavailable") - // ErrLoginMissingIdentity means a login result contained neither an - // account ID nor a caller-supplied stable ID. - ErrLoginMissingIdentity = errors.New("login did not provide an account identity") - // ErrLoginIdentityMismatch prevents a provider result from being stored - // under a different account's stable ID. - ErrLoginIdentityMismatch = errors.New("login account identity mismatch") - // ErrCredentialStateMismatch indicates a registry/vault inconsistency - // that should be repaired before attempting a lifecycle mutation. - ErrCredentialStateMismatch = errors.New("account credential state is inconsistent") - // ErrNativeLoginFailed and ErrNativeRefreshFailed intentionally hide the - // native provider's free-form error text. Provider errors can accidentally - // contain bearer material; cancellation is preserved separately below. - ErrNativeLoginFailed = errors.New("native login failed") - ErrNativeRefreshFailed = errors.New("native refresh failed") -) - -// LoginRequest contains only non-secret operator intent. The integrated -// runtime may use Alias as a display hint, but it remains the source of the -// authenticated identity returned in LoginResult. -type LoginRequest struct { - // AccountID is optional for OAuth login. When omitted, the native runtime - // must return the provider account ID in LoginResult. - AccountID string - Alias string - // Activate makes this account the controller-selected active profile after - // the snapshot has been durably saved. The first account is activated even - // when this flag is false. - Activate bool - Overwrite bool - Metadata map[string]string -} - -// LoginResult is the in-memory handoff from the integrated runtime's native -// AuthManager/login crate. AuthJSON is sensitive and is never returned by -// AccountManager methods, journalled, or printed; it exists only long enough -// to validate and persist the opaque snapshot into the vault. -type LoginResult struct { - AccountID string - Alias string - AuthJSON []byte - Metadata map[string]string -} - -// ImportRequest describes an already authenticated Codex auth.json snapshot. -// AuthJSON is sensitive and is consumed only in memory before it is persisted -// to the protected vault; callers must not log or print it. -type ImportRequest struct { - AccountID string - Alias string - AuthJSON []byte - Activate bool - Overwrite bool - Metadata map[string]string -} - -// RefreshRequest is the in-memory handoff for a native refresh. The runtime -// receives the opaque current snapshot so it can use its own auth schema and -// refresh implementation. The manager validates the returned token set and -// merges it without discarding unknown auth fields. -type RefreshRequest struct { - AccountID string - AuthJSON []byte -} - -// AuthService is the direct integration seam for the embedded Codex login -// runtime. Implementations should call Codex's native login/AuthManager -// APIs in-process (or through CodexMarathon's authenticated internal runtime -// boundary), never execute `codex login`, and never log the request/response -// token material. -type AuthService interface { - Login(context.Context, LoginRequest) (LoginResult, error) - Refresh(context.Context, RefreshRequest) (credentials.TokenSet, error) -} - -// AuthServiceFunc is useful for wiring an integrated runtime and for tests -// without coupling the manager to a concrete Rust or RPC implementation. -type AuthServiceFunc struct { - LoginFunc func(context.Context, LoginRequest) (LoginResult, error) - RefreshFunc func(context.Context, RefreshRequest) (credentials.TokenSet, error) -} - -func (f AuthServiceFunc) Login(ctx context.Context, request LoginRequest) (LoginResult, error) { - if f.LoginFunc == nil { - return LoginResult{}, ErrAuthServiceUnavailable - } - return f.LoginFunc(ctx, request) -} - -func (f AuthServiceFunc) Refresh(ctx context.Context, request RefreshRequest) (credentials.TokenSet, error) { - if f.RefreshFunc == nil { - return credentials.TokenSet{}, ErrAuthServiceUnavailable - } - return f.RefreshFunc(ctx, request) -} - -// UnavailableAuthService is the safe default until the embedded runtime is -// attached. It is preferable to a shell-out fallback because an absent -// runtime must be explicit rather than silently using a second Codex install. -type UnavailableAuthService struct{} - -func (UnavailableAuthService) Login(context.Context, LoginRequest) (LoginResult, error) { - return LoginResult{}, ErrAuthServiceUnavailable -} - -func (UnavailableAuthService) Refresh(context.Context, RefreshRequest) (credentials.TokenSet, error) { - return credentials.TokenSet{}, ErrAuthServiceUnavailable -} - -// ProfileStatus is a secret-free operator view of one registered profile. -// SavedAt is the vault file modification time, not a claim that the provider -// tokens are currently valid. -type ProfileStatus struct { - AccountID string `json:"account_id"` - Alias string `json:"alias,omitempty"` - CredentialRef string `json:"credential_ref,omitempty"` - Active bool `json:"active"` - CredentialPresent bool `json:"credential_present"` - CredentialHealth CredentialHealth `json:"credential_health"` - SavedAt *time.Time `json:"saved_at,omitempty"` - LastTelemetryAt *time.Time `json:"last_telemetry_at,omitempty"` - TelemetrySource string `json:"telemetry_source,omitempty"` -} - -// LoginOutcome reports non-secret evidence of a completed profile save. -type LoginOutcome struct { - AccountID string `json:"account_id"` - Alias string `json:"alias,omitempty"` - Activated bool `json:"activated"` -} - -// ActivationOutcome reports a completed local profile selection. Runtime -// AuthManager reload and transport invalidation remain a later transition -// operation owned by the runtime coordinator. -type ActivationOutcome struct { - AccountID string `json:"account_id"` -} - -// RefreshOutcome reports successful token write-back without exposing the -// returned token set. -type RefreshOutcome struct { - AccountID string `json:"account_id"` - RefreshedAt time.Time `json:"refreshed_at"` - Activated bool `json:"activated"` -} - -// ManagerConfig wires durable account state to a native runtime auth service. -// Registry and Vault are required. Deployer/AuthPath are needed for login -// activation, explicit activation, and refresh of the active profile. -type ManagerConfig struct { - Registry *FileRegistry - Vault credentials.Vault - Deployer credentials.CredentialDeployer - AuthPath string - AuthService AuthService - Now func() time.Time -} - -// Manager owns serialized profile lifecycle mutations. It does not own the -// Codex runtime; it only supplies a narrow native-auth service seam and an -// atomic deployment boundary for the runtime's auth.json. -type Manager struct { - registry *FileRegistry - vault credentials.Vault - deployer credentials.CredentialDeployer - authPath string - auth AuthService - now func() time.Time - - mu sync.Mutex -} - -// NewManager constructs an account manager without reading or writing any -// state. The default auth service is intentionally unavailable until the -// embedded runtime is attached with SetAuthService. -func NewManager(config ManagerConfig) *Manager { - auth := config.AuthService - if auth == nil { - auth = UnavailableAuthService{} - } - now := config.Now - if now == nil { - now = time.Now - } - deployer := config.Deployer - if deployer == nil && config.Vault != nil && config.AuthPath != "" { - deployer = credentials.NewAtomicDeployer(config.Vault, config.AuthPath) - } - return &Manager{ - registry: config.Registry, - vault: config.Vault, - deployer: deployer, - authPath: config.AuthPath, - auth: auth, - now: now, - } -} - -// SetAuthService attaches the native integrated Codex login/refresh service. -// Passing nil restores the explicit unavailable state; no shell fallback is -// ever installed. -func (m *Manager) SetAuthService(service AuthService) { - if m == nil { - return - } - m.mu.Lock() - defer m.mu.Unlock() - if service == nil { - m.auth = UnavailableAuthService{} - return - } - m.auth = service -} - -// AuthService reports whether a native service has been attached without -// exposing implementation details. It is intended for diagnostics only. -func (m *Manager) AuthService() AuthService { - if m == nil { - return nil - } - m.mu.Lock() - defer m.mu.Unlock() - return m.auth -} - -// Login runs one native login flow and stores the resulting opaque snapshot. -// The first configured account is activated automatically; later accounts -// remain available for selection unless Activate is requested. Every -// filesystem/registry mutation has a rollback path so a failed activation -// leaves the prior active account deployed and selected. -func (m *Manager) Login(ctx context.Context, request LoginRequest) (LoginOutcome, error) { - if err := contextError(ctx); err != nil { - return LoginOutcome{}, err - } - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(true); err != nil { - return LoginOutcome{}, err - } - if err := ValidateAccountIDOptional(request.AccountID); err != nil { - return LoginOutcome{}, err - } - if err := ValidateAlias(request.Alias); err != nil { - return LoginOutcome{}, err - } - auth := m.auth - result, err := auth.Login(nonNilContext(ctx), request) - if err != nil { - return LoginOutcome{}, nativeAuthError(ErrNativeLoginFailed, err) - } - if err := contextError(ctx); err != nil { - return LoginOutcome{}, err - } - - raw := bytes.Clone(result.AuthJSON) - if len(raw) == 0 { - return LoginOutcome{}, fmt.Errorf("%w: native login returned an empty auth snapshot", credentials.ErrInvalidCredential) - } - identity, err := credentials.ExtractAccountID(raw) - if err != nil { - return LoginOutcome{}, err - } - accountID := result.AccountID - if accountID == "" { - accountID = identity - } - if accountID == "" { - accountID = request.AccountID - } - if accountID == "" { - return LoginOutcome{}, ErrLoginMissingIdentity - } - if err := ValidateAccountID(accountID); err != nil { - return LoginOutcome{}, err - } - if request.AccountID != "" && request.AccountID != accountID { - return LoginOutcome{}, fmt.Errorf("%w: requested %q, native login returned %q", ErrLoginIdentityMismatch, request.AccountID, accountID) - } - if identity != "" && identity != accountID { - return LoginOutcome{}, fmt.Errorf("%w: snapshot is for %q, result is %q", ErrLoginIdentityMismatch, identity, accountID) - } - - alias := request.Alias - if alias == "" { - alias = result.Alias - } - if alias == "" { - alias = accountID - } - if err := ValidateAlias(alias); err != nil { - return LoginOutcome{}, err - } - metadata := cloneMetadata(request.Metadata) - if metadata == nil && len(result.Metadata) > 0 { - metadata = make(map[string]string, len(result.Metadata)) - } - for key, value := range result.Metadata { - if _, exists := metadata[key]; !exists { - metadata[key] = value - } - } - return m.persistSnapshotLocked(ctx, accountID, alias, raw, metadata, request.Overwrite, request.Activate) -} - -// Import stores an existing Codex auth.json snapshot without invoking a login -// runtime. This is the supported bootstrap path for a stock Codex CLI whose -// active authentication already exists but which does not expose Marathon's -// native login endpoint. -func (m *Manager) Import(ctx context.Context, request ImportRequest) (LoginOutcome, error) { - if err := contextError(ctx); err != nil { - return LoginOutcome{}, err - } - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(false); err != nil { - return LoginOutcome{}, err - } - if err := ValidateAccountIDOptional(request.AccountID); err != nil { - return LoginOutcome{}, err - } - if err := ValidateAlias(request.Alias); err != nil { - return LoginOutcome{}, err - } - raw, err := credentials.ValidateSnapshot(request.AuthJSON) - if err != nil { - return LoginOutcome{}, err - } - identity, err := credentials.ExtractAccountID(raw) - if err != nil { - return LoginOutcome{}, err - } - accountID := request.AccountID - if accountID == "" { - accountID = identity - } - if accountID == "" { - return LoginOutcome{}, ErrLoginMissingIdentity - } - if err := ValidateAccountID(accountID); err != nil { - return LoginOutcome{}, err - } - if identity != "" && identity != accountID { - return LoginOutcome{}, fmt.Errorf("%w: snapshot is for %q, requested %q", ErrLoginIdentityMismatch, identity, accountID) - } - alias := request.Alias - if alias == "" { - alias = accountID - } - if err := ValidateAlias(alias); err != nil { - return LoginOutcome{}, err - } - return m.persistSnapshotLocked(ctx, accountID, alias, raw, request.Metadata, request.Overwrite, request.Activate) -} - -func (m *Manager) persistSnapshotLocked(ctx context.Context, accountID, alias string, raw []byte, metadata map[string]string, overwrite, activateRequested bool) (LoginOutcome, error) { - account := Account{ - ID: accountID, - Alias: alias, - CredentialRef: accountID, - Metadata: cloneMetadata(metadata), - CredentialHealth: CredentialHealthHealthy, - } - if err := ValidateAccount(account); err != nil { - return LoginOutcome{}, err - } - - previous, existed, err := m.registry.Lookup(accountID) - if err != nil { - return LoginOutcome{}, err - } - previousRaw, hadPreviousRaw, err := loadOptional(m.vault, accountID) - if err != nil { - return LoginOutcome{}, err - } - if existed && !overwrite { - return LoginOutcome{}, fmt.Errorf("%w: %s", ErrAccountExists, accountID) - } - if !existed && hadPreviousRaw { - return LoginOutcome{}, fmt.Errorf("%w: orphaned credential snapshot for %q", ErrCredentialStateMismatch, accountID) - } - - if err := m.vault.Save(accountID, raw); err != nil { - return LoginOutcome{}, err - } - rollback := func(cause error) (LoginOutcome, error) { - return LoginOutcome{}, errors.Join(cause, m.restoreProfileLocked(accountID, existed, previous, previousRaw, hadPreviousRaw)) - } - if err := m.registry.Upsert(account); err != nil { - return rollback(err) - } - - activeID, err := m.registry.ActiveID() - if err != nil { - return rollback(err) - } - // An overwrite of the currently selected profile must redeploy the newly - // authenticated snapshot as well; otherwise the vault and auth.json would - // silently diverge until a later manual activation. - activate := activateRequested || activeID == "" || activeID == accountID - if !activate { - return LoginOutcome{AccountID: accountID, Alias: alias}, nil - } - if _, err := m.activateLocked(ctx, accountID); err != nil { - return rollback(err) - } - return LoginOutcome{AccountID: accountID, Alias: alias, Activated: true}, nil -} - -// Add is the profile-oriented spelling for Login. It keeps the public API -// compatible with codex-switch's add/import terminology while still routing -// through the native integrated login service. -func (m *Manager) Add(ctx context.Context, request LoginRequest) (LoginOutcome, error) { - return m.Login(ctx, request) -} - -// Activate selects and atomically deploys a stored profile. Registry state -// is updated only after the auth file replacement succeeds; any cancellation -// or failure restores the previous file and active marker. -func (m *Manager) Activate(ctx context.Context, accountID string) (ActivationOutcome, error) { - if err := contextError(ctx); err != nil { - return ActivationOutcome{}, err - } - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(false); err != nil { - return ActivationOutcome{}, err - } - if err := ValidateAccountID(accountID); err != nil { - return ActivationOutcome{}, err - } - if _, err := m.registry.Get(accountID); err != nil { - return ActivationOutcome{}, err - } - if _, err := m.vault.Load(accountID); err != nil { - return ActivationOutcome{}, err - } - if _, err := m.activateLocked(ctx, accountID); err != nil { - return ActivationOutcome{}, err - } - return ActivationOutcome{AccountID: accountID}, nil -} - -// Use is the profile-oriented spelling for Activate. -func (m *Manager) Use(ctx context.Context, accountID string) (ActivationOutcome, error) { - return m.Activate(ctx, accountID) -} - -// Rename changes only the display alias. Stable IDs and credential filenames -// never change, matching codex-switch's profile semantics. -func (m *Manager) Rename(accountID, alias string) error { - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(false); err != nil { - return err - } - return m.registry.Rename(accountID, alias) -} - -// Remove safely removes a non-active profile. An active profile requires an -// explicit force request; force clears the controller marker but deliberately -// leaves the currently deployed auth.json intact so Codex is not logged out -// as a side effect of deleting local profile metadata. -func (m *Manager) Remove(accountID string, force bool) error { - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(false); err != nil { - return err - } - if err := ValidateAccountID(accountID); err != nil { - return err - } - if _, err := m.registry.Get(accountID); err != nil { - return err - } - activeID, err := m.registry.ActiveID() - if err != nil { - return err - } - if activeID == accountID && !force { - return fmt.Errorf("%w: %s", ErrActiveAccount, accountID) - } - raw, err := m.vault.Load(accountID) - if err != nil { - return err - } - if err := m.vault.Delete(accountID); err != nil { - return err - } - if err := m.registry.Remove(accountID, force); err != nil { - return errors.Join(err, m.vault.Save(accountID, raw)) - } - return nil -} - -// Delete is the explicit destructive spelling for Remove. -func (m *Manager) Delete(accountID string, force bool) error { - return m.Remove(accountID, force) -} - -// Refresh uses the integrated runtime's native refresh implementation and -// persists only non-empty returned token fields. Unknown auth fields are -// retained, and active auth.json is atomically replaced after the vault write. -func (m *Manager) Refresh(ctx context.Context, accountID string) (RefreshOutcome, error) { - if err := contextError(ctx); err != nil { - return RefreshOutcome{}, err - } - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(true); err != nil { - return RefreshOutcome{}, err - } - if err := ValidateAccountID(accountID); err != nil { - return RefreshOutcome{}, err - } - if _, err := m.registry.Get(accountID); err != nil { - return RefreshOutcome{}, err - } - raw, err := m.vault.Load(accountID) - if err != nil { - return RefreshOutcome{}, err - } - auth := m.auth - tokens, err := auth.Refresh(nonNilContext(ctx), RefreshRequest{AccountID: accountID, AuthJSON: bytes.Clone(raw)}) - if err != nil { - return RefreshOutcome{}, nativeAuthError(ErrNativeRefreshFailed, err) - } - if err := contextError(ctx); err != nil { - return RefreshOutcome{}, err - } - refreshedAt := m.nowUTC() - updated, err := credentials.MergeTokenWriteBackForAccount(accountID, raw, tokens, refreshedAt) - if err != nil { - return RefreshOutcome{}, err - } - activeID, err := m.registry.ActiveID() - if err != nil { - return RefreshOutcome{}, err - } - active := activeID == accountID - if err := m.vault.Save(accountID, updated); err != nil { - return RefreshOutcome{}, err - } - if active { - if err := contextError(ctx); err != nil { - return RefreshOutcome{}, errors.Join(err, m.vault.Save(accountID, raw)) - } - if m.deployer == nil { - return RefreshOutcome{}, errors.Join(errors.New("active profile deployer is unavailable"), m.vault.Save(accountID, raw)) - } - if _, err := m.deployer.Deploy(accountID); err != nil { - return RefreshOutcome{}, errors.Join(err, m.vault.Save(accountID, raw)) - } - } - if err := m.registry.SetCredentialHealth(accountID, CredentialHealthHealthy); err != nil { - return RefreshOutcome{}, err - } - return RefreshOutcome{AccountID: accountID, RefreshedAt: refreshedAt, Activated: active}, nil -} - -// Status returns one secret-free profile view. -func (m *Manager) Status(accountID string) (ProfileStatus, error) { - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(false); err != nil { - return ProfileStatus{}, err - } - account, err := m.registry.Get(accountID) - if err != nil { - return ProfileStatus{}, err - } - activeID, err := m.registry.ActiveID() - if err != nil { - return ProfileStatus{}, err - } - return m.statusLocked(account, activeID == account.ID) -} - -// List returns all registered profiles in deterministic ID order. -func (m *Manager) List() ([]ProfileStatus, error) { - m.mu.Lock() - defer m.mu.Unlock() - if err := m.validateLocked(false); err != nil { - return nil, err - } - accounts, err := m.registry.List() - if err != nil { - return nil, err - } - activeID, err := m.registry.ActiveID() - if err != nil { - return nil, err - } - statuses := make([]ProfileStatus, 0, len(accounts)) - for _, account := range accounts { - status, err := m.statusLocked(account, account.ID == activeID) - if err != nil { - return nil, err - } - statuses = append(statuses, status) - } - return statuses, nil -} - -func (m *Manager) statusLocked(account Account, active bool) (ProfileStatus, error) { - status := ProfileStatus{ - AccountID: account.ID, - Alias: account.Alias, - CredentialRef: account.CredentialRef, - Active: active, - CredentialHealth: account.CredentialHealth, - TelemetrySource: account.TelemetrySource, - } - if account.LastTelemetryAt != nil { - at := account.LastTelemetryAt.UTC() - status.LastTelemetryAt = &at - } - if snapshotReader, ok := m.vault.(interface { - Read(string) (credentials.Snapshot, error) - }); ok { - snapshot, err := snapshotReader.Read(account.ID) - if err == nil { - status.CredentialPresent = true - saved := snapshot.SavedAt.UTC() - status.SavedAt = &saved - return status, nil - } - if !errors.Is(err, credentials.ErrCredentialNotFound) { - return ProfileStatus{}, err - } - return status, nil - } - if _, err := m.vault.Load(account.ID); err == nil { - status.CredentialPresent = true - } else if !errors.Is(err, credentials.ErrCredentialNotFound) { - return ProfileStatus{}, err - } - return status, nil -} - -func (m *Manager) activateLocked(ctx context.Context, accountID string) (ActivationOutcome, error) { - if m.deployer == nil { - return ActivationOutcome{}, errors.New("active profile deployer is unavailable") - } - if err := contextError(ctx); err != nil { - return ActivationOutcome{}, err - } - previousID, err := m.registry.ActiveID() - if err != nil { - return ActivationOutcome{}, err - } - previousAuth, hadAuth, err := readOptionalFile(m.authPath) - if err != nil { - return ActivationOutcome{}, err - } - if _, err := m.deployer.Deploy(accountID); err != nil { - return ActivationOutcome{}, err - } - rollback := func(cause error) (ActivationOutcome, error) { - return ActivationOutcome{}, errors.Join(cause, m.restoreActivationLocked(previousID, previousAuth, hadAuth)) - } - if err := contextError(ctx); err != nil { - return rollback(err) - } - if err := m.registry.SetActive(accountID); err != nil { - return rollback(err) - } - return ActivationOutcome{AccountID: accountID}, nil -} - -func (m *Manager) restoreActivationLocked(previousID string, previousAuth []byte, hadAuth bool) error { - var restoreErr error - if m.authPath == "" { - restoreErr = errors.New("auth path is empty; cannot restore previous deployment") - } else if hadAuth { - restoreErr = credentials.WriteAtomically(m.authPath, previousAuth) - } else if err := os.Remove(m.authPath); err != nil && !errors.Is(err, os.ErrNotExist) { - restoreErr = err - } - var markerErr error - if previousID == "" { - markerErr = m.registry.ClearActive() - } else { - markerErr = m.registry.SetActive(previousID) - } - return errors.Join(restoreErr, markerErr) -} - -func (m *Manager) restoreProfileLocked(accountID string, existed bool, previous Account, previousRaw []byte, hadPreviousRaw bool) error { - var vaultErr error - if hadPreviousRaw { - vaultErr = m.vault.Save(accountID, previousRaw) - } else { - if err := m.vault.Delete(accountID); err != nil && !errors.Is(err, credentials.ErrCredentialNotFound) { - vaultErr = err - } - } - var registryErr error - if existed { - registryErr = m.registry.Upsert(previous) - } else { - if err := m.registry.Remove(accountID, true); err != nil && !errors.Is(err, ErrAccountNotFound) { - registryErr = err - } - } - return errors.Join(vaultErr, registryErr) -} - -func (m *Manager) validateLocked(requireAuth bool) error { - if m == nil { - return errors.New("nil account manager") - } - if m.registry == nil { - return errors.New("account manager registry is nil") - } - if m.vault == nil { - return errors.New("account manager vault is nil") - } - if requireAuth && m.auth == nil { - return ErrAuthServiceUnavailable - } - return nil -} - -func (m *Manager) nowUTC() time.Time { - now := m.now - if now == nil { - now = time.Now - } - return now().UTC() -} - -func ValidateAccountIDOptional(accountID string) error { - if accountID == "" { - return nil - } - return ValidateAccountID(accountID) -} - -func loadOptional(vault credentials.Vault, accountID string) ([]byte, bool, error) { - raw, err := vault.Load(accountID) - if err == nil { - return raw, true, nil - } - if errors.Is(err, credentials.ErrCredentialNotFound) { - return nil, false, nil - } - return nil, false, err -} - -func readOptionalFile(path string) ([]byte, bool, error) { - if path == "" { - return nil, false, errors.New("auth path is empty") - } - raw, err := os.ReadFile(path) - if err == nil { - return bytes.Clone(raw), true, nil - } - if errors.Is(err, os.ErrNotExist) { - return nil, false, nil - } - return nil, false, err -} - -func contextError(ctx context.Context) error { - if ctx == nil { - return nil - } - return ctx.Err() -} - -func nonNilContext(ctx context.Context) context.Context { - if ctx == nil { - return context.Background() - } - return ctx -} - -func nativeAuthError(kind, err error) error { - if err == nil { - return kind - } - if errors.Is(err, ErrAuthServiceUnavailable) { - return ErrAuthServiceUnavailable - } - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return err - } - // Do not wrap the provider error: callers print returned errors, and a - // provider-controlled message could contain access/refresh token material. - return kind -} diff --git a/controller/internal/accounts/manager_test.go b/controller/internal/accounts/manager_test.go deleted file mode 100644 index abe3fe85..00000000 --- a/controller/internal/accounts/manager_test.go +++ /dev/null @@ -1,364 +0,0 @@ -package accounts - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "codexmarathon/controller/internal/credentials" -) - -type fakeAuthService struct { - loginResults []LoginResult - loginErr error - refreshTokens credentials.TokenSet - refreshErr error - lastRefresh RefreshRequest -} - -func (f *fakeAuthService) Login(context.Context, LoginRequest) (LoginResult, error) { - if f.loginErr != nil { - return LoginResult{}, f.loginErr - } - if len(f.loginResults) == 0 { - return LoginResult{}, errors.New("no fake login result") - } - result := f.loginResults[0] - f.loginResults = f.loginResults[1:] - return result, nil -} - -func (f *fakeAuthService) Refresh(_ context.Context, request RefreshRequest) (credentials.TokenSet, error) { - f.lastRefresh = RefreshRequest{AccountID: request.AccountID, AuthJSON: append([]byte(nil), request.AuthJSON...)} - if f.refreshErr != nil { - return credentials.TokenSet{}, f.refreshErr - } - return f.refreshTokens, nil -} - -type failingDeployer struct { - vault credentials.SnapshotReader - authPath string - failID string -} - -func (d failingDeployer) Deploy(accountID string) (credentials.DeploymentResult, error) { - if accountID == d.failID { - return credentials.DeploymentResult{}, errors.New("simulated deployment failure") - } - raw, err := d.vault.Load(accountID) - if err != nil { - return credentials.DeploymentResult{}, err - } - if err := credentials.WriteAtomically(d.authPath, raw); err != nil { - return credentials.DeploymentResult{}, err - } - return credentials.DeploymentResult{AccountID: accountID, Path: d.authPath}, nil -} - -func newManagerFixture(t *testing.T, auth AuthService, deployer credentials.CredentialDeployer) (*Manager, *credentials.FileVault, *FileRegistry, string) { - t.Helper() - root := t.TempDir() - vault := credentials.NewFileVault(filepath.Join(root, "credentials")) - registry := NewFileRegistry(filepath.Join(root, "accounts.json")) - authPath := filepath.Join(root, "codex", "auth.json") - manager := NewManager(ManagerConfig{ - Registry: registry, - Vault: vault, - Deployer: deployer, - AuthPath: authPath, - AuthService: auth, - Now: func() time.Time { return time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) }, - }) - if manager.deployer == nil { - manager.deployer = credentials.NewAtomicDeployer(vault, authPath) - } - return manager, vault, registry, authPath -} - -func loginSnapshot(accountID, token string) []byte { - return []byte(`{"auth_mode":"chatgpt","tokens":{"access_token":"` + token + `","id_token":"id-` + accountID + `","refresh_token":"refresh-` + accountID + `","account_id":"` + accountID + `"},"custom":{"keep":true}}`) -} - -func TestManagerLoginSwitchRefreshAndRenameLifecycle(t *testing.T) { - service := &fakeAuthService{ - loginResults: []LoginResult{ - {AccountID: "acct-a", Alias: "Personal", AuthJSON: loginSnapshot("acct-a", "token-a"), Metadata: map[string]string{"plan": "plus"}}, - {AccountID: "acct-b", Alias: "Work", AuthJSON: loginSnapshot("acct-b", "token-b")}, - }, - } - manager, vault, registry, authPath := newManagerFixture(t, service, nil) - - first, err := manager.Login(context.Background(), LoginRequest{}) - if err != nil { - t.Fatalf("first Login() error = %v", err) - } - if !first.Activated || first.AccountID != "acct-a" { - t.Fatalf("first Login() = %#v, want activated acct-a", first) - } - firstAccount, err := registry.Get("acct-a") - if err != nil { - t.Fatal(err) - } - if firstAccount.Metadata["plan"] != "plus" { - t.Fatalf("native login metadata = %#v, want plan marker", firstAccount.Metadata) - } - if got := readAuthIdentity(t, authPath); got != "acct-a" { - t.Fatalf("initial auth identity = %q, want acct-a", got) - } - - second, err := manager.Login(context.Background(), LoginRequest{}) - if err != nil { - t.Fatalf("second Login() error = %v", err) - } - if second.Activated { - t.Fatalf("second Login() = %#v, want inactive by default", second) - } - active, ok, err := registry.Active() - if err != nil || !ok || active.ID != "acct-a" { - t.Fatalf("active after second login = %#v, %v, %v", active, ok, err) - } - - if _, err := manager.Activate(context.Background(), "acct-b"); err != nil { - t.Fatalf("Activate(acct-b) error = %v", err) - } - if got := readAuthIdentity(t, authPath); got != "acct-b" { - t.Fatalf("auth identity after B = %q, want acct-b", got) - } - active, ok, err = registry.Active() - if err != nil || !ok || active.ID != "acct-b" { - t.Fatalf("active after B = %#v, %v, %v", active, ok, err) - } - - service.refreshTokens = credentials.TokenSet{AccessToken: "token-b-refreshed", AccountID: "acct-b"} - refresh, err := manager.Refresh(context.Background(), "acct-b") - if err != nil { - t.Fatalf("Refresh(B) error = %v", err) - } - if !refresh.Activated || service.lastRefresh.AccountID != "acct-b" { - t.Fatalf("Refresh(B) = %#v, request=%#v", refresh, service.lastRefresh) - } - updatedB, err := vault.Load("acct-b") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(updatedB), `"access_token":"token-b-refreshed"`) || !strings.Contains(string(updatedB), `"keep":true`) { - t.Fatalf("refreshed B snapshot lost fields: %s", updatedB) - } - - if _, err := manager.Activate(context.Background(), "acct-a"); err != nil { - t.Fatalf("Activate(acct-a) error = %v", err) - } - service.refreshTokens = credentials.TokenSet{AccessToken: "token-a-refreshed", AccountID: "acct-a"} - if _, err := manager.Refresh(context.Background(), "acct-a"); err != nil { - t.Fatalf("Refresh(A) error = %v", err) - } - if got := readAuthIdentity(t, authPath); got != "acct-a" { - t.Fatalf("auth identity after A = %q, want acct-a", got) - } - updatedA, err := vault.Load("acct-a") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(updatedA), `"access_token":"token-a-refreshed"`) { - t.Fatalf("refreshed A snapshot missing new token: %s", updatedA) - } - - if err := manager.Rename("acct-a", "Personal main"); err != nil { - t.Fatalf("Rename() error = %v", err) - } - status, err := manager.Status("acct-a") - if err != nil { - t.Fatal(err) - } - if status.Alias != "Personal main" || !status.Active || !status.CredentialPresent { - t.Fatalf("status after rename = %#v", status) - } - encoded, err := json.Marshal(status) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(encoded), "token-a") || strings.Contains(string(encoded), "refresh-") { - t.Fatalf("profile status exposed credential data: %s", encoded) - } -} - -func TestManagerImportExistingSnapshotWithoutAuthService(t *testing.T) { - manager, vault, registry, authPath := newManagerFixture(t, nil, nil) - raw := loginSnapshot("stock-account", "stock-token") - - outcome, err := manager.Import(context.Background(), ImportRequest{ - AuthJSON: raw, - Alias: "personal", - }) - if err != nil { - t.Fatalf("Import() error = %v", err) - } - if !outcome.Activated || outcome.AccountID != "stock-account" || outcome.Alias != "personal" { - t.Fatalf("Import() = %#v, want activated stock-account/personal", outcome) - } - stored, err := vault.Load("stock-account") - if err != nil { - t.Fatal(err) - } - if string(stored) != string(raw) { - t.Fatalf("stored snapshot changed during import: got %s want %s", stored, raw) - } - deployed := mustReadFile(t, authPath) - if string(deployed) != string(raw) { - t.Fatalf("deployed snapshot changed during import: got %s want %s", deployed, raw) - } - account, err := registry.Get("stock-account") - if err != nil { - t.Fatal(err) - } - if account.Alias != "personal" || account.CredentialRef != "stock-account" { - t.Fatalf("imported account = %#v", account) - } -} - -func TestManagerImportRejectsSnapshotIdentityMismatch(t *testing.T) { - manager, _, _, _ := newManagerFixture(t, nil, nil) - _, err := manager.Import(context.Background(), ImportRequest{ - AccountID: "requested-account", - AuthJSON: loginSnapshot("snapshot-account", "stock-token"), - }) - if !errors.Is(err, ErrLoginIdentityMismatch) { - t.Fatalf("Import() error = %v, want ErrLoginIdentityMismatch", err) - } -} - -func TestManagerOverwriteOfActiveProfileRedeploysNewSnapshot(t *testing.T) { - service := &fakeAuthService{ - loginResults: []LoginResult{ - {AccountID: "acct-a", AuthJSON: loginSnapshot("acct-a", "token-a")}, - {AccountID: "acct-a", AuthJSON: loginSnapshot("acct-a", "token-a-new")}, - }, - } - manager, vault, _, authPath := newManagerFixture(t, service, nil) - if _, err := manager.Login(context.Background(), LoginRequest{}); err != nil { - t.Fatal(err) - } - overwrite, err := manager.Login(context.Background(), LoginRequest{Overwrite: true}) - if err != nil { - t.Fatalf("overwrite Login() error = %v", err) - } - if !overwrite.Activated { - t.Fatalf("overwrite Login() = %#v, want active redeployment", overwrite) - } - if got := readAuthIdentity(t, authPath); got != "acct-a" { - t.Fatalf("auth identity after overwrite = %q, want acct-a", got) - } - raw, err := vault.Load("acct-a") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(raw), "token-a-new") { - t.Fatalf("vault after overwrite does not contain new snapshot: %s", raw) - } - if !strings.Contains(string(mustReadFile(t, authPath)), "token-a-new") { - t.Fatalf("auth.json after overwrite does not contain new snapshot") - } -} - -func TestManagerActivationFailureAndCancellationPreservePreviousActive(t *testing.T) { - service := &fakeAuthService{ - loginResults: []LoginResult{ - {AccountID: "acct-a", AuthJSON: loginSnapshot("acct-a", "token-a")}, - {AccountID: "acct-b", AuthJSON: loginSnapshot("acct-b", "token-b")}, - }, - } - manager, vault, registry, authPath := newManagerFixture(t, service, nil) - if _, err := manager.Login(context.Background(), LoginRequest{}); err != nil { - t.Fatal(err) - } - if _, err := manager.Login(context.Background(), LoginRequest{}); err != nil { - t.Fatal(err) - } - manager.deployer = failingDeployer{vault: vault, authPath: authPath, failID: "acct-b"} - if _, err := manager.Activate(context.Background(), "acct-b"); err == nil { - t.Fatal("Activate(B) error = nil, want failure") - } - active, ok, err := registry.Active() - if err != nil || !ok || active.ID != "acct-a" { - t.Fatalf("active after failed activation = %#v, %v, %v", active, ok, err) - } - if got := readAuthIdentity(t, authPath); got != "acct-a" { - t.Fatalf("auth after failed activation = %q, want acct-a", got) - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if _, err := manager.Activate(ctx, "acct-b"); !errors.Is(err, context.Canceled) { - t.Fatalf("cancelled Activate() error = %v, want context.Canceled", err) - } - active, ok, err = registry.Active() - if err != nil || !ok || active.ID != "acct-a" { - t.Fatalf("active after cancelled activation = %#v, %v, %v", active, ok, err) - } -} - -func TestManagerRejectsProviderErrorsWithoutLeakingSecrets(t *testing.T) { - service := &fakeAuthService{loginErr: errors.New(`refresh_token="super-secret"`)} - manager, _, _, _ := newManagerFixture(t, service, nil) - _, err := manager.Login(context.Background(), LoginRequest{AccountID: "acct-a"}) - if !errors.Is(err, ErrNativeLoginFailed) { - t.Fatalf("Login() error = %v, want ErrNativeLoginFailed", err) - } - if strings.Contains(err.Error(), "super-secret") || strings.Contains(err.Error(), "refresh_token") { - t.Fatalf("Login() leaked provider error: %v", err) - } -} - -func TestManagerSafeRemovalAndForceMarkerClearing(t *testing.T) { - service := &fakeAuthService{loginResults: []LoginResult{{AccountID: "acct-a", AuthJSON: loginSnapshot("acct-a", "token-a")}, {AccountID: "acct-b", AuthJSON: loginSnapshot("acct-b", "token-b")}}} - manager, _, registry, authPath := newManagerFixture(t, service, nil) - if _, err := manager.Login(context.Background(), LoginRequest{}); err != nil { - t.Fatal(err) - } - if _, err := manager.Login(context.Background(), LoginRequest{}); err != nil { - t.Fatal(err) - } - if err := manager.Remove("acct-a", false); !errors.Is(err, ErrActiveAccount) { - t.Fatalf("Remove(active) error = %v, want ErrActiveAccount", err) - } - if err := manager.Remove("acct-b", false); err != nil { - t.Fatalf("Remove(inactive) error = %v", err) - } - if err := manager.Remove("acct-a", true); err != nil { - t.Fatalf("Remove(active, force) error = %v", err) - } - if _, ok, err := registry.Active(); err != nil || ok { - t.Fatalf("active after forced removal = ok %v, err %v", ok, err) - } - if _, err := os.Stat(authPath); err != nil { - t.Fatalf("forced removal should preserve deployed auth.json: %v", err) - } -} - -func readAuthIdentity(t *testing.T, path string) string { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - id, err := credentials.ExtractAccountID(raw) - if err != nil { - t.Fatal(err) - } - return id -} - -func mustReadFile(t *testing.T, path string) []byte { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return raw -} diff --git a/controller/internal/automation/policy_loop.go b/controller/internal/automation/policy_loop.go deleted file mode 100644 index 8d4066df..00000000 --- a/controller/internal/automation/policy_loop.go +++ /dev/null @@ -1,304 +0,0 @@ -// Package automation connects runtime quota events to the controller's -// observation and policy domains. It intentionally stops at the transition -// callback: the transition coordinator/runtime owns safe-boundary auth -// mechanics, while this loop owns trigger deduplication, provider refresh, -// reset waiting, and bounded wake-ups. -package automation - -import ( - "context" - "errors" - "sort" - "time" - - "codexmarathon/controller/internal/policy" - "codexmarathon/controller/internal/telemetry" -) - -var ( - ErrMissingAccountSource = errors.New("automation account source is unavailable") - ErrMissingPolicy = errors.New("automation policy engine is unavailable") - ErrMissingRouter = errors.New("automation usage router is unavailable") -) - -// EventType identifies runtime or controller events that can wake policy. -type EventType string - -const ( - EventThresholdReached EventType = "threshold_reached" - EventUsageLimitExceeded EventType = "usage_limit_exceeded" - EventResetDue EventType = "reset_due" - EventTelemetryChanged EventType = "telemetry_changed" -) - -// Event is intentionally secret-free. EventID should be the runtime's stable -// notification ID when available; duplicate IDs are suppressed by policy. -type Event struct { - ID string - Type EventType - AccountID string - OccurredAt time.Time -} - -// AccountSource supplies stable configured IDs and the current runtime -// account. Registry implementations can adapt to these two callbacks without -// exposing credential material to the automation package. -type AccountSource struct { - IDs func() ([]string, error) - Active func() (string, error) -} - -// TransitionFunc is called only after policy returns a verified candidate. -// The callback should synchronously run the transition coordinator and return -// an error if safe-boundary/auth/identity verification did not commit. -type TransitionFunc func(context.Context, string) error - -// Config controls one event-driven policy loop. A zero poll interval uses a -// conservative bounded wake-up; server reset timestamps remain the preferred -// wake-up and are never treated as proof of restored quota. -type Config struct { - Router *telemetry.MultiAccountUsageRouter - Policy *policy.Engine - Accounts AccountSource - Transition TransitionFunc - Thresholds policy.Thresholds - FreshnessTTL time.Duration - PollInterval time.Duration - MaxWaitSlice time.Duration - Now func() time.Time - // OnError receives a non-fatal observation/transition error. The loop - // keeps running and schedules a bounded retry; a transient provider or - // runtime failure must not silently disable automatic account switching. - OnError func(error) -} - -// ObservationResult is returned for operator diagnostics and tests. Each -// account retains its provider error; a failed account does not prevent other -// accounts from being evaluated. -type ObservationResult struct { - Event Event - Decision policy.PolicyDecision - Observations []telemetry.AccountObservation - TransitionRan bool - WaitUntil time.Time -} - -// Loop is safe for a single Run goroutine plus a status caller that invokes -// Evaluate directly. Policy itself owns event/cooldown synchronization. -type Loop struct { - config Config -} - -func New(config Config) *Loop { - if config.Now == nil { - config.Now = func() time.Time { return time.Now().UTC() } - } - if config.FreshnessTTL == 0 { - config.FreshnessTTL = 5 * time.Minute - } - if config.PollInterval <= 0 { - config.PollInterval = 5 * time.Second - } - if config.MaxWaitSlice <= 0 { - config.MaxWaitSlice = 30 * time.Second - } - return &Loop{config: config} -} - -// Evaluate observes every configured account once, asks policy for an action, -// and invokes the transition callback only for a PolicyTransition decision. -// Reset/no-data decisions are returned with WaitUntil but are never converted -// into an optimistic account switch. -func (l *Loop) Evaluate(ctx context.Context, event Event) (ObservationResult, error) { - if l == nil { - return ObservationResult{}, errors.New("nil automation loop") - } - if ctx == nil { - ctx = context.Background() - } - if l.config.Router == nil { - return ObservationResult{}, ErrMissingRouter - } - if l.config.Policy == nil { - return ObservationResult{}, ErrMissingPolicy - } - if l.config.Accounts.IDs == nil || l.config.Accounts.Active == nil { - return ObservationResult{}, ErrMissingAccountSource - } - ids, err := l.config.Accounts.IDs() - if err != nil { - return ObservationResult{}, err - } - active, err := l.config.Accounts.Active() - if err != nil { - return ObservationResult{}, err - } - now := event.OccurredAt - if now.IsZero() { - now = l.config.Now() - } - now = now.UTC() - observations, err := l.config.Router.Observe(ctx, ids, telemetry.ObserveOptions{MaxAge: l.config.FreshnessTTL, Now: now}) - if err != nil { - return ObservationResult{}, err - } - accounts := make([]telemetry.AccountTelemetry, 0, len(observations)) - for _, observation := range observations { - account := observation.Telemetry.Clone() - if account.AccountID == "" { - account.AccountID = observation.AccountID - } - if observation.Err != nil { - account.IsUsable = false - } - accounts = append(accounts, account) - } - trigger := policy.TriggerManual - switch event.Type { - case EventThresholdReached: - trigger = policy.TriggerProactiveThreshold - case EventUsageLimitExceeded: - trigger = policy.TriggerUsageLimitExceeded - case EventResetDue: - trigger = policy.TriggerResetRevalidation - case EventTelemetryChanged: - // Telemetry changes alone should be interpreted as a threshold check; - // policy will stay when the active account remains below threshold. - trigger = policy.TriggerProactiveThreshold - } - decision := l.config.Policy.EvaluateInput(policy.EvaluationInput{ - Accounts: accounts, - ActiveAccountID: active, - Trigger: trigger, - Thresholds: l.config.Thresholds, - FreshnessTTL: l.config.FreshnessTTL, - EventID: event.ID, - Now: now, - }) - result := ObservationResult{Event: event, Decision: decision} - if decision.Type == policy.PolicyTransition { - if l.config.Transition == nil { - l.config.Policy.TransitionFailed() - return result, errors.New("automation transition callback is unavailable") - } - if err := ctx.Err(); err != nil { - l.config.Policy.TransitionFailed() - return result, err - } - if err := l.config.Transition(ctx, decision.AccountID); err != nil { - l.config.Policy.TransitionFailed() - return result, err - } - l.config.Policy.RecordTransition(active, decision.AccountID, now) - l.config.Policy.ConfirmTransition(decision.AccountID) - result.TransitionRan = true - } - result.WaitUntil = decision.RetryAt - if result.WaitUntil.IsZero() { - result.WaitUntil = decision.ResetAt - } - if result.WaitUntil.IsZero() && (decision.Type == policy.PolicyNoTelemetry || decision.Type == policy.PolicyCooldown) { - result.WaitUntil = now.Add(l.config.PollInterval) - } - return result, nil -} - -// Run consumes events until cancellation or the stream closes. Waiting is -// timer-driven and capped by MaxWaitSlice so a far-away reset or missing data -// cannot make shutdown unresponsive. Every wake re-observes telemetry before -// policy can authorize a transition. -func (l *Loop) Run(ctx context.Context, events <-chan Event) error { - if l == nil { - return errors.New("nil automation loop") - } - if ctx == nil { - ctx = context.Background() - } - if events == nil { - return errors.New("automation event stream is nil") - } - var timer *time.Timer - var timerC <-chan time.Time - defer func() { - if timer != nil { - timer.Stop() - } - }() - arm := func(at time.Time) { - if at.IsZero() { - at = l.config.Now().Add(l.config.PollInterval) - } - delay := time.Until(at) - if delay < 0 { - delay = 0 - } - if delay > l.config.MaxWaitSlice { - delay = l.config.MaxWaitSlice - } - if timer == nil { - timer = time.NewTimer(delay) - } else { - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - timer.Reset(delay) - } - timerC = timer.C - } - for { - select { - case <-ctx.Done(): - return ctx.Err() - case event, ok := <-events: - if !ok { - return nil - } - result, err := l.Evaluate(ctx, event) - if err != nil { - if l.config.OnError != nil { - l.config.OnError(err) - } - arm(l.config.Now().Add(l.config.PollInterval)) - continue - } - if !result.WaitUntil.IsZero() { - arm(result.WaitUntil) - } - case <-timerC: - wake := Event{Type: EventResetDue, OccurredAt: l.config.Now()} - result, err := l.Evaluate(ctx, wake) - if err != nil { - if l.config.OnError != nil { - l.config.OnError(err) - } - arm(l.config.Now().Add(l.config.PollInterval)) - continue - } - if !result.WaitUntil.IsZero() { - arm(result.WaitUntil) - } else { - timerC = nil - } - } - } -} - -// AccountIDs normalizes a source list for adapters that want deterministic -// output before constructing a Loop. -func AccountIDs(ids []string) []string { - seen := make(map[string]struct{}, len(ids)) - for _, id := range ids { - if id != "" { - seen[id] = struct{}{} - } - } - result := make([]string, 0, len(seen)) - for id := range seen { - result = append(result, id) - } - sort.Strings(result) - return result -} diff --git a/controller/internal/automation/policy_loop_test.go b/controller/internal/automation/policy_loop_test.go deleted file mode 100644 index 01d76183..00000000 --- a/controller/internal/automation/policy_loop_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package automation - -import ( - "context" - "errors" - "testing" - "time" - - "codexmarathon/controller/internal/policy" - "codexmarathon/controller/internal/telemetry" -) - -func TestEvaluateRefreshesInactiveAccountsAndRunsOneTransition(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - store := telemetry.NewStateStore(func() time.Time { return now }) - router := telemetry.NewMultiAccountUsageRouter(nil, store) - if err := router.Register("account-a", telemetry.ProviderFunc(func(context.Context, string) (telemetry.SnapshotResult, error) { - return telemetry.UsableSnapshot(autoAccount("account-a", now, 95)), nil - })); err != nil { - t.Fatal(err) - } - if err := router.Register("account-b", telemetry.ProviderFunc(func(context.Context, string) (telemetry.SnapshotResult, error) { - return telemetry.UsableSnapshot(autoAccount("account-b", now, 5)), nil - })); err != nil { - t.Fatal(err) - } - transitions := 0 - loop := New(Config{ - Router: router, - Policy: policy.NewConfiguredEngine(policy.EngineConfig{FreshnessTTL: time.Hour}, nil, func() time.Time { return now }), - Accounts: AccountSource{ - IDs: func() ([]string, error) { return []string{"account-b", "account-a"}, nil }, - Active: func() (string, error) { return "account-a", nil }, - }, - Transition: func(context.Context, string) error { transitions++; return nil }, - Thresholds: policy.Thresholds{PrimaryPercent: 80, SecondaryPercent: 90}, - FreshnessTTL: time.Hour, - Now: func() time.Time { return now }, - }) - result, err := loop.Evaluate(context.Background(), Event{ID: "threshold-1", Type: EventThresholdReached, OccurredAt: now}) - if err != nil { - t.Fatal(err) - } - if !result.TransitionRan || result.Decision.AccountID != "account-b" || transitions != 1 { - t.Fatalf("result = %#v transitions=%d", result, transitions) - } -} - -func TestEvaluateDoesNotSwitchWhenProviderFails(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - router := telemetry.NewMultiAccountUsageRouter(nil, telemetry.NewStateStore()) - _ = router.Register("account-a", telemetry.ProviderFunc(func(context.Context, string) (telemetry.SnapshotResult, error) { - return telemetry.UnusableSnapshot(errors.New("network unavailable")), errors.New("network unavailable") - })) - _ = router.Register("account-b", telemetry.ProviderFunc(func(context.Context, string) (telemetry.SnapshotResult, error) { - return telemetry.UnusableSnapshot(errors.New("network unavailable")), errors.New("network unavailable") - })) - transitions := 0 - loop := New(Config{ - Router: router, - Policy: policy.NewEngine(nil, func() time.Time { return now }), - Accounts: AccountSource{ - IDs: func() ([]string, error) { return []string{"account-a", "account-b"}, nil }, - Active: func() (string, error) { return "account-a", nil }, - }, - Transition: func(context.Context, string) error { transitions++; return nil }, - Now: func() time.Time { return now }, - }) - result, err := loop.Evaluate(context.Background(), Event{Type: EventUsageLimitExceeded, OccurredAt: now}) - if err != nil { - t.Fatal(err) - } - if result.Decision.Type == policy.PolicyTransition || transitions != 0 { - t.Fatalf("provider failures authorized transition: %#v", result) - } -} - -func TestRunReturnsOnCancellationWhileWaiting(t *testing.T) { - now := time.Now().UTC() - router := telemetry.NewMultiAccountUsageRouter(nil, telemetry.NewStateStore()) - _ = router.Register("account-a", telemetry.ProviderFunc(func(context.Context, string) (telemetry.SnapshotResult, error) { - return telemetry.UnusableSnapshot(errors.New("no data")), errors.New("no data") - })) - loop := New(Config{ - Router: router, - Policy: policy.NewEngine(nil), - Accounts: AccountSource{ - IDs: func() ([]string, error) { return []string{"account-a"}, nil }, - Active: func() (string, error) { return "account-a", nil }, - }, - Now: func() time.Time { return now }, - PollInterval: time.Hour, - MaxWaitSlice: time.Millisecond, - }) - events := make(chan Event, 1) - events <- Event{Type: EventUsageLimitExceeded, OccurredAt: now} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - timer := time.AfterFunc(20*time.Millisecond, cancel) - defer timer.Stop() - if err := loop.Run(ctx, events); !errors.Is(err, context.Canceled) { - t.Fatalf("Run() error = %v, want context.Canceled", err) - } -} - -func autoAccount(accountID string, observedAt time.Time, usage float64) telemetry.AccountTelemetry { - return telemetry.AccountTelemetry{AccountID: accountID, IsUsable: true, ObservedAt: observedAt, Limits: map[string]telemetry.LimitTelemetry{ - "codex": {LimitID: "codex", Windows: []telemetry.WindowTelemetry{{Kind: telemetry.PrimaryWindow, UsedPercent: usage, ObservedAt: observedAt, Freshness: telemetry.WindowFresh}}}, - }} -} diff --git a/controller/internal/companion/process.go b/controller/internal/companion/process.go deleted file mode 100644 index 99d11f11..00000000 --- a/controller/internal/companion/process.go +++ /dev/null @@ -1,606 +0,0 @@ -// Package companion owns the process boundary used when CodexMarathon is -// installed beside an existing Codex CLI. -// -// A credential transition cannot safely replace auth.json while Codex is -// running: the CLI may have the old credentials cached in memory and can -// write them back while shutting down. RestartAndResume therefore enforces a -// strict order: -// -// 1. ask the launched Codex process to exit gracefully; -// 2. wait until it has really exited (escalating only after the deadline); -// 3. run the caller's credential deployment hook; -// 4. launch the installed Codex executable with `resume` and the same -// conversation target. -// -// The package intentionally does not read, parse, or log auth material. The -// caller owns the atomic credential deployment and supplies only a callback -// for that step. -package companion - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "time" -) - -var ( - ErrNilSupervisor = errors.New("nil Codex companion supervisor") - ErrAlreadyStarted = errors.New("Codex companion is already started") - ErrNotStarted = errors.New("Codex companion is not started") - ErrResumeTargetMissing = errors.New("a conversation id or --last is required to resume Codex") - ErrResumeTargetInvalid = errors.New("conversation id and --last cannot be used together") -) - -const ( - defaultShutdownTimeout = 10 * time.Second - defaultKillTimeout = 5 * time.Second -) - -// ResumeTarget identifies the conversation Codex should reopen after a -// controlled restart. Exactly one of ThreadID and Last must be set. -// ExtraArgs are appended after the resume target and are useful for stable -// Codex CLI options such as --no-alt-screen. They are argv values, never a -// shell expression. -type ResumeTarget struct { - ThreadID string - Last bool - WorkingDir string - ExtraArgs []string -} - -func (t ResumeTarget) validate() error { - threadID := strings.TrimSpace(t.ThreadID) - if threadID == "" && !t.Last { - return ErrResumeTargetMissing - } - if threadID != "" && t.Last { - return ErrResumeTargetInvalid - } - for _, arg := range t.ExtraArgs { - if strings.TrimSpace(arg) == "" { - return errors.New("resume argument must not be empty") - } - } - if dir := strings.TrimSpace(t.WorkingDir); dir != "" { - if !filepath.IsAbs(dir) { - return errors.New("resume working directory must be absolute") - } - } - return nil -} - -// BuildResumeCommand returns an argv-safe invocation of the installed Codex -// CLI's native resume command. It performs no process or filesystem side -// effects, making it suitable for callers that need to preview or journal the -// selected conversation target without exposing credentials. -func BuildResumeCommand(command []string, target ResumeTarget) ([]string, error) { - if len(command) == 0 || strings.TrimSpace(command[0]) == "" { - return nil, errors.New("installed Codex command is required") - } - if isBundledRuntimeCommand(command[0]) { - return nil, errors.New("companion command must be the installed codex CLI, not a bundled app-server runtime") - } - if err := target.validate(); err != nil { - return nil, err - } - result := append([]string(nil), command...) - result = append(result, "resume") - if threadID := strings.TrimSpace(target.ThreadID); threadID != "" { - result = append(result, threadID) - } else { - result = append(result, "--last") - } - if dir := strings.TrimSpace(target.WorkingDir); dir != "" { - result = append(result, "--cd", dir) - } - result = append(result, target.ExtraArgs...) - return result, nil -} - -// Config controls the installed Codex command. Command is argv-shaped and -// must begin with the user's installed `codex` executable. The supervisor -// never searches for or starts a bundled app-server. -type Config struct { - Command []string - WorkingDir string - Environment []string - ShutdownTimeout time.Duration - KillTimeout time.Duration - ProcessFactory ProcessFactory -} - -func (c Config) withDefaults() (Config, error) { - if len(c.Command) == 0 || strings.TrimSpace(c.Command[0]) == "" { - return Config{}, errors.New("installed Codex command is required") - } - if isBundledRuntimeCommand(c.Command[0]) { - return Config{}, errors.New("companion command must be the installed codex CLI, not a bundled app-server runtime") - } - if strings.TrimSpace(c.WorkingDir) != "" { - if !filepath.IsAbs(c.WorkingDir) { - return Config{}, errors.New("Codex working directory must be absolute") - } - info, err := os.Stat(c.WorkingDir) - if err != nil { - return Config{}, fmt.Errorf("stat Codex working directory: %w", err) - } - if !info.IsDir() { - return Config{}, errors.New("Codex working directory is not a directory") - } - } - if c.ShutdownTimeout <= 0 { - c.ShutdownTimeout = defaultShutdownTimeout - } - if c.KillTimeout <= 0 { - c.KillTimeout = defaultKillTimeout - } - if c.ProcessFactory == nil { - c.ProcessFactory = NewExecProcess - } - c.Command = append([]string(nil), c.Command...) - c.Environment = append([]string(nil), c.Environment...) - return c, nil -} - -// Process is the small lifecycle seam needed by Supervisor. Done is a -// broadcast completion signal; WaitError returns the process wait result once -// Done has closed. -type Process interface { - PID() int - // Done is closed after the process has been waited. It is a broadcast - // signal, so multiple lifecycle callers can observe one exit safely. - Done() <-chan struct{} - WaitError() error - Interrupt() error - Terminate() error - Kill() error -} - -// ProcessFactory starts one argv-shaped command. It receives a copy of the -// configured environment and must not mutate the caller's slices. -type ProcessFactory func(command []string, workingDir string, environment []string) (Process, error) - -// ExecProcess is the production Process implementation backed by os/exec. -type ExecProcess struct { - cmd *exec.Cmd - done chan struct{} - waitMu sync.RWMutex - waitErr error -} - -// NewExecProcess starts an installed Codex process. stdout/stderr are -// inherited so Codex remains an interactive CLI when launched through the -// companion. The process is placed in its own group on Unix so shutdown does -// not leave an app-server child behind. -func NewExecProcess(command []string, workingDir string, environment []string) (Process, error) { - if len(command) == 0 || strings.TrimSpace(command[0]) == "" { - return nil, errors.New("Codex command is empty") - } - cmd := exec.Command(command[0], command[1:]...) - cmd.Dir = workingDir - // Keep the installed Codex CLI interactive when Marathon launches it. - // os/exec otherwise connects stdin to the null device and discards stdout - // and stderr when these fields are left nil. - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if len(environment) > 0 { - cmd.Env = append(os.Environ(), environment...) - } - configureProcessCommand(cmd) - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("start installed Codex %q: %w", command[0], err) - } - process := &ExecProcess{cmd: cmd, done: make(chan struct{})} - go func() { - err := cmd.Wait() - process.waitMu.Lock() - process.waitErr = err - process.waitMu.Unlock() - close(process.done) - }() - return process, nil -} - -func (p *ExecProcess) PID() int { - if p == nil || p.cmd == nil || p.cmd.Process == nil { - return 0 - } - return p.cmd.Process.Pid -} - -func (p *ExecProcess) Done() <-chan struct{} { - if p == nil { - return nil - } - return p.done -} - -func (p *ExecProcess) WaitError() error { - if p == nil { - return nil - } - p.waitMu.RLock() - err := p.waitErr - p.waitMu.RUnlock() - return err -} - -func (p *ExecProcess) Interrupt() error { - if p == nil || p.cmd == nil { - return nil - } - return interruptProcess(p.cmd) -} - -func (p *ExecProcess) Terminate() error { - if p == nil || p.cmd == nil { - return nil - } - return terminateProcess(p.cmd) -} - -func (p *ExecProcess) Kill() error { - if p == nil || p.cmd == nil { - return nil - } - return killProcess(p.cmd) -} - -// State describes the process lifecycle without including environment or -// credentials. -type State string - -const ( - StateStopped State = "stopped" - StateStarting State = "starting" - StateRunning State = "running" - StateStopping State = "stopping" - StateFailed State = "failed" -) - -type Status struct { - State State `json:"state"` - PID int `json:"pid,omitempty"` - Command string `json:"command,omitempty"` - RestartCount int `json:"restart_count"` - StartedAt time.Time `json:"started_at,omitempty"` - StoppedAt time.Time `json:"stopped_at,omitempty"` - LastExit string `json:"last_exit,omitempty"` - LastError string `json:"last_error,omitempty"` - LastResumeID string `json:"last_resume_id,omitempty"` - LastResumeLast bool `json:"last_resume_last,omitempty"` -} - -// Supervisor controls one installed Codex process. A supervisor only tracks -// processes it started; it never guesses at or signals an unrelated Codex -// process owned by the user. -type Supervisor struct { - config Config - - mu sync.RWMutex - process Process - starting bool - startDone chan struct{} - status Status -} - -func NewSupervisor(config Config) (*Supervisor, error) { - config, err := config.withDefaults() - if err != nil { - return nil, err - } - return &Supervisor{ - config: config, - status: Status{State: StateStopped, Command: config.Command[0]}, - }, nil -} - -// Status returns a race-free snapshot. -func (s *Supervisor) Status() Status { - if s == nil { - return Status{State: StateFailed, LastError: ErrNilSupervisor.Error()} - } - s.mu.RLock() - status := s.status - s.mu.RUnlock() - return status -} - -// Start launches the installed Codex command supplied in Config.Command. -func (s *Supervisor) Start() error { - return s.start(s.config.Command, s.config.WorkingDir, ResumeTarget{}) -} - -func (s *Supervisor) start(command []string, workingDir string, resume ResumeTarget) error { - if s == nil { - return ErrNilSupervisor - } - s.mu.Lock() - if s.process != nil || s.starting { - s.mu.Unlock() - return ErrAlreadyStarted - } - s.starting = true - s.startDone = make(chan struct{}) - s.status.State = StateStarting - s.status.LastError = "" - s.status.Command = command[0] - s.mu.Unlock() - - process, err := s.config.ProcessFactory( - append([]string(nil), command...), - workingDir, - append([]string(nil), s.config.Environment...), - ) - if err != nil { - s.mu.Lock() - s.starting = false - close(s.startDone) - s.startDone = nil - s.status.State = StateFailed - s.status.LastError = err.Error() - s.mu.Unlock() - return err - } - if process == nil { - err = errors.New("Codex process factory returned nil process") - s.mu.Lock() - s.starting = false - close(s.startDone) - s.startDone = nil - s.status.State = StateFailed - s.status.LastError = err.Error() - s.mu.Unlock() - return err - } - now := time.Now().UTC() - s.mu.Lock() - s.starting = false - close(s.startDone) - s.startDone = nil - s.process = process - s.status.State = StateRunning - s.status.PID = process.PID() - s.status.StartedAt = now - s.status.StoppedAt = time.Time{} - s.status.LastResumeID = strings.TrimSpace(resume.ThreadID) - s.status.LastResumeLast = resume.Last - s.mu.Unlock() - return nil -} - -// Stop asks the launched process to finish cleanly and waits for its actual -// exit before returning. The configured timeout is independent from the -// caller's context; the shorter deadline wins. -func (s *Supervisor) Stop(ctx context.Context) error { - if s == nil { - return nil - } - if ctx == nil { - ctx = context.Background() - } - s.mu.Lock() - process := s.process - startDone := s.startDone - starting := s.starting - if process == nil && starting { - s.mu.Unlock() - select { - case <-startDone: - return s.Stop(ctx) - case <-ctx.Done(): - return ctx.Err() - } - } - if process == nil { - s.mu.Unlock() - return nil - } - s.status.State = StateStopping - s.mu.Unlock() - - if err := process.Interrupt(); err != nil { - // An interrupt failure is recoverable: the process may still honour a - // termination request. Keep waiting and report only if escalation also - // fails. - _ = process.Terminate() - } - waitErr := waitForProcess(ctx, process, s.config.ShutdownTimeout) - if waitErr == nil { - s.finishStop(process, nil) - return nil - } - if !errors.Is(waitErr, context.DeadlineExceeded) && !errors.Is(waitErr, context.Canceled) { - // The process exited with a non-zero status. A deliberate stop still - // completed; retain the exit detail in diagnostics but do not turn a - // normal signal exit into a failed restart. - s.finishStop(process, waitErr) - return nil - } - if err := process.Terminate(); err != nil { - _ = process.Kill() - } - // A canceled caller still must not leave Codex running with an ambiguous - // auth file. Once escalation starts, use a fresh bounded wait context. - killErr := waitForProcess(context.Background(), process, s.config.KillTimeout) - if killErr != nil { - if isProcessDone(process) { - s.finishStop(process, killErr) - return nil - } - _ = process.Kill() - finalErr := waitForProcess(context.Background(), process, s.config.KillTimeout) - if finalErr != nil && !isProcessDone(process) { - return fmt.Errorf("stop installed Codex: %w", finalErr) - } - if finalErr != nil { - s.finishStop(process, finalErr) - } else { - s.finishStop(process, nil) - } - return nil - } - s.finishStop(process, nil) - return nil -} - -func (s *Supervisor) finishStop(process Process, exitErr error) { - s.mu.Lock() - if s.process == process { - s.process = nil - s.status.State = StateStopped - s.status.PID = 0 - s.status.StoppedAt = time.Now().UTC() - if exitErr != nil { - s.status.LastExit = exitErr.Error() - } - } - s.mu.Unlock() -} - -// RestartAndResume performs the only safe fallback when the installed Codex -// process does not expose the live Marathon reload interface. It waits for -// the old process to exit, invokes deployCredentials, and starts `codex -// resume ` (or `codex resume --last`). The callback must atomically -// deploy the selected auth snapshot and must not start Codex itself. -func (s *Supervisor) RestartAndResume(ctx context.Context, target ResumeTarget, deployCredentials func(context.Context) error) error { - if s == nil { - return ErrNilSupervisor - } - if err := target.validate(); err != nil { - return err - } - if deployCredentials == nil { - return errors.New("credential deployment callback is required") - } - if ctx == nil { - ctx = context.Background() - } - if err := s.Stop(ctx); err != nil { - return err - } - if err := deployCredentials(ctx); err != nil { - s.mu.Lock() - s.status.State = StateFailed - s.status.LastError = fmt.Sprintf("deploy credentials before Codex restart: %v", err) - s.mu.Unlock() - return err - } - command, err := BuildResumeCommand(s.config.Command, target) - if err != nil { - return err - } - s.mu.Lock() - s.status.RestartCount++ - s.mu.Unlock() - workingDir := s.config.WorkingDir - if strings.TrimSpace(target.WorkingDir) != "" { - workingDir = target.WorkingDir - } - return s.start(command, workingDir, target) -} - -// Wait waits for the currently tracked process. It is useful to let a CLI -// wrapper mirror Codex's lifetime. An intentional Stop returns nil; an -// unexpected process exit returns its wait error. -func (s *Supervisor) Wait(ctx context.Context) error { - if s == nil { - return ErrNilSupervisor - } - if ctx == nil { - ctx = context.Background() - } - s.mu.RLock() - process := s.process - s.mu.RUnlock() - if process == nil { - return ErrNotStarted - } - select { - case <-process.Done(): - err := process.WaitError() - s.mu.Lock() - if s.process == process { - s.process = nil - s.status.PID = 0 - s.status.StoppedAt = time.Now().UTC() - if err == nil { - s.status.State = StateStopped - } else { - s.status.State = StateFailed - s.status.LastExit = err.Error() - } - } - s.mu.Unlock() - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -func waitForProcess(ctx context.Context, process Process, timeout time.Duration) error { - if process == nil || process.Done() == nil { - return errors.New("Codex process has no wait channel") - } - // The caller passes a context with the desired deadline. Stop creates a - // bounded context for each escalation phase; keeping the process itself as - // the input means observing exit is broadcast-safe. - if timeout <= 0 { - timeout = defaultShutdownTimeout - } - timer := time.NewTimer(timeout) - defer timer.Stop() - select { - case <-process.Done(): - return process.WaitError() - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return context.DeadlineExceeded - } -} - -func isProcessDone(process Process) bool { - if process == nil || process.Done() == nil { - return false - } - select { - case <-process.Done(): - return true - default: - return false - } -} - -// ResolveInstalledCodex locates the user's installed Codex executable. It -// deliberately uses PATH discovery and does not fall back to a bundled -// codex-app-server binary. -func ResolveInstalledCodex() (string, error) { - path, err := exec.LookPath("codex") - if err != nil { - return "", fmt.Errorf("find installed Codex CLI: %w", err) - } - return path, nil -} - -// IsCodexCommand performs a conservative executable-name check for callers -// that want to reject an accidental app-server path before starting it. -func IsCodexCommand(command string) bool { - base := strings.ToLower(filepath.Base(strings.TrimSpace(command))) - return base == "codex" || base == "codex.exe" -} - -func isBundledRuntimeCommand(command string) bool { - base := strings.ToLower(filepath.Base(strings.TrimSpace(command))) - return base == "codex-app-server" || - base == "codex-app-server.exe" || - base == "codexmarathon-runtime" || - base == "codexmarathon-runtime.exe" -} diff --git a/controller/internal/companion/process_test.go b/controller/internal/companion/process_test.go deleted file mode 100644 index bb92d0df..00000000 --- a/controller/internal/companion/process_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package companion - -import ( - "context" - "errors" - "reflect" - "sync" - "testing" - "time" -) - -type fakeProcess struct { - mu sync.Mutex - pid int - done chan struct{} - waitErr error - interrupts int - terminates int - kills int - ignoreUntil string -} - -func newFakeProcess(pid int) *fakeProcess { - return &fakeProcess{pid: pid, done: make(chan struct{})} -} - -func (p *fakeProcess) PID() int { return p.pid } - -func (p *fakeProcess) Done() <-chan struct{} { return p.done } - -func (p *fakeProcess) WaitError() error { - p.mu.Lock() - defer p.mu.Unlock() - return p.waitErr -} - -func (p *fakeProcess) Interrupt() error { - p.mu.Lock() - p.interrupts++ - ignore := p.ignoreUntil == "interrupt" - p.mu.Unlock() - if !ignore { - p.finish(nil) - } - return nil -} - -func (p *fakeProcess) Terminate() error { - p.mu.Lock() - p.terminates++ - ignore := p.ignoreUntil == "terminate" - p.mu.Unlock() - if !ignore { - p.finish(errors.New("terminated")) - } - return nil -} - -func (p *fakeProcess) Kill() error { - p.mu.Lock() - p.kills++ - ignore := p.ignoreUntil == "kill" - p.mu.Unlock() - if !ignore { - p.finish(errors.New("killed")) - } - return nil -} - -func (p *fakeProcess) finish(err error) { - p.mu.Lock() - defer p.mu.Unlock() - select { - case <-p.done: - return - default: - } - p.waitErr = err - close(p.done) -} - -func (p *fakeProcess) counts() (interrupts, terminates, kills int) { - p.mu.Lock() - defer p.mu.Unlock() - return p.interrupts, p.terminates, p.kills -} - -type recordingFactory struct { - mu sync.Mutex - commands [][]string - dirs []string - envs [][]string - processes []Process -} - -func (f *recordingFactory) start(command []string, dir string, env []string) (Process, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.commands = append(f.commands, append([]string(nil), command...)) - f.dirs = append(f.dirs, dir) - f.envs = append(f.envs, append([]string(nil), env...)) - if len(f.processes) == 0 { - return nil, errors.New("test process queue is empty") - } - process := f.processes[0] - f.processes = f.processes[1:] - return process, nil -} - -func (f *recordingFactory) snapshot() ([][]string, []string, [][]string) { - f.mu.Lock() - defer f.mu.Unlock() - commands := make([][]string, len(f.commands)) - for i := range f.commands { - commands[i] = append([]string(nil), f.commands[i]...) - } - return commands, append([]string(nil), f.dirs...), append([][]string(nil), f.envs...) -} - -func TestRestartAndResumeStopsBeforeCredentialDeployment(t *testing.T) { - first := newFakeProcess(101) - second := newFakeProcess(202) - factory := &recordingFactory{processes: []Process{first, second}} - supervisor, err := NewSupervisor(Config{ - Command: []string{"codex"}, - ProcessFactory: factory.start, - ShutdownTimeout: time.Second, - KillTimeout: time.Second, - }) - if err != nil { - t.Fatal(err) - } - if err := supervisor.Start(); err != nil { - t.Fatal(err) - } - deployed := false - err = supervisor.RestartAndResume(context.Background(), ResumeTarget{ThreadID: "thread-123"}, func(context.Context) error { - select { - case <-first.Done(): - default: - t.Fatal("credential deployment ran before the old Codex process exited") - } - deployed = true - return nil - }) - if err != nil { - t.Fatal(err) - } - if !deployed { - t.Fatal("credential deployment callback was not called") - } - commands, _, _ := factory.snapshot() - want := [][]string{{"codex"}, {"codex", "resume", "thread-123"}} - if !reflect.DeepEqual(commands, want) { - t.Fatalf("commands = %#v, want %#v", commands, want) - } - status := supervisor.Status() - if status.State != StateRunning || status.PID != 202 || status.RestartCount != 1 { - t.Fatalf("status = %#v, want running pid=202 restart_count=1", status) - } - if status.LastResumeID != "thread-123" || status.LastResumeLast { - t.Fatalf("resume status = %#v, want exact thread target", status) - } - if err := supervisor.Stop(context.Background()); err != nil { - t.Fatal(err) - } -} - -func TestRestartAndResumeUsesLastAndPreservesArgvBoundaries(t *testing.T) { - first := newFakeProcess(101) - second := newFakeProcess(202) - factory := &recordingFactory{processes: []Process{first, second}} - dir := t.TempDir() - supervisor, err := NewSupervisor(Config{ - Command: []string{"/usr/bin/codex", "--no-alt-screen"}, - WorkingDir: dir, - Environment: []string{"CODEX_HOME=/tmp/codex-home"}, - ProcessFactory: factory.start, - ShutdownTimeout: time.Second, - KillTimeout: time.Second, - }) - if err != nil { - t.Fatal(err) - } - if err := supervisor.Start(); err != nil { - t.Fatal(err) - } - resumeDir := t.TempDir() - err = supervisor.RestartAndResume(context.Background(), ResumeTarget{ - Last: true, - WorkingDir: resumeDir, - ExtraArgs: []string{"--no-alt-screen", "--dangerously-bypass-approvals-and-sandbox"}, - }, func(context.Context) error { return nil }) - if err != nil { - t.Fatal(err) - } - commands, dirs, envs := factory.snapshot() - want := [][]string{ - {"/usr/bin/codex", "--no-alt-screen"}, - {"/usr/bin/codex", "--no-alt-screen", "resume", "--last", "--cd", resumeDir, "--no-alt-screen", "--dangerously-bypass-approvals-and-sandbox"}, - } - if !reflect.DeepEqual(commands, want) { - t.Fatalf("commands = %#v, want %#v", commands, want) - } - if dirs[0] != dir || dirs[1] != resumeDir { - t.Fatalf("working directories = %#v, want initial=%q resumed=%q", dirs, dir, resumeDir) - } - if len(envs) != 2 || !reflect.DeepEqual(envs[0], []string{"CODEX_HOME=/tmp/codex-home"}) { - t.Fatalf("environment = %#v, want copied configured environment", envs) - } - status := supervisor.Status() - if !status.LastResumeLast || status.LastResumeID != "" { - t.Fatalf("resume status = %#v, want --last", status) - } - if err := supervisor.Stop(context.Background()); err != nil { - t.Fatal(err) - } -} - -func TestStopEscalatesOnlyAfterGracefulDeadline(t *testing.T) { - process := newFakeProcess(101) - process.ignoreUntil = "interrupt" - factory := &recordingFactory{processes: []Process{process}} - supervisor, err := NewSupervisor(Config{ - Command: []string{"codex"}, - ProcessFactory: factory.start, - ShutdownTimeout: 5 * time.Millisecond, - KillTimeout: 100 * time.Millisecond, - }) - if err != nil { - t.Fatal(err) - } - if err := supervisor.Start(); err != nil { - t.Fatal(err) - } - if err := supervisor.Stop(context.Background()); err != nil { - t.Fatal(err) - } - interrupts, terminates, kills := process.counts() - if interrupts != 1 || terminates != 1 || kills != 0 { - t.Fatalf("signals = interrupt:%d terminate:%d kill:%d, want 1/1/0", interrupts, terminates, kills) - } - if status := supervisor.Status(); status.State != StateStopped || status.PID != 0 { - t.Fatalf("status after stop = %#v, want stopped without pid", status) - } -} - -func TestRestartAndResumeRequiresTargetAndCallback(t *testing.T) { - first := newFakeProcess(101) - factory := &recordingFactory{processes: []Process{first}} - supervisor, err := NewSupervisor(Config{Command: []string{"codex"}, ProcessFactory: factory.start}) - if err != nil { - t.Fatal(err) - } - if err := supervisor.Start(); err != nil { - t.Fatal(err) - } - if err := supervisor.RestartAndResume(context.Background(), ResumeTarget{}, func(context.Context) error { return nil }); !errors.Is(err, ErrResumeTargetMissing) { - t.Fatalf("missing target error = %v, want ErrResumeTargetMissing", err) - } - if err := supervisor.RestartAndResume(context.Background(), ResumeTarget{Last: true}, nil); err == nil { - t.Fatal("nil deployment callback error = nil, want error") - } - if status := supervisor.Status(); status.State != StateRunning || status.PID != 101 { - t.Fatalf("status after validation failures = %#v, want original process running", status) - } - if err := supervisor.Stop(context.Background()); err != nil { - t.Fatal(err) - } -} - -func TestSupervisorRejectsBundledRuntimeCommand(t *testing.T) { - for _, command := range []string{"codex-app-server", "/opt/codexmarathon-runtime", "codex-app-server.exe"} { - if _, err := NewSupervisor(Config{Command: []string{command}}); err == nil { - t.Fatalf("NewSupervisor(%q) error = nil, want bundled runtime rejection", command) - } - } -} - -func TestWaitBroadcastsOneProcessExitToStopAndWait(t *testing.T) { - process := newFakeProcess(101) - factory := &recordingFactory{processes: []Process{process}} - supervisor, err := NewSupervisor(Config{Command: []string{"codex"}, ProcessFactory: factory.start}) - if err != nil { - t.Fatal(err) - } - if err := supervisor.Start(); err != nil { - t.Fatal(err) - } - process.finish(errors.New("unexpected exit")) - waitErr := supervisor.Wait(context.Background()) - if waitErr == nil || waitErr.Error() != "unexpected exit" { - t.Fatalf("Wait() error = %v, want unexpected exit", waitErr) - } - if status := supervisor.Status(); status.State != StateFailed || status.PID != 0 { - t.Fatalf("status after unexpected exit = %#v, want failed without pid", status) - } -} diff --git a/controller/internal/companion/process_unix.go b/controller/internal/companion/process_unix.go deleted file mode 100644 index e1e2338f..00000000 --- a/controller/internal/companion/process_unix.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build !windows - -package companion - -import ( - "os" - "os/exec" - "syscall" -) - -func configureProcessCommand(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} -} - -func interruptProcess(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - // Signal the process group so an interactive Codex child/app-server gets - // the same graceful shutdown request as its parent. - return syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) -} - -func terminateProcess(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) -} - -func killProcess(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) -} - -var _ os.Signal = syscall.SIGINT diff --git a/controller/internal/companion/process_windows.go b/controller/internal/companion/process_windows.go deleted file mode 100644 index 89da6848..00000000 --- a/controller/internal/companion/process_windows.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build windows - -package companion - -import ( - "os" - "os/exec" -) - -// Windows does not expose a portable process-group Ctrl+C API through the Go -// standard library. Process.Signal(os.Interrupt) is still the least -// surprising graceful request for console-attached Codex; the timeout path -// always escalates to Process.Kill so auth replacement cannot race a live -// process. -func configureProcessCommand(_ *exec.Cmd) {} - -func interruptProcess(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - return cmd.Process.Signal(os.Interrupt) -} - -func terminateProcess(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - return cmd.Process.Kill() -} - -func killProcess(cmd *exec.Cmd) error { - if cmd == nil || cmd.Process == nil { - return nil - } - return cmd.Process.Kill() -} diff --git a/controller/internal/credentials/atomic.go b/controller/internal/credentials/atomic.go deleted file mode 100644 index ba6b5d3e..00000000 --- a/controller/internal/credentials/atomic.go +++ /dev/null @@ -1,153 +0,0 @@ -package credentials - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "time" -) - -// DeploymentResult contains non-secret evidence about a successful atomic -// auth.json replacement. -type DeploymentResult struct { - AccountID string - Path string - DeployedAt time.Time -} - -// AtomicDeployer reads an opaque snapshot from a SnapshotReader and replaces -// the runtime's auth file at a safe filesystem boundary. -type AtomicDeployer struct { - Source SnapshotReader - AuthPath string - now func() time.Time -} - -// CredentialDeployer is the narrow deployment seam used by transitions. -type CredentialDeployer interface { - Deploy(accountID string) (DeploymentResult, error) -} - -// AtomicCredentialDeployer is a descriptive alias for AtomicDeployer. -type AtomicCredentialDeployer = AtomicDeployer - -// NewAtomicDeployer constructs a deployer. It does not write anything until -// Deploy is called. -func NewAtomicDeployer(source SnapshotReader, authPath string) *AtomicDeployer { - return &AtomicDeployer{Source: source, AuthPath: authPath, now: time.Now} -} - -// SetClock injects a clock for deterministic deployment records. -func (d *AtomicDeployer) SetClock(now func() time.Time) { - if now == nil { - d.now = time.Now - return - } - d.now = now -} - -// Deploy atomically installs accountID's snapshot into AuthPath. The file is -// written in the destination directory, synced, and then replaced using the -// platform's atomic replace primitive. The temporary file is cleaned up on -// every failure path. -func (d *AtomicDeployer) Deploy(accountID string) (DeploymentResult, error) { - if d == nil { - return DeploymentResult{}, errors.New("nil atomic deployer") - } - if d.Source == nil { - return DeploymentResult{}, errors.New("atomic deployer source is nil") - } - if err := validateAccountID(accountID); err != nil { - return DeploymentResult{}, err - } - raw, err := d.Source.Load(accountID) - if err != nil { - return DeploymentResult{}, err - } - if err := d.DeployRaw(accountID, raw); err != nil { - return DeploymentResult{}, err - } - now := d.now - if now == nil { - now = time.Now - } - return DeploymentResult{AccountID: accountID, Path: d.AuthPath, DeployedAt: now().UTC()}, nil -} - -// DeployRaw is useful for a transition that has already read and verified a -// snapshot from a different vault implementation. -func (d *AtomicDeployer) DeployRaw(accountID string, raw []byte) error { - if d == nil { - return errors.New("nil atomic deployer") - } - if err := validateAccountID(accountID); err != nil { - return err - } - if d.AuthPath == "" { - return errors.New("auth path is empty") - } - canonical, err := validateSnapshotForAccount(accountID, raw) - if err != nil { - return err - } - return WriteAtomically(d.AuthPath, canonical) -} - -// WriteAtomically replaces path with data. The temporary file and final file -// share a directory so the final replacement is a single filesystem rename. -func WriteAtomically(path string, data []byte) error { - if path == "" { - return errors.New("destination path is empty") - } - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - if err := os.Chmod(dir, 0o700); err != nil && !isPermissionMetadataError(err) { - return err - } - - tmp, err := os.CreateTemp(dir, ".codexmarathon-auth-*.tmp") - if err != nil { - return err - } - tmpName := tmp.Name() - cleanup := func() { - _ = tmp.Close() - _ = os.Remove(tmpName) - } - - if err := tmp.Chmod(0o600); err != nil { - cleanup() - return err - } - if _, err := tmp.Write(data); err != nil { - cleanup() - return err - } - if err := tmp.Sync(); err != nil { - cleanup() - return err - } - if err := tmp.Close(); err != nil { - cleanup() - return err - } - if err := replaceFile(tmpName, path); err != nil { - cleanup() - return fmt.Errorf("replace %s: %w", path, err) - } - if err := syncDirectory(dir); err != nil { - // The data and rename are complete. Return the durability error so a - // caller can record an uncertain deployment rather than claiming that - // directory metadata was definitely persisted. - return fmt.Errorf("sync directory %s: %w", dir, err) - } - return nil -} - -// AtomicWriteFile is a descriptive alias used by controller code. -func AtomicWriteFile(path string, data []byte) error { - return WriteAtomically(path, data) -} diff --git a/controller/internal/credentials/atomic_unix.go b/controller/internal/credentials/atomic_unix.go deleted file mode 100644 index 73a0b41d..00000000 --- a/controller/internal/credentials/atomic_unix.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build !windows - -package credentials - -import "os" - -func replaceFile(source, destination string) error { - return os.Rename(source, destination) -} - -func syncDirectory(path string) error { - dir, err := os.Open(path) - if err != nil { - return err - } - defer dir.Close() - return dir.Sync() -} - -func isPermissionMetadataError(error) bool { - return false -} diff --git a/controller/internal/credentials/atomic_windows.go b/controller/internal/credentials/atomic_windows.go deleted file mode 100644 index 5b94c268..00000000 --- a/controller/internal/credentials/atomic_windows.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build windows - -package credentials - -import ( - "fmt" - "syscall" - "unsafe" -) - -var ( - kernel32MoveFile = syscall.NewLazyDLL("kernel32.dll") - moveFileEx = kernel32MoveFile.NewProc("MoveFileExW") -) - -const ( - moveFileReplaceExisting = 0x00000001 - moveFileWriteThrough = 0x00000008 -) - -func replaceFile(source, destination string) error { - sourcePtr, err := syscall.UTF16PtrFromString(source) - if err != nil { - return err - } - destinationPtr, err := syscall.UTF16PtrFromString(destination) - if err != nil { - return err - } - result, _, callErr := moveFileEx.Call( - uintptr(unsafe.Pointer(sourcePtr)), - uintptr(unsafe.Pointer(destinationPtr)), - moveFileReplaceExisting|moveFileWriteThrough, - ) - if result == 0 { - if callErr != nil && callErr != syscall.Errno(0) { - return callErr - } - return fmt.Errorf("MoveFileExW failed") - } - return nil -} - -func syncDirectory(string) error { - // Windows' MoveFileExW with MOVEFILE_WRITE_THROUGH provides the durable - // rename request. Directory handles cannot be synced portably via os.File. - return nil -} - -func isPermissionMetadataError(error) bool { - return false -} diff --git a/controller/internal/credentials/credentials.go b/controller/internal/credentials/credentials.go deleted file mode 100644 index 46c392c1..00000000 --- a/controller/internal/credentials/credentials.go +++ /dev/null @@ -1,570 +0,0 @@ -// Package credentials owns the controller's credential snapshots and the -// boundary at which a snapshot is deployed to Codex's auth.json. -// -// The runtime remains the authority for parsing and loading auth.json. The -// controller stores opaque, validated JSON snapshots and never needs to -// retain a second in-memory authentication object. -package credentials - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -var ( - // ErrInvalidAccountID is returned when an account identifier cannot be used - // as a single vault filename component. - ErrInvalidAccountID = errors.New("invalid account id") - // ErrCredentialNotFound indicates that no snapshot exists for an account. - ErrCredentialNotFound = errors.New("credential snapshot not found") - // ErrInvalidCredential indicates malformed or non-object auth JSON. - ErrInvalidCredential = errors.New("invalid credential snapshot") - // ErrCredentialAccountMismatch prevents a snapshot for one account from - // being written under another account's key. - ErrCredentialAccountMismatch = errors.New("credential account mismatch") - // ErrEmptyTokenUpdate indicates that a write-back request contained no - // token material or account identity. - ErrEmptyTokenUpdate = errors.New("empty token write-back") -) - -// TokenSet is the small token contract shared by the controller vault and the -// runtime adapter. Values are write-only from the perspective of the journal; -// they are never included in journal.Event. -type TokenSet struct { - AccessToken string `json:"access_token,omitempty"` - IDToken string `json:"id_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - AccountID string `json:"account_id,omitempty"` -} - -// Tokens is retained as a readable alias for integrations that use the donor -// project's terminology. -type Tokens = TokenSet - -// TokenWriteBackRequest describes a runtime refresh that should be persisted -// to the account's stored snapshot. RefreshedAt is optional; a zero value is -// replaced by the vault clock. -type TokenWriteBackRequest struct { - AccountID string - Tokens TokenSet - RefreshedAt time.Time -} - -// Snapshot is a defensive view of one stored credential file. Raw is copied -// on ingress and egress so callers cannot mutate vault-owned data. -type Snapshot struct { - AccountID string - Raw []byte - SavedAt time.Time -} - -// Vault is the controller abstraction used by transition code. The runtime -// consumes a deployed auth.json; it does not access this vault directly. -type Vault interface { - Save(accountID string, raw []byte) error - Load(accountID string) ([]byte, error) - Delete(accountID string) error - List() ([]string, error) - WriteBack(request TokenWriteBackRequest) error -} - -// CredentialVault is a descriptive alias for Vault. -type CredentialVault = Vault - -// TokenWriteBackSink is the explicit runtime-refresh persistence seam. A -// runtime adapter may depend on this interface without depending on the file -// implementation. -type TokenWriteBackSink interface { - WriteBack(request TokenWriteBackRequest) error -} - -// SnapshotReader is the narrow source needed by AtomicDeployer. Keeping this -// interface small allows transition tests to use an in-memory fake without -// coupling them to the file-backed vault. -type SnapshotReader interface { - Load(accountID string) ([]byte, error) -} - -// SnapshotWriter is the narrow sink used when a live runtime synchronizes -// the active account's refreshed opaque auth snapshot before a switch. It is -// intentionally separate from Vault so transition code cannot enumerate or -// delete credentials as part of that synchronization step. -type SnapshotWriter interface { - Save(accountID string, raw []byte) error -} - -// FileVault is the MVP credential vault. Each account is stored as an opaque -// JSON document in /.json. The directory is created with -// owner-only permissions and files are written with owner read/write -// permissions. -type FileVault struct { - dir string - mu sync.RWMutex - now func() time.Time -} - -// NewFileVault constructs a file-backed vault rooted at dir. Construction is -// intentionally side-effect free; the directory is created by the first -// mutating operation. -func NewFileVault(dir string) *FileVault { - return &FileVault{dir: dir, now: time.Now} -} - -// NewVault is the short constructor used by controller wiring. -func NewVault(dir string) *FileVault { - return NewFileVault(dir) -} - -// Dir returns the vault root. It is useful for diagnostics and tests and does -// not expose any credential contents. -func (v *FileVault) Dir() string { - if v == nil { - return "" - } - return v.dir -} - -// SetClock injects a clock for deterministic tests. A nil clock restores the -// wall clock. -func (v *FileVault) SetClock(now func() time.Time) { - v.mu.Lock() - defer v.mu.Unlock() - if now == nil { - v.now = time.Now - return - } - v.now = now -} - -// Save validates and atomically stores an account snapshot. -func (v *FileVault) Save(accountID string, raw []byte) error { - if v == nil { - return errors.New("nil credential vault") - } - if err := validateAccountID(accountID); err != nil { - return err - } - canonical, err := validateSnapshotForAccount(accountID, raw) - if err != nil { - return err - } - - v.mu.Lock() - defer v.mu.Unlock() - if err := v.ensureDir(); err != nil { - return err - } - return WriteAtomically(v.pathUnchecked(accountID), canonical) -} - -// Put is a compatibility spelling for Save. -func (v *FileVault) Put(accountID string, raw []byte) error { - return v.Save(accountID, raw) -} - -// Load returns a defensive copy of the raw JSON snapshot. -func (v *FileVault) Load(accountID string) ([]byte, error) { - if v == nil { - return nil, errors.New("nil credential vault") - } - if err := validateAccountID(accountID); err != nil { - return nil, err - } - - v.mu.RLock() - defer v.mu.RUnlock() - path := v.pathUnchecked(accountID) - if err := rejectCredentialSymlink(path); err != nil { - return nil, err - } - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("%w: %s", ErrCredentialNotFound, accountID) - } - return nil, err - } - canonical, err := validateSnapshotForAccount(accountID, raw) - if err != nil { - return nil, err - } - return bytes.Clone(canonical), nil -} - -// Read returns a snapshot with file metadata. It is convenient when registry -// code needs to expose a saved-at timestamp without reopening the file. -func (v *FileVault) Read(accountID string) (Snapshot, error) { - if v == nil { - return Snapshot{}, errors.New("nil credential vault") - } - if err := validateAccountID(accountID); err != nil { - return Snapshot{}, err - } - - v.mu.RLock() - defer v.mu.RUnlock() - path := v.pathUnchecked(accountID) - if err := rejectCredentialSymlink(path); err != nil { - return Snapshot{}, err - } - info, err := os.Stat(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return Snapshot{}, fmt.Errorf("%w: %s", ErrCredentialNotFound, accountID) - } - return Snapshot{}, err - } - raw, err := os.ReadFile(path) - if err != nil { - return Snapshot{}, err - } - canonical, err := validateSnapshotForAccount(accountID, raw) - if err != nil { - return Snapshot{}, err - } - return Snapshot{AccountID: accountID, Raw: bytes.Clone(canonical), SavedAt: info.ModTime().UTC()}, nil -} - -// Get is an alias for Read. -func (v *FileVault) Get(accountID string) (Snapshot, error) { - return v.Read(accountID) -} - -// List returns account identifiers in stable lexical order. Non-JSON files -// are ignored so an operator can keep a README or backup outside the vault -// contract without changing account discovery. -func (v *FileVault) List() ([]string, error) { - if v == nil { - return nil, errors.New("nil credential vault") - } - v.mu.RLock() - defer v.mu.RUnlock() - entries, err := os.ReadDir(v.dir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []string{}, nil - } - return nil, err - } - accounts := make([]string, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || filepath.Ext(entry.Name()) != ".json" { - continue - } - name := strings.TrimSuffix(entry.Name(), ".json") - if validateAccountID(name) == nil { - accounts = append(accounts, name) - } - } - sort.Strings(accounts) - return accounts, nil -} - -// Delete removes a stored snapshot. -func (v *FileVault) Delete(accountID string) error { - if v == nil { - return errors.New("nil credential vault") - } - if err := validateAccountID(accountID); err != nil { - return err - } - v.mu.Lock() - defer v.mu.Unlock() - path := v.pathUnchecked(accountID) - if err := rejectCredentialSymlink(path); err != nil { - return err - } - if err := os.Remove(path); err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%w: %s", ErrCredentialNotFound, accountID) - } - return err - } - return nil -} - -// Remove is an alias for Delete. -func (v *FileVault) Remove(accountID string) error { - return v.Delete(accountID) -} - -// WriteBack merges refreshed runtime tokens into the existing snapshot and -// atomically replaces that snapshot. Unknown auth fields are retained. -func (v *FileVault) WriteBack(request TokenWriteBackRequest) error { - if v == nil { - return errors.New("nil credential vault") - } - if err := validateAccountID(request.AccountID); err != nil { - return err - } - if request.Tokens.AccountID != "" && request.Tokens.AccountID != request.AccountID { - return fmt.Errorf("%w: write-back is for %q, requested %q", ErrCredentialAccountMismatch, request.Tokens.AccountID, request.AccountID) - } - refreshedAt := request.RefreshedAt - if refreshedAt.IsZero() { - v.mu.RLock() - now := v.now - v.mu.RUnlock() - if now == nil { - now = time.Now - } - refreshedAt = now() - } - - v.mu.Lock() - defer v.mu.Unlock() - path := v.pathUnchecked(request.AccountID) - if err := rejectCredentialSymlink(path); err != nil { - return err - } - raw, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%w: %s", ErrCredentialNotFound, request.AccountID) - } - return err - } - updated, err := MergeTokenWriteBackForAccount(request.AccountID, raw, request.Tokens, refreshedAt) - if err != nil { - return err - } - return WriteAtomically(path, updated) -} - -// WriteBackTokens is a convenience method for callers that already have the -// account identifier separated from the runtime token payload. -func (v *FileVault) WriteBackTokens(accountID string, tokens TokenSet, refreshedAt time.Time) error { - return v.WriteBack(TokenWriteBackRequest{AccountID: accountID, Tokens: tokens, RefreshedAt: refreshedAt}) -} - -// Deploy installs one snapshot into authPath using the vault's atomic -// deployment boundary. It is a convenience for small controllers; larger -// transition coordinators can construct an AtomicDeployer directly and retain -// an explicit deployment result. -func (v *FileVault) Deploy(accountID, authPath string) (DeploymentResult, error) { - return NewAtomicDeployer(v, authPath).Deploy(accountID) -} - -func (v *FileVault) ensureDir() error { - if v.dir == "" { - return errors.New("credential vault directory is empty") - } - if err := os.MkdirAll(v.dir, 0o700); err != nil { - return err - } - // Tighten an existing directory created with broad permissions. On - // Windows this is best effort because ACLs, not mode bits, are authoritative. - if err := os.Chmod(v.dir, 0o700); err != nil && !isPermissionMetadataError(err) { - return err - } - return nil -} - -func (v *FileVault) pathUnchecked(accountID string) string { - return filepath.Join(v.dir, accountID+".json") -} - -func validateAccountID(accountID string) error { - if accountID == "" || accountID == "." || accountID == ".." || len(accountID) > 240 { - return ErrInvalidAccountID - } - if strings.ContainsAny(accountID, `/\\<>:"|?*`) || strings.ContainsRune(accountID, 0) { - return ErrInvalidAccountID - } - if strings.HasSuffix(accountID, ".") || strings.HasSuffix(accountID, " ") { - return ErrInvalidAccountID - } - for _, r := range accountID { - if r < 0x20 || r == 0x7f { - return ErrInvalidAccountID - } - } - base := strings.ToUpper(strings.SplitN(accountID, ".", 2)[0]) - switch base { - case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": - return ErrInvalidAccountID - } - return nil -} - -// ValidateSnapshot verifies that raw is a JSON object and returns a defensive -// copy of the opaque document. It deliberately does not interpret -// provider-specific auth fields; Codext remains responsible for that schema. -func ValidateSnapshot(raw []byte) ([]byte, error) { - return validateSnapshotForAccount("", raw) -} - -func validateSnapshotForAccount(accountID string, raw []byte) ([]byte, error) { - var object map[string]json.RawMessage - if err := json.Unmarshal(raw, &object); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - if object == nil { - return nil, fmt.Errorf("%w: expected JSON object", ErrInvalidCredential) - } - if accountID != "" { - identity, err := accountIDFromObject(object) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - if identity != "" && identity != accountID { - return nil, fmt.Errorf("%w: snapshot is for %q, requested %q", ErrCredentialAccountMismatch, identity, accountID) - } - } - // Keep the original bytes for snapshots. Auth formats can contain fields - // unknown to this controller, and preserving the opaque document avoids an - // unnecessary reformat before Codext reads it. - return bytes.Clone(raw), nil -} - -func accountIDFromObject(object map[string]json.RawMessage) (string, error) { - var topLevel string - if value, ok := object["account_id"]; ok && string(value) != "null" { - if err := json.Unmarshal(value, &topLevel); err != nil { - return "", err - } - } - value, ok := object["tokens"] - if !ok || string(value) == "null" { - return topLevel, nil - } - var tokens map[string]json.RawMessage - if err := json.Unmarshal(value, &tokens); err != nil { - return "", err - } - if tokens == nil { - return "", nil - } - var nested string - if value, ok := tokens["account_id"]; ok && string(value) != "null" { - if err := json.Unmarshal(value, &nested); err != nil { - return "", err - } - } - if topLevel != "" && nested != "" && topLevel != nested { - return "", fmt.Errorf("conflicting account_id values %q and %q", topLevel, nested) - } - if topLevel != "" { - return topLevel, nil - } - return nested, nil -} - -// ExtractAccountID reads the optional account identity without exposing any -// token values. An empty result means the auth format did not include identity. -func ExtractAccountID(raw []byte) (string, error) { - var object map[string]json.RawMessage - if err := json.Unmarshal(raw, &object); err != nil || object == nil { - if err == nil { - err = errors.New("expected JSON object") - } - return "", fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - identity, err := accountIDFromObject(object) - if err != nil { - return "", fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - return identity, nil -} - -// MergeTokenWriteBack preserves all unknown fields in an auth snapshot while -// merging non-empty refreshed token fields and a last_refresh timestamp. -func MergeTokenWriteBack(raw []byte, tokens TokenSet, refreshedAt time.Time) ([]byte, error) { - return MergeTokenWriteBackForAccount("", raw, tokens, refreshedAt) -} - -// MergeTokenWriteBackForAccount additionally ensures that an identity present -// in the existing document matches accountID. -func MergeTokenWriteBackForAccount(accountID string, raw []byte, tokens TokenSet, refreshedAt time.Time) ([]byte, error) { - if tokens.AccessToken == "" && tokens.IDToken == "" && tokens.RefreshToken == "" && tokens.AccountID == "" { - return nil, ErrEmptyTokenUpdate - } - var object map[string]json.RawMessage - if err := json.Unmarshal(raw, &object); err != nil || object == nil { - if err == nil { - err = errors.New("expected JSON object") - } - return nil, fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - if accountID != "" { - identity, err := accountIDFromObject(object) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - if identity != "" && identity != accountID { - return nil, fmt.Errorf("%w: snapshot is for %q, requested %q", ErrCredentialAccountMismatch, identity, accountID) - } - if tokens.AccountID != "" && tokens.AccountID != accountID { - return nil, fmt.Errorf("%w: write-back is for %q, requested %q", ErrCredentialAccountMismatch, tokens.AccountID, accountID) - } - } - - merged := map[string]json.RawMessage{} - if existing, ok := object["tokens"]; ok && len(existing) > 0 && string(existing) != "null" { - if err := json.Unmarshal(existing, &merged); err != nil { - return nil, fmt.Errorf("%w: malformed tokens object: %v", ErrInvalidCredential, err) - } - if merged == nil { - merged = map[string]json.RawMessage{} - } - } - setToken := func(name, value string) { - if value == "" { - return - } - encoded, _ := json.Marshal(value) - merged[name] = encoded - } - setToken("access_token", tokens.AccessToken) - setToken("id_token", tokens.IDToken) - setToken("refresh_token", tokens.RefreshToken) - setToken("account_id", tokens.AccountID) - encodedTokens, err := json.Marshal(merged) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - object["tokens"] = encodedTokens - if !refreshedAt.IsZero() { - encodedRefresh, err := json.Marshal(refreshedAt.UTC()) - if err != nil { - return nil, err - } - object["last_refresh"] = encodedRefresh - } - updated, err := json.Marshal(object) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidCredential, err) - } - return updated, nil -} - -func rejectCredentialSymlink(path string) error { - info, err := os.Lstat(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("%w: credential path is a symbolic link", ErrInvalidCredential) - } - if info.IsDir() { - return fmt.Errorf("%w: credential path is a directory", ErrInvalidCredential) - } - return nil -} - -// UpdateTokens is the donor-compatible spelling for the controller's -// token-write-back merge contract. -func UpdateTokens(raw []byte, tokens TokenSet, refreshedAt time.Time) ([]byte, error) { - return MergeTokenWriteBack(raw, tokens, refreshedAt) -} diff --git a/controller/internal/credentials/credentials_test.go b/controller/internal/credentials/credentials_test.go deleted file mode 100644 index c0c871ed..00000000 --- a/controller/internal/credentials/credentials_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package credentials - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestFileVaultRoundTripListAndValidation(t *testing.T) { - vault := NewFileVault(filepath.Join(t.TempDir(), "vault")) - alpha := []byte(`{"auth_mode":"chatgpt","tokens":{"access_token":"alpha","account_id":"acct-alpha"},"custom":{"enabled":true}}`) - if err := vault.Save("acct-alpha", alpha); err != nil { - t.Fatalf("Save() error = %v", err) - } - got, err := vault.Load("acct-alpha") - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if !strings.Contains(string(got), `"access_token":"alpha"`) { - t.Fatalf("Load() lost token field: %s", got) - } - got[0] = 'x' - again, err := vault.Load("acct-alpha") - if err != nil { - t.Fatalf("Load() after mutation error = %v", err) - } - if again[0] == 'x' { - t.Fatal("Load() returned mutable vault-owned bytes") - } - accounts, err := vault.List() - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(accounts) != 1 || accounts[0] != "acct-alpha" { - t.Fatalf("List() = %v, want [acct-alpha]", accounts) - } - - info, err := os.Stat(filepath.Join(vault.Dir(), "acct-alpha.json")) - if err != nil { - t.Fatalf("Stat() error = %v", err) - } - if info.Mode().Perm()&0o077 != 0 { - t.Fatalf("snapshot permissions = %o, want owner-only", info.Mode().Perm()) - } - dirInfo, err := os.Stat(vault.Dir()) - if err != nil { - t.Fatalf("Stat(vault dir) error = %v", err) - } - if dirInfo.Mode().Perm()&0o077 != 0 { - t.Fatalf("vault directory permissions = %o, want owner-only", dirInfo.Mode().Perm()) - } - - if err := vault.Save("other", []byte(`{"tokens":{"account_id":"acct-alpha"}}`)); !errors.Is(err, ErrCredentialAccountMismatch) { - t.Fatalf("Save(mismatched) error = %v, want ErrCredentialAccountMismatch", err) - } - for _, id := range []string{"", ".", "..", "../escape", `nested/name`, `nested\\name`} { - if err := vault.Save(id, []byte(`{}`)); !errors.Is(err, ErrInvalidAccountID) { - t.Fatalf("Save(%q) error = %v, want ErrInvalidAccountID", id, err) - } - } -} - -func TestFileVaultWriteBackPreservesUnknownFieldsAndSupportsPartialTokens(t *testing.T) { - vault := NewFileVault(filepath.Join(t.TempDir(), "vault")) - raw := []byte(`{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"access_token":"old","id_token":"old-id","refresh_token":"old-refresh","account_id":"acct-1"},"custom":{"enabled":true}}`) - if err := vault.Save("acct-1", raw); err != nil { - t.Fatalf("Save() error = %v", err) - } - refreshed := time.Date(2026, 9, 4, 10, 11, 12, 345678000, time.FixedZone("UTC+2", 2*60*60)) - if err := vault.WriteBack(TokenWriteBackRequest{ - AccountID: "acct-1", - Tokens: TokenSet{AccessToken: "new-access"}, - RefreshedAt: refreshed, - }); err != nil { - t.Fatalf("WriteBack() error = %v", err) - } - got, err := vault.Load("acct-1") - if err != nil { - t.Fatalf("Load() error = %v", err) - } - text := string(got) - for _, want := range []string{`"access_token":"new-access"`, `"id_token":"old-id"`, `"refresh_token":"old-refresh"`, `"account_id":"acct-1"`, `"enabled":true`, `"last_refresh":"2026-09-04T08:11:12.345678Z"`} { - if !strings.Contains(text, want) { - t.Fatalf("write-back document missing %s: %s", want, text) - } - } - if strings.Contains(text, "new-refresh") { - t.Fatal("partial write-back unexpectedly invented a refresh token") - } -} - -func TestMergeTokenWriteBackRejectsInvalidAndEmptyUpdates(t *testing.T) { - if _, err := MergeTokenWriteBack([]byte(`{"tokens":`), TokenSet{AccessToken: "x"}, time.Now()); !errors.Is(err, ErrInvalidCredential) { - t.Fatalf("malformed merge error = %v, want ErrInvalidCredential", err) - } - if _, err := MergeTokenWriteBack([]byte(`{}`), TokenSet{}, time.Now()); !errors.Is(err, ErrEmptyTokenUpdate) { - t.Fatalf("empty merge error = %v, want ErrEmptyTokenUpdate", err) - } -} - -func TestFileVaultRejectsConflictingAccountIdentities(t *testing.T) { - vault := NewFileVault(filepath.Join(t.TempDir(), "vault")) - raw := []byte(`{"account_id":"acct-a","tokens":{"account_id":"acct-b"}}`) - if err := vault.Save("acct-a", raw); !errors.Is(err, ErrInvalidCredential) { - t.Fatalf("Save(conflicting identities) error = %v, want ErrInvalidCredential", err) - } -} - -func TestAtomicDeployerReplacesDestinationAndCleansTemporaryFiles(t *testing.T) { - dir := t.TempDir() - vault := NewFileVault(filepath.Join(dir, "vault")) - if err := vault.Save("acct-1", []byte(`{"tokens":{"access_token":"new","account_id":"acct-1"}}`)); err != nil { - t.Fatalf("Save() error = %v", err) - } - authPath := filepath.Join(dir, "codex", "auth.json") - if err := os.MkdirAll(filepath.Dir(authPath), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(authPath, []byte(`{"tokens":{"access_token":"old"}}`), 0o600); err != nil { - t.Fatal(err) - } - deployer := NewAtomicDeployer(vault, authPath) - deployedAt := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - deployer.SetClock(func() time.Time { return deployedAt }) - result, err := deployer.Deploy("acct-1") - if err != nil { - t.Fatalf("Deploy() error = %v", err) - } - if result.AccountID != "acct-1" || !result.DeployedAt.Equal(deployedAt) { - t.Fatalf("DeploymentResult = %#v", result) - } - deployed, err := os.ReadFile(authPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(deployed), `"access_token":"new"`) { - t.Fatalf("auth.json = %s, want deployed snapshot", deployed) - } - entries, err := os.ReadDir(filepath.Dir(authPath)) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), ".codexmarathon-auth-") { - t.Fatalf("temporary deployment file %q remains", entry.Name()) - } - } - - failingTarget := filepath.Join(dir, "codex", "target-dir") - if err := os.Mkdir(failingTarget, 0o700); err != nil { - t.Fatal(err) - } - deployer.AuthPath = failingTarget - if err := deployer.DeployRaw("acct-1", []byte(`{"tokens":{"access_token":"new","account_id":"acct-1"}}`)); err == nil { - t.Fatal("DeployRaw(directory) error = nil, want replacement failure") - } - entries, err = os.ReadDir(filepath.Dir(failingTarget)) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), ".codexmarathon-auth-") { - t.Fatalf("temporary file %q remains after failure", entry.Name()) - } - } -} - -func TestFileVaultDeleteAndNotFound(t *testing.T) { - vault := NewFileVault(filepath.Join(t.TempDir(), "vault")) - if err := vault.Save("acct-1", []byte(`{}`)); err != nil { - t.Fatal(err) - } - if err := vault.Delete("acct-1"); err != nil { - t.Fatalf("Delete() error = %v", err) - } - if _, err := vault.Load("acct-1"); !errors.Is(err, ErrCredentialNotFound) { - t.Fatalf("Load(deleted) error = %v, want ErrCredentialNotFound", err) - } - if err := vault.Delete("acct-1"); !errors.Is(err, ErrCredentialNotFound) { - t.Fatalf("Delete(deleted) error = %v, want ErrCredentialNotFound", err) - } -} diff --git a/controller/internal/journal/journal.go b/controller/internal/journal/journal.go deleted file mode 100644 index 420d5370..00000000 --- a/controller/internal/journal/journal.go +++ /dev/null @@ -1,380 +0,0 @@ -// Package journal provides the controller's append-only recovery journal. -// -// Journal records contain transition/runtime metadata only. Credential bytes, -// auth.json contents, authorization headers, and recovery prompt text are not -// part of Event and therefore cannot be serialized accidentally through the -// normal API. -package journal - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -var ( - ErrInvalidEvent = errors.New("invalid journal event") - ErrCorruptJournal = errors.New("corrupt journal") - ErrSensitiveEvent = errors.New("journal event contains sensitive material") -) - -// EventType is intentionally open to forward-compatible runtime events while -// the constants below document the MVP recovery lifecycle. -type EventType string - -const ( - TransitionCreated EventType = "TransitionCreated" - TransitionPrepared EventType = "TransitionPrepared" - AuthDeployed EventType = "AuthDeployed" - CommitSent EventType = "CommitSent" - IdentityConfirmed EventType = "IdentityConfirmed" - TransitionCommitted EventType = "TransitionCommitted" - TransitionUncertain EventType = "TransitionUncertain" - TransitionReconciled EventType = "TransitionReconciled" - RuntimeConnected EventType = "RuntimeConnected" - RuntimeDisconnected EventType = "RuntimeDisconnected" - // Recovery records describe the controller's durable authorization of a - // runtime-owned UsageLimitExceeded continuation. They contain only - // correlation metadata; the prompt and credentials remain in Codext. - RecoveryParked EventType = "RecoveryParked" - RecoveryWaiting EventType = "RecoveryWaiting" - RecoveryBound EventType = "RecoveryBound" - RecoveryReleaseRequested EventType = "RecoveryReleaseRequested" - RecoveryReleased EventType = "RecoveryReleased" - RecoveryStarted EventType = "RecoveryStarted" - RecoveryCompleted EventType = "RecoveryCompleted" - RecoveryUncertain EventType = "RecoveryUncertain" - - // Event-prefixed aliases make call sites self-documenting when journal and - // runtime event constants are imported together. - EventTransitionCreated = TransitionCreated - EventTransitionPrepared = TransitionPrepared - EventAuthDeployed = AuthDeployed - EventCommitSent = CommitSent - EventIdentityConfirmed = IdentityConfirmed - EventTransitionCommitted = TransitionCommitted - EventTransitionUncertain = TransitionUncertain - EventTransitionReconciled = TransitionReconciled - EventRuntimeConnected = RuntimeConnected - EventRuntimeDisconnected = RuntimeDisconnected - EventRecoveryParked = RecoveryParked - EventRecoveryWaiting = RecoveryWaiting - EventRecoveryBound = RecoveryBound - EventRecoveryReleaseRequested = RecoveryReleaseRequested - EventRecoveryReleased = RecoveryReleased - EventRecoveryStarted = RecoveryStarted - EventRecoveryCompleted = RecoveryCompleted - EventRecoveryUncertain = RecoveryUncertain -) - -// Event is the complete journal wire record. The fields are deliberately -// scalar and non-secret; use Reason for a short operator-safe explanation, not -// raw provider responses or token material. -type Event struct { - At time.Time `json:"at"` - Type EventType `json:"type"` - TransitionID string `json:"transition_id,omitempty"` - RecoveryID string `json:"recovery_id,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - TurnID string `json:"turn_id,omitempty"` - // Generation zero is a valid initial runtime generation. Keep the field on - // the JSON record even when it is zero so every transition record carries - // the correlation field required by the protocol contract. - AuthGeneration uint64 `json:"auth_generation"` - ExpectedGeneration uint64 `json:"expected_generation,omitempty"` - RuntimeID string `json:"runtime_id,omitempty"` - AccountID string `json:"account_id,omitempty"` - FromAccountID string `json:"from_account_id,omitempty"` - TargetAccountID string `json:"target_account_id,omitempty"` - Outcome string `json:"outcome,omitempty"` - Reason string `json:"reason,omitempty"` -} - -// Journal is the persistence seam consumed by transition orchestration. -type Journal interface { - Append(event Event) error - ReadAll() ([]Event, error) -} - -// FileJournal is a process-safe JSONL journal. O_APPEND plus a process mutex -// keeps each record whole for concurrent callers in this process. Every append -// is synced before returning so a successful call is durable to the OS. -type FileJournal struct { - path string - mu sync.Mutex - now func() time.Time -} - -// NewFileJournal constructs a journal without creating its file. -func NewFileJournal(path string) *FileJournal { - return &FileJournal{path: path, now: time.Now} -} - -// NewJournal is the concise constructor used by controller wiring. -func NewJournal(path string) *FileJournal { - return NewFileJournal(path) -} - -// Path returns the journal path without exposing its records. -func (j *FileJournal) Path() string { - if j == nil { - return "" - } - return j.path -} - -// SetClock injects a clock for deterministic tests. Event.At values supplied -// by callers remain authoritative; the clock fills only zero timestamps. -func (j *FileJournal) SetClock(now func() time.Time) { - if j == nil { - return - } - j.mu.Lock() - defer j.mu.Unlock() - if now == nil { - j.now = time.Now - return - } - j.now = now -} - -// Append validates and durably appends one event. It never rewrites or prunes -// existing records. -func (j *FileJournal) Append(event Event) error { - if j == nil { - return errors.New("nil journal") - } - j.mu.Lock() - defer j.mu.Unlock() - if event.At.IsZero() { - now := j.now - if now == nil { - now = time.Now - } - event.At = now() - } - event.At = event.At.UTC() - if err := ValidateEvent(event); err != nil { - return err - } - if j.path == "" { - return errors.New("journal path is empty") - } - if err := rejectJournalSymlink(j.path); err != nil { - return err - } - dir := filepath.Dir(j.path) - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - if err := os.Chmod(dir, 0o700); err != nil && !isPermissionMetadataError(err) { - return err - } - raw, err := json.Marshal(event) - if err != nil { - return err - } - raw = append(raw, '\n') - file, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return err - } - defer file.Close() - if err := file.Chmod(0o600); err != nil && !isPermissionMetadataError(err) { - return err - } - if _, err := file.Write(raw); err != nil { - return err - } - if err := file.Sync(); err != nil { - return err - } - return nil -} - -// AppendEvent is a compatibility spelling for Append. -func (j *FileJournal) AppendEvent(event Event) error { - return j.Append(event) -} - -// ReadAll reads and validates every JSONL record. A malformed line is an -// integrity error; silently skipping it would make recovery state ambiguous. -func (j *FileJournal) ReadAll() ([]Event, error) { - if j == nil { - return nil, errors.New("nil journal") - } - if j.path == "" { - return nil, errors.New("journal path is empty") - } - j.mu.Lock() - defer j.mu.Unlock() - if err := rejectJournalSymlink(j.path); err != nil { - return nil, err - } - file, err := os.Open(j.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []Event{}, nil - } - return nil, err - } - defer file.Close() - scanner := bufio.NewScanner(file) - // A normal event is small, but permit a bounded diagnostic reason without - // allowing an unbounded scanner allocation. - scanner.Buffer(make([]byte, 4096), 1024*1024) - events := make([]Event, 0) - line := 0 - for scanner.Scan() { - line++ - data := bytes.TrimSpace(scanner.Bytes()) - if len(data) == 0 { - continue - } - var event Event - if err := json.Unmarshal(data, &event); err != nil { - return nil, fmt.Errorf("%w: line %d: %v", ErrCorruptJournal, line, err) - } - if err := ValidateEvent(event); err != nil { - return nil, fmt.Errorf("%w: line %d: %v", ErrCorruptJournal, line, err) - } - events = append(events, event) - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("%w: line %d: %v", ErrCorruptJournal, line+1, err) - } - return events, nil -} - -// Events is an alias for ReadAll. -func (j *FileJournal) Events() ([]Event, error) { - return j.ReadAll() -} - -// ValidateEvent enforces the scalar event contract and rejects obvious secret -// field patterns in operator-provided text. Raw credential values cannot be -// represented as structured fields in Event. -func ValidateEvent(event Event) error { - if strings.TrimSpace(string(event.Type)) == "" { - return ErrInvalidEvent - } - if len(event.Type) > 256 { - return fmt.Errorf("%w: event type is too long", ErrInvalidEvent) - } - if event.At.IsZero() { - return fmt.Errorf("%w: timestamp is required", ErrInvalidEvent) - } - if isTransitionEvent(event.Type) { - if event.TransitionID == "" { - return fmt.Errorf("%w: transition_id is required for %s", ErrInvalidEvent, event.Type) - } - if event.RuntimeID == "" { - return fmt.Errorf("%w: runtime_id is required for %s", ErrInvalidEvent, event.Type) - } - if (event.Type == TransitionCommitted || event.Type == TransitionReconciled) && event.AuthGeneration == 0 { - return fmt.Errorf("%w: auth_generation is required for %s", ErrInvalidEvent, event.Type) - } - } - if isRecoveryEvent(event.Type) { - if event.RecoveryID == "" { - return fmt.Errorf("%w: recovery_id is required for %s", ErrInvalidEvent, event.Type) - } - if event.RuntimeID == "" { - return fmt.Errorf("%w: runtime_id is required for %s", ErrInvalidEvent, event.Type) - } - } - for name, value := range map[string]string{ - "transition_id": event.TransitionID, - "recovery_id": event.RecoveryID, - "thread_id": event.ThreadID, - "turn_id": event.TurnID, - "runtime_id": event.RuntimeID, - "account_id": event.AccountID, - "from_account_id": event.FromAccountID, - "target_account_id": event.TargetAccountID, - "outcome": event.Outcome, - "reason": event.Reason, - } { - if err := validateText(name, value); err != nil { - return err - } - } - return nil -} - -func isTransitionEvent(eventType EventType) bool { - switch eventType { - case TransitionCreated, TransitionPrepared, AuthDeployed, CommitSent, - IdentityConfirmed, TransitionCommitted, TransitionUncertain, - TransitionReconciled: - return true - default: - return false - } -} - -func isRecoveryEvent(eventType EventType) bool { - switch eventType { - case RecoveryParked, RecoveryWaiting, RecoveryBound, - RecoveryReleaseRequested, RecoveryReleased, RecoveryStarted, - RecoveryCompleted, RecoveryUncertain: - return true - default: - return false - } -} - -func validateText(field, value string) error { - if strings.ContainsRune(value, 0) || strings.ContainsAny(value, "\r\n") { - return fmt.Errorf("%w: %s contains control characters", ErrInvalidEvent, field) - } - if len(value) > 4096 { - return fmt.Errorf("%w: %s is too long", ErrInvalidEvent, field) - } - if field == "reason" && looksSensitive(value) { - return fmt.Errorf("%w: %s", ErrSensitiveEvent, field) - } - return nil -} - -func looksSensitive(value string) bool { - lower := strings.ToLower(value) - for _, marker := range []string{ - "access_token", "id_token", "refresh_token", "authorization:", - "bearer ", "api_key", "openai_api_key", "password=", "secret=", - "auth.json", "\"tokens\"", - } { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - -func isPermissionMetadataError(error) bool { - return false -} - -func rejectJournalSymlink(path string) error { - info, err := os.Lstat(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("%w: journal path is a symbolic link", ErrInvalidEvent) - } - if info.IsDir() { - return fmt.Errorf("%w: journal path is a directory", ErrInvalidEvent) - } - return nil -} diff --git a/controller/internal/journal/journal_test.go b/controller/internal/journal/journal_test.go deleted file mode 100644 index ef04e3f0..00000000 --- a/controller/internal/journal/journal_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package journal - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestFileJournalAppendsDurablyAndReplaysInOrder(t *testing.T) { - path := filepath.Join(t.TempDir(), "journal", "events.jsonl") - j := NewFileJournal(path) - firstAt := time.Date(2026, 9, 4, 12, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60)) - j.SetClock(func() time.Time { return firstAt }) - events := []Event{ - {Type: TransitionCreated, TransitionID: "tx-1", AuthGeneration: 1, RuntimeID: "runtime-1", TargetAccountID: "acct-b"}, - {Type: AuthDeployed, TransitionID: "tx-1", AuthGeneration: 1, RuntimeID: "runtime-1", AccountID: "acct-b", Reason: "atomic deployment completed"}, - {Type: TransitionCommitted, TransitionID: "tx-1", AuthGeneration: 1, RuntimeID: "runtime-1", AccountID: "acct-b", Outcome: "committed"}, - } - for _, event := range events { - if err := j.Append(event); err != nil { - t.Fatalf("Append(%s) error = %v", event.Type, err) - } - } - got, err := j.ReadAll() - if err != nil { - t.Fatalf("ReadAll() error = %v", err) - } - if len(got) != len(events) { - t.Fatalf("ReadAll() length = %d, want %d", len(got), len(events)) - } - for i := range got { - if got[i].Type != events[i].Type || got[i].TransitionID != events[i].TransitionID || !got[i].At.Equal(firstAt.UTC()) { - t.Fatalf("event[%d] = %#v, want type/transition/time from %#v", i, got[i], events[i]) - } - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(data), "token") || strings.Contains(string(data), "auth.json") { - t.Fatalf("journal contains forbidden credential markers: %s", data) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm()&0o077 != 0 { - t.Fatalf("journal permissions = %o, want owner-only", info.Mode().Perm()) - } -} - -func TestFileJournalRejectsInvalidOrSensitiveEvents(t *testing.T) { - j := NewFileJournal(filepath.Join(t.TempDir(), "events.jsonl")) - base := Event{Type: TransitionCommitted, AuthGeneration: 1, RuntimeID: "runtime-1", TransitionID: "tx-1"} - if err := j.Append(base); err != nil { - t.Fatalf("Append(base) error = %v", err) - } - for _, event := range []Event{ - {Type: TransitionCommitted, RuntimeID: "runtime-1", TransitionID: "tx-2"}, - {Type: TransitionCommitted, AuthGeneration: 1, RuntimeID: "runtime-1", TransitionID: "tx-2", Reason: "refresh_token=do-not-log"}, - {Type: TransitionCommitted, AuthGeneration: 1, RuntimeID: "runtime-1", TransitionID: "tx-2", Reason: "line\nfeed"}, - } { - if err := j.Append(event); err == nil { - t.Fatalf("Append(%#v) error = nil, want validation error", event) - } - } -} - -func TestFileJournalDetectsCorruptionAndKeepsAppendOnlyBytes(t *testing.T) { - path := filepath.Join(t.TempDir(), "events.jsonl") - j := NewFileJournal(path) - if err := j.Append(Event{Type: RuntimeConnected, RuntimeID: "runtime-1"}); err != nil { - t.Fatal(err) - } - original, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - t.Fatal(err) - } - if _, err := file.WriteString("not-json\n"); err != nil { - _ = file.Close() - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } - if _, err := j.ReadAll(); !errors.Is(err, ErrCorruptJournal) { - t.Fatalf("ReadAll(corrupt) error = %v, want ErrCorruptJournal", err) - } - after, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(string(after), string(original)) { - t.Fatal("journal bytes were rewritten instead of remaining append-only") - } -} - -func TestNewJournalAliasesFileJournal(t *testing.T) { - j := NewJournal(filepath.Join(t.TempDir(), "events.jsonl")) - if j == nil || j.Path() == "" { - t.Fatalf("NewJournal() = %#v", j) - } - if _, err := j.Events(); err != nil { - t.Fatalf("Events(empty) error = %v", err) - } -} - -func TestJournalPreservesInitialZeroGeneration(t *testing.T) { - path := filepath.Join(t.TempDir(), "events.jsonl") - j := NewFileJournal(path) - if err := j.Append(Event{ - Type: TransitionCreated, - TransitionID: "tx-initial", - RuntimeID: "runtime-1", - AuthGeneration: 0, - }); err != nil { - t.Fatalf("Append(initial generation) error = %v", err) - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), `"auth_generation":0`) { - t.Fatalf("journal omitted zero generation: %s", data) - } -} - -func TestJournalAcceptsSecretFreeRecoveryLifecycle(t *testing.T) { - j := NewFileJournal(filepath.Join(t.TempDir(), "events.jsonl")) - events := []Event{ - {Type: RecoveryParked, RecoveryID: "recovery-1", RuntimeID: "runtime-1", ThreadID: "thread-1", TurnID: "turn-1", AccountID: "acct-a", AuthGeneration: 1, Reason: "runtime parked usage-limit recovery"}, - {Type: RecoveryBound, RecoveryID: "recovery-1", RuntimeID: "runtime-1", ThreadID: "thread-1", TransitionID: "tx-1", TargetAccountID: "acct-b", AuthGeneration: 2, ExpectedGeneration: 2}, - {Type: RecoveryReleaseRequested, RecoveryID: "recovery-1", RuntimeID: "runtime-1", ThreadID: "thread-1", TransitionID: "tx-1", TargetAccountID: "acct-b", AuthGeneration: 2, ExpectedGeneration: 2}, - {Type: RecoveryReleased, RecoveryID: "recovery-1", RuntimeID: "runtime-1", ThreadID: "thread-1", TransitionID: "tx-1", TargetAccountID: "acct-b", AuthGeneration: 2, ExpectedGeneration: 2, Outcome: "released"}, - } - for _, event := range events { - if err := j.Append(event); err != nil { - t.Fatalf("Append(%s) error = %v", event.Type, err) - } - } - got, err := j.ReadAll() - if err != nil { - t.Fatal(err) - } - if len(got) != len(events) || got[2].RecoveryID != "recovery-1" || got[2].TransitionID != "tx-1" || got[2].ExpectedGeneration != 2 { - t.Fatalf("replayed recovery events = %#v", got) - } - raw, err := os.ReadFile(j.Path()) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "prompt") || strings.Contains(string(raw), "access_token") { - t.Fatalf("recovery journal contains sensitive/prompt marker: %s", raw) - } -} diff --git a/controller/internal/policy/policy.go b/controller/internal/policy/policy.go deleted file mode 100644 index c1b94e99..00000000 --- a/controller/internal/policy/policy.go +++ /dev/null @@ -1,802 +0,0 @@ -// Package policy makes deterministic account-selection decisions from -// normalized telemetry. Observation, reset waiting, and runtime transitions -// remain separate: policy may request a transition or a revalidation wake-up, -// but it never deploys credentials or reloads the runtime. -package policy - -import ( - "sort" - "strings" - "sync" - "time" - - "codexmarathon/controller/internal/reset" - "codexmarathon/controller/internal/telemetry" -) - -const ( - defaultPrimaryThreshold = 90.0 - defaultSecondaryThreshold = 95.0 - defaultSwitchCooldown = time.Minute - defaultFreshnessTTL = 5 * time.Minute - defaultRememberedEvents = 256 -) - -// PolicyDecisionType is intentionally small and stable for controller and -// CLI consumers. PolicyNoTelemetry includes missing, stale, ambiguous, and -// failed observations; RefetchRequired/RefreshAccountIDs distinguish the -// actionable substate without treating an error as zero usage. -type PolicyDecisionType uint8 - -const ( - PolicyStay PolicyDecisionType = iota - PolicyTransition - PolicyWaitForReset - PolicyNoTelemetry - PolicyCooldown -) - -// Compatibility aliases with the architecture names. -const ( - Stay = PolicyStay - Transition = PolicyTransition - WaitForReset = PolicyWaitForReset - NoTelemetry = PolicyNoTelemetry - NoEligibleAccount = PolicyNoTelemetry - Cooldown = PolicyCooldown -) - -func (t PolicyDecisionType) String() string { - switch t { - case PolicyStay: - return "stay" - case PolicyTransition: - return "transition" - case PolicyWaitForReset: - return "wait_for_reset" - case PolicyNoTelemetry: - return "no_telemetry" - case PolicyCooldown: - return "cooldown" - default: - return "unknown" - } -} - -// Trigger identifies why policy is being evaluated. A proactive threshold -// event and a hard UsageLimitExceeded event share candidate ranking but have -// different behavior when the current account is still below its threshold. -type Trigger string - -const ( - TriggerManual Trigger = "manual" - TriggerProactiveThreshold Trigger = "proactive_threshold" - TriggerUsageLimitExceeded Trigger = "usage_limit_exceeded" - TriggerResetRevalidation Trigger = "reset_revalidation" -) - -// TriggerType is a readable compatibility alias. -type TriggerType = Trigger - -const ( - Manual = TriggerManual - ProactiveThreshold = TriggerProactiveThreshold - UsageLimitExceeded = TriggerUsageLimitExceeded - ResetRevalidation = TriggerResetRevalidation -) - -// Thresholds are the donor watcher's primary/secondary threshold policy, -// generalized to every normalized bucket. Values are percentages in [0,100]. -type Thresholds struct { - PrimaryPercent float64 - SecondaryPercent float64 -} - -func DefaultThresholds() Thresholds { - return Thresholds{PrimaryPercent: defaultPrimaryThreshold, SecondaryPercent: defaultSecondaryThreshold} -} - -func (t Thresholds) normalized() Thresholds { - defaults := DefaultThresholds() - if t.PrimaryPercent <= 0 { - t.PrimaryPercent = defaults.PrimaryPercent - } - if t.SecondaryPercent <= 0 { - t.SecondaryPercent = defaults.SecondaryPercent - } - if t.PrimaryPercent > 100 { - t.PrimaryPercent = 100 - } - if t.SecondaryPercent > 100 { - t.SecondaryPercent = 100 - } - return t -} - -// Reached reports whether any observed conventional window crossed its -// configured threshold. Callers should require a complete fresh snapshot -// before using this as authorization for a switch. -func (t Thresholds) Reached(account telemetry.AccountTelemetry) bool { - t = t.normalized() - for _, window := range account.WindowList() { - switch window.Kind { - case telemetry.PrimaryWindow: - if window.UsedPercent >= t.PrimaryPercent { - return true - } - case telemetry.SecondaryWindow: - if window.UsedPercent >= t.SecondaryPercent { - return true - } - } - } - return false -} - -// EvaluationInput is the complete policy event context. Accounts may come -// from both an active runtime snapshot and inactive-profile providers. An -// absent or failed snapshot must be represented as unusable telemetry. -type EvaluationInput struct { - Accounts []telemetry.AccountTelemetry - ActiveAccountID string - Trigger Trigger - Thresholds Thresholds - FreshnessTTL time.Duration - CooldownUntil time.Time - PendingTargetID string - EventID string - Now time.Time -} - -// PolicyInput and EvaluationRequest preserve descriptive call-site names. -type PolicyInput = EvaluationInput -type EvaluationRequest = EvaluationInput - -// CandidateRank records the multi-bucket score used to select an account. -// The ordering intentionally preserves codex-switch's secondary-first, -// primary-second behavior while adding aggregate pressure and a stable ID -// tie-breaker for arbitrary metered bucket sets. -type CandidateRank struct { - AccountID string - SecondaryUsedPercent float64 - PrimaryUsedPercent float64 - MaxUsedPercent float64 - TotalUsedPercent float64 -} - -// PolicyDecision is the result of evaluating all eligible configured -// accounts. Reset fields are copied out of reset.ResetDecision so callers do -// not need to retain mutable scheduler state. -type PolicyDecision struct { - Type PolicyDecisionType - Trigger Trigger - ActiveAccountID string - AccountID string - Reason string - CandidateAccountID string - LimitID string - DurationMins int - ResetAt time.Time - RetryAt time.Time - CooldownUntil time.Time - RefetchRequired bool - RefreshAccountIDs []string - Rank CandidateRank -} - -func (d PolicyDecision) IsTransition() bool { return d.Type == PolicyTransition } - -func (d PolicyDecision) IsWaiting() bool { - return d.Type == PolicyWaitForReset || d.Type == PolicyNoTelemetry || d.Type == PolicyCooldown -} - -// EngineConfig controls freshness, thresholds, and loop prevention. A zero -// config uses the same 90/95 threshold and one-minute cooldown defaults as -// codex-switch's watcher. -type EngineConfig struct { - Thresholds Thresholds - FreshnessTTL time.Duration - Cooldown time.Duration - RememberedEvents int -} - -// Engine evaluates telemetry and delegates pool-exhaustion scheduling to one -// reset scheduler. It is safe for a policy loop and timer loop to call its -// methods concurrently. -type Engine struct { - scheduler reset.ResetScheduler - now func() time.Time - config EngineConfig - - mu sync.Mutex - cooldownUntil time.Time - pending bool - pendingFrom string - pendingTarget string - lastEventID string - seenEvents map[string]time.Time - lastTransition time.Time -} - -// NewEngine constructs an engine. If scheduler is nil, a private scheduler -// is created. The optional clock is used when Evaluate receives a zero time. -func NewEngine(scheduler reset.ResetScheduler, clock ...func() time.Time) *Engine { - if scheduler == nil { - scheduler = reset.NewScheduler(clock...) - } - return &Engine{ - scheduler: scheduler, - now: selectClock(clock...), - config: normalizeConfig(EngineConfig{}), - seenEvents: make(map[string]time.Time), - } -} - -// NewPolicyEngine is a descriptive compatibility alias for NewEngine. -func NewPolicyEngine(scheduler reset.ResetScheduler, clock ...func() time.Time) *Engine { - return NewEngine(scheduler, clock...) -} - -// Evaluate is a stateless convenience for one policy tick. It retains the -// original package-level API while using the stricter normalized eligibility -// and reset handling implemented by Engine. -func Evaluate(accounts []telemetry.AccountTelemetry, now time.Time) PolicyDecision { - return NewEngine(nil).Evaluate(accounts, now) -} - -// Eligible reports whether one account has a complete fresh non-exhausted -// snapshot at now. -func Eligible(account telemetry.AccountTelemetry, now time.Time) bool { - return account.HasFreshCapacity(now) -} - -// NewConfiguredEngine constructs an engine with explicit policy settings. -func NewConfiguredEngine(config EngineConfig, scheduler reset.ResetScheduler, clock ...func() time.Time) *Engine { - if scheduler == nil { - scheduler = reset.NewScheduler(clock...) - } - return &Engine{ - scheduler: scheduler, - now: selectClock(clock...), - config: normalizeConfig(config), - seenEvents: make(map[string]time.Time), - } -} - -// Configure updates policy defaults. Existing cooldown/pending state is -// retained deliberately so a configuration reload cannot trigger a switch -// loop. -func (e *Engine) Configure(config EngineConfig) { - if e == nil { - return - } - e.mu.Lock() - e.config = normalizeConfig(config) - e.mu.Unlock() -} - -// Evaluate retains the original API: it selects the first deterministic -// fresh account. New automatic callers should use EvaluateInput/Evaluate -// Trigger so active account and trigger semantics are explicit. -func (e *Engine) Evaluate(accounts []telemetry.AccountTelemetry, now time.Time) PolicyDecision { - return e.evaluate(EvaluationInput{ - Accounts: accounts, - Trigger: TriggerManual, - Now: now, - // Legacy callers historically did not configure an age TTL. Reset and - // structural freshness remain enforced, but no wall-clock age is - // imposed here; automatic callers use the configured TTL. - FreshnessTTL: -1, - }) -} - -// EvaluateInput evaluates one event and is the primary automatic-policy API. -func (e *Engine) EvaluateInput(input EvaluationInput) PolicyDecision { - return e.evaluate(input) -} - -// EvaluateRequest is an alias for integrations that use request terminology. -func (e *Engine) EvaluateRequest(input EvaluationInput) PolicyDecision { - return e.evaluate(input) -} - -// EvaluateTrigger evaluates a trigger without requiring callers to construct -// an EvaluationInput value. -func (e *Engine) EvaluateTrigger(accounts []telemetry.AccountTelemetry, activeAccountID string, trigger Trigger, thresholds Thresholds, now time.Time) PolicyDecision { - return e.evaluate(EvaluationInput{ - Accounts: accounts, - ActiveAccountID: activeAccountID, - Trigger: trigger, - Thresholds: thresholds, - Now: now, - }) -} - -// EvaluateUsageLimitExceeded is a convenience for the hard-limit runtime -// event. EventID may be empty when the runtime cannot provide one. -func (e *Engine) EvaluateUsageLimitExceeded(accounts []telemetry.AccountTelemetry, activeAccountID, eventID string, now time.Time) PolicyDecision { - return e.evaluate(EvaluationInput{ - Accounts: accounts, - ActiveAccountID: activeAccountID, - Trigger: TriggerUsageLimitExceeded, - EventID: eventID, - Now: now, - }) -} - -// EvaluateThreshold is the proactive threshold convenience method. -func (e *Engine) EvaluateThreshold(accounts []telemetry.AccountTelemetry, activeAccountID string, thresholds Thresholds, now time.Time) PolicyDecision { - return e.evaluate(EvaluationInput{ - Accounts: accounts, - ActiveAccountID: activeAccountID, - Trigger: TriggerProactiveThreshold, - Thresholds: thresholds, - Now: now, - }) -} - -// RecordTransition marks a transition as accepted by the transition layer. -// It arms the cooldown and suppresses duplicate event-driven switch requests -// until the target is confirmed or the caller records a failure. -func (e *Engine) RecordTransition(fromAccountID, toAccountID string, at time.Time) { - if e == nil { - return - } - if at.IsZero() { - at = e.currentTime() - } - e.mu.Lock() - defer e.mu.Unlock() - e.pending = true - e.pendingFrom = fromAccountID - e.pendingTarget = toAccountID - e.lastTransition = at.UTC() - e.cooldownUntil = at.UTC().Add(e.config.Cooldown) -} - -// TransitionAccepted is an alias for RecordTransition. -func (e *Engine) TransitionAccepted(fromAccountID, toAccountID string, at time.Time) { - e.RecordTransition(fromAccountID, toAccountID, at) -} - -// ConfirmTransition clears pending suppression once the runtime reports the -// new identity. The cooldown remains in force to prevent an immediate bounce. -func (e *Engine) ConfirmTransition(accountID string) { - if e == nil { - return - } - e.mu.Lock() - if e.pending && (accountID == "" || accountID == e.pendingTarget) { - e.pending = false - e.pendingFrom = "" - e.pendingTarget = "" - } - e.mu.Unlock() -} - -// TransitionFailed clears the in-flight suppression. It does not erase the -// last cooldown boundary when the transition layer has already changed the -// runtime identity; callers can explicitly ConfirmTransition in that case. -func (e *Engine) TransitionFailed() { - if e == nil { - return - } - e.mu.Lock() - e.pending = false - e.pendingFrom = "" - e.pendingTarget = "" - e.mu.Unlock() -} - -// CooldownUntil returns the current loop-prevention boundary. -func (e *Engine) CooldownUntil() time.Time { - if e == nil { - return time.Time{} - } - e.mu.Lock() - until := e.cooldownUntil - e.mu.Unlock() - return until -} - -// PendingTarget returns the target currently suppressed as in flight. -func (e *Engine) PendingTarget() (string, bool) { - if e == nil { - return "", false - } - e.mu.Lock() - target, pending := e.pendingTarget, e.pending - e.mu.Unlock() - return target, pending -} - -func (e *Engine) evaluate(input EvaluationInput) PolicyDecision { - if e == nil { - return PolicyDecision{Type: PolicyNoTelemetry, Trigger: input.Trigger, Reason: "policy engine is nil"} - } - now := input.Now - if now.IsZero() { - now = e.currentTime() - } - now = now.UTC() - trigger := input.Trigger - if trigger == "" { - trigger = TriggerManual - } - e.mu.Lock() - config := e.config - if config.RememberedEvents <= 0 { - config.RememberedEvents = defaultRememberedEvents - } - cooldownUntil := e.cooldownUntil - if input.CooldownUntil.After(cooldownUntil) { - cooldownUntil = input.CooldownUntil - } - pending := e.pending - pendingTarget := e.pendingTarget - if input.PendingTargetID != "" { - pending = true - pendingTarget = input.PendingTargetID - } - duplicateEvent := input.EventID != "" && e.seenEventLocked(input.EventID, now, config.RememberedEvents) - e.mu.Unlock() - - thresholds := input.Thresholds - if thresholds.PrimaryPercent == 0 && thresholds.SecondaryPercent == 0 { - thresholds = config.Thresholds - } - thresholds = thresholds.normalized() - maxAge := input.FreshnessTTL - if maxAge == 0 { - maxAge = config.FreshnessTTL - } - if maxAge < 0 { - maxAge = 0 - } - ordered := cloneAndSort(input.Accounts) - eligible := make([]telemetry.AccountTelemetry, 0, len(ordered)) - thresholded := make(map[string]bool, len(ordered)) - activeFound := false - activeEligible := false - activeThresholded := false - for _, account := range ordered { - ok, _ := account.IsCompleteFreshCapacity(now, maxAge) - if input.ActiveAccountID != "" && account.AccountID == input.ActiveAccountID { - activeFound = true - activeEligible = ok - activeThresholded = ok && thresholds.Reached(account) - } - if ok { - eligible = append(eligible, account) - thresholded[account.AccountID] = thresholds.Reached(account) - } - } - - decisionBase := PolicyDecision{ - Trigger: trigger, - ActiveAccountID: input.ActiveAccountID, - CooldownUntil: cooldownUntil, - } - if duplicateEvent { - return e.stay(decisionBase, "duplicate policy event already handled") - } - if pending { - return e.cooldown(decisionBase, now, pendingTarget, "a transition is already in flight") - } - if input.ActiveAccountID != "" && activeFound && trigger == TriggerProactiveThreshold && activeEligible && !activeThresholded { - return e.stay(decisionBase, "active account is below configured thresholds") - } - - legacySelection := trigger == TriggerManual && input.ActiveAccountID == "" && input.FreshnessTTL < 0 - if legacySelection { - if len(eligible) > 0 { - candidate := chooseBestCandidate(eligible, "") - return e.transition(decisionBase, candidate, "account has fresh capacity", input.EventID) - } - return e.poolDecision(decisionBase, ordered, now, maxAge) - } - - // A manual request without an active identity behaves like a first-start - // selection. For automatic triggers the active account is excluded even if - // it still has sub-100 capacity. - if input.ActiveAccountID == "" && trigger == TriggerManual { - if len(eligible) > 0 { - candidate := chooseBestCandidate(eligible, "") - return e.transition(decisionBase, candidate, "manual selection found fresh capacity", input.EventID) - } - return e.poolDecision(decisionBase, ordered, now, maxAge) - } - - candidates := make([]telemetry.AccountTelemetry, 0, len(eligible)) - for _, account := range eligible { - if account.AccountID == input.ActiveAccountID { - continue - } - candidates = append(candidates, account) - } - if trigger == TriggerProactiveThreshold { - belowThreshold := candidates[:0] - for _, account := range candidates { - if !thresholded[account.AccountID] { - belowThreshold = append(belowThreshold, account) - } - } - candidates = belowThreshold - } - if len(candidates) > 0 { - if cooldownUntil.After(now) && trigger != TriggerManual { - return e.cooldown(decisionBase, now, "", "switch cooldown is active") - } - candidate := chooseBestCandidate(candidates, input.ActiveAccountID) - reason := "fresh account has lower quota pressure" - if trigger == TriggerUsageLimitExceeded { - reason = "UsageLimitExceeded requires a fresh replacement account" - } else if trigger == TriggerProactiveThreshold { - reason = "active account crossed the configured usage threshold" - } - return e.transition(decisionBase, candidate, reason, input.EventID) - } - - // If there is a fresh account but all of them are at/above the proactive - // threshold, waiting for a reset is not justified by stale data. Staying is - // safer than thrashing between equally pressured accounts. - if len(eligible) > 0 { - if trigger == TriggerProactiveThreshold { - return e.stay(decisionBase, "no replacement account is below the configured thresholds") - } - if input.ActiveAccountID != "" && activeEligible && trigger != TriggerUsageLimitExceeded { - return e.stay(decisionBase, "no lower-pressure replacement account is available") - } - } - return e.poolDecision(decisionBase, ordered, now, maxAge) -} - -func (e *Engine) poolDecision(base PolicyDecision, accounts []telemetry.AccountTelemetry, now time.Time, maxAge time.Duration) PolicyDecision { - if e.scheduler == nil { - e.scheduler = reset.NewScheduler(e.now) - } - // Age and structural failures are represented as unusable/stale copies for - // the scheduler. This prevents an old sub-100 snapshot from causing a - // false ResetReady result. - schedulerAccounts := make([]telemetry.AccountTelemetry, len(accounts)) - for i, account := range accounts { - clone := account.Clone() - // Preserve fresh exhausted windows so Scheduler can select their - // authoritative future reset. Only age/future timestamp failures are - // marked unusable/stale; otherwise an exhausted account would lose the - // very reset metadata needed for pool-exhaustion waiting. - if !account.IsUsable || account.AccountID == "" || (maxAge > 0 && (account.ObservedAt.IsZero() || account.ObservedAt.After(now) || now.Sub(account.ObservedAt) >= maxAge)) { - clone.IsUsable = false - } - for key, limit := range clone.Limits { - markWindow := func(window *telemetry.WindowTelemetry) { - if window == nil { - return - } - validAge := maxAge <= 0 || (!window.ObservedAt.IsZero() && !window.ObservedAt.After(now) && now.Sub(window.ObservedAt) < maxAge) - preserveReset := window.IsExhausted() && window.HasFutureReset(now) && window.Freshness == telemetry.WindowFresh && validAge - if !preserveReset && (!validAge || window.IsStaleAt(now)) { - window.Freshness = telemetry.WindowStale - } - } - for index := range limit.Windows { - markWindow(&limit.Windows[index]) - } - if limit.Primary != nil { - markWindow(limit.Primary) - } - if limit.Secondary != nil { - markWindow(limit.Secondary) - } - clone.Limits[key] = limit - } - schedulerAccounts[i] = clone - } - decision := e.scheduler.Evaluate(schedulerAccounts, now) - base.RefetchRequired = decision.RefetchRequired - base.RefreshAccountIDs = append([]string(nil), decision.RefreshAccountIDs...) - base.CandidateAccountID = decision.CandidateAccountID - base.LimitID = decision.LimitID - base.DurationMins = decision.DurationMins - base.ResetAt = decision.ResetAt - base.Reason = decision.Reason - if decision.State == reset.ResetWaitForReset { - base.Type = PolicyWaitForReset - return base - } - base.Type = PolicyNoTelemetry - if next, ok := e.scheduler.(interface{ NextDataRecheck() (time.Time, bool) }); ok { - if retry, exists := next.NextDataRecheck(); exists { - base.RetryAt = retry - } - } - if base.Reason == "" { - base.Reason = "no fresh complete account telemetry is available" - } - return base -} - -func (e *Engine) transition(base PolicyDecision, candidate telemetry.AccountTelemetry, reason, eventID string) PolicyDecision { - base.Type = PolicyTransition - base.AccountID = candidate.AccountID - base.CandidateAccountID = candidate.AccountID - base.Reason = reason - base.Rank = Rank(candidate) - if base.Trigger == TriggerManual { - // Preserve the original one-shot/manual API: a caller that only asks for - // a recommendation must explicitly RecordTransition after it accepts - // the decision. Automatic event triggers reserve the target below before - // returning so duplicate notifications cannot enqueue another request. - return base - } - // Mark intent before returning so repeated runtime notifications cannot - // enqueue the same switch before the transition coordinator acknowledges it. - e.mu.Lock() - e.pending = true - e.pendingFrom = base.ActiveAccountID - e.pendingTarget = candidate.AccountID - e.lastEventID = eventID - if eventID != "" { - e.rememberEventLocked(eventID, e.currentTime()) - } - e.mu.Unlock() - return base -} - -func (e *Engine) stay(base PolicyDecision, reason string) PolicyDecision { - base.Type = PolicyStay - base.Reason = reason - return base -} - -func (e *Engine) cooldown(base PolicyDecision, now time.Time, target, reason string) PolicyDecision { - base.Type = PolicyCooldown - base.AccountID = target - base.CandidateAccountID = target - base.Reason = reason - if base.RetryAt.IsZero() { - base.RetryAt = base.CooldownUntil - } - if base.RetryAt.Before(now) { - base.RetryAt = now - } - return base -} - -// Rank computes the deterministic donor-compatible score for one account. -func Rank(account telemetry.AccountTelemetry) CandidateRank { - secondary, hasSecondary := account.UsedPercentByKind(telemetry.SecondaryWindow) - primary, hasPrimary := account.UsedPercentByKind(telemetry.PrimaryWindow) - if !hasSecondary { - secondary = 0 - } - if !hasPrimary { - primary = 0 - } - total := 0.0 - for _, window := range account.WindowList() { - total += window.UsedPercent - } - return CandidateRank{ - AccountID: account.AccountID, - SecondaryUsedPercent: secondary, - PrimaryUsedPercent: primary, - MaxUsedPercent: account.MaxUsedPercent(), - TotalUsedPercent: total, - } -} - -func chooseBestCandidate(accounts []telemetry.AccountTelemetry, active string) telemetry.AccountTelemetry { - ordered := append([]telemetry.AccountTelemetry(nil), accounts...) - sort.SliceStable(ordered, func(i, j int) bool { - left, right := Rank(ordered[i]), Rank(ordered[j]) - if left.SecondaryUsedPercent != right.SecondaryUsedPercent { - return left.SecondaryUsedPercent < right.SecondaryUsedPercent - } - if left.PrimaryUsedPercent != right.PrimaryUsedPercent { - return left.PrimaryUsedPercent < right.PrimaryUsedPercent - } - if left.MaxUsedPercent != right.MaxUsedPercent { - return left.MaxUsedPercent < right.MaxUsedPercent - } - if left.TotalUsedPercent != right.TotalUsedPercent { - return left.TotalUsedPercent < right.TotalUsedPercent - } - return left.AccountID < right.AccountID - }) - if active != "" && len(ordered) > 1 && ordered[0].AccountID == active { - return ordered[1] - } - return ordered[0] -} - -func cloneAndSort(accounts []telemetry.AccountTelemetry) []telemetry.AccountTelemetry { - ordered := make([]telemetry.AccountTelemetry, 0, len(accounts)) - seen := make(map[string]struct{}, len(accounts)) - for _, account := range accounts { - if account.AccountID == "" { - continue - } - if _, duplicate := seen[account.AccountID]; duplicate { - continue - } - seen[account.AccountID] = struct{}{} - ordered = append(ordered, account.Clone()) - } - sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].AccountID < ordered[j].AccountID }) - return ordered -} - -func normalizeConfig(config EngineConfig) EngineConfig { - config.Thresholds = config.Thresholds.normalized() - if config.FreshnessTTL == 0 { - config.FreshnessTTL = defaultFreshnessTTL - } - if config.FreshnessTTL < 0 { - config.FreshnessTTL = 0 - } - if config.Cooldown <= 0 { - config.Cooldown = defaultSwitchCooldown - } - if config.RememberedEvents <= 0 { - config.RememberedEvents = defaultRememberedEvents - } - return config -} - -func (e *Engine) seenEventLocked(eventID string, now time.Time, max int) bool { - if e.seenEvents == nil { - e.seenEvents = make(map[string]time.Time) - } - if at, ok := e.seenEvents[eventID]; ok { - if now.Sub(at) < 24*time.Hour { - return true - } - delete(e.seenEvents, eventID) - } - if len(e.seenEvents) >= max { - oldestID := "" - var oldest time.Time - for id, at := range e.seenEvents { - if oldestID == "" || at.Before(oldest) { - oldestID, oldest = id, at - } - } - if oldestID != "" { - delete(e.seenEvents, oldestID) - } - } - return false -} - -func (e *Engine) rememberEventLocked(eventID string, at time.Time) { - if eventID == "" { - return - } - if e.seenEvents == nil { - e.seenEvents = make(map[string]time.Time) - } - e.seenEvents[eventID] = at.UTC() -} - -// NormalizeReason trims caller-provided policy reasons for stable logs and -// journal entries. It is intentionally not used to make authorization -// decisions. -func NormalizeReason(reason string) string { return strings.TrimSpace(reason) } - -func selectClock(clocks ...func() time.Time) func() time.Time { - if len(clocks) > 0 && clocks[0] != nil { - return clocks[0] - } - return func() time.Time { return time.Now().UTC() } -} - -func (e *Engine) currentTime() time.Time { - if e == nil || e.now == nil { - return time.Now().UTC() - } - if now := e.now(); !now.IsZero() { - return now - } - return time.Now().UTC() -} diff --git a/controller/internal/policy/policy_test.go b/controller/internal/policy/policy_test.go deleted file mode 100644 index 51caedca..00000000 --- a/controller/internal/policy/policy_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package policy - -import ( - "testing" - "time" - - "codexmarathon/controller/internal/telemetry" -) - -func TestEvaluateChoosesDeterministicFreshAccount(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - decision := Evaluate([]telemetry.AccountTelemetry{ - policyAccount("account-b", 20, now.Add(time.Hour), now), - policyAccount("account-a", 20, now.Add(time.Hour), now), - }, now) - if decision.Type != PolicyTransition || decision.AccountID != "account-a" { - t.Fatalf("unexpected account decision: %#v", decision) - } -} - -func TestEvaluateWaitsForEarliestResetWhenPoolExhausted(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - first := now.Add(10 * time.Minute) - decision := Evaluate([]telemetry.AccountTelemetry{ - policyAccount("account-a", 100, first, now), - }, now) - if decision.Type != PolicyWaitForReset || decision.CandidateAccountID != "account-a" { - t.Fatalf("unexpected reset decision: %#v", decision) - } - if !decision.ResetAt.Equal(first) { - t.Fatalf("wrong reset time: %v", decision.ResetAt) - } -} - -func TestEvaluateDoesNotAssumeElapsedResetRestoredCapacity(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 1, 0, 0, time.UTC) - resetAt := now.Add(-time.Minute) - decision := Evaluate([]telemetry.AccountTelemetry{policyAccount("account-a", 100, resetAt, now)}, now) - if decision.Type != PolicyNoTelemetry || !decision.RefetchRequired { - t.Fatalf("elapsed reset should require telemetry, got %#v", decision) - } -} - -func TestProactiveThresholdSelectsDeterministicLowerPressureAccount(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - engine := NewConfiguredEngine(EngineConfig{FreshnessTTL: time.Hour, Cooldown: time.Minute}, nil, func() time.Time { return now }) - decision := engine.EvaluateThreshold([]telemetry.AccountTelemetry{ - policyAccountWithWindows("account-z", []float64{80, 90}, now), - policyAccountWithWindows("account-b", []float64{20, 10}, now), - policyAccountWithWindows("account-a", []float64{20, 10}, now), - }, "account-z", Thresholds{PrimaryPercent: 80, SecondaryPercent: 90}, now) - if decision.Type != PolicyTransition || decision.AccountID != "account-a" { - t.Fatalf("unexpected threshold decision: %#v", decision) - } - if decision.Rank.SecondaryUsedPercent != 10 || decision.Rank.PrimaryUsedPercent != 20 { - t.Fatalf("unexpected candidate rank: %#v", decision.Rank) - } -} - -func TestUsageLimitExceededSelectsFreshReplacement(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - resetAt := now.Add(time.Hour) - engine := NewConfiguredEngine(EngineConfig{FreshnessTTL: time.Hour}, nil, func() time.Time { return now }) - decision := engine.EvaluateUsageLimitExceeded([]telemetry.AccountTelemetry{ - exhaustedPolicyAccount("account-a", resetAt, now), - policyAccountWithWindows("account-b", []float64{5, 6}, now), - }, "account-a", "usage-event-1", now) - if decision.Type != PolicyTransition || decision.AccountID != "account-b" { - t.Fatalf("unexpected hard-limit decision: %#v", decision) - } - duplicate := engine.EvaluateUsageLimitExceeded([]telemetry.AccountTelemetry{ - exhaustedPolicyAccount("account-a", resetAt, now), - policyAccountWithWindows("account-b", []float64{5, 6}, now), - }, "account-a", "usage-event-1", now) - if duplicate.Type != PolicyStay { - t.Fatalf("duplicate hard-limit event retriggered switch: %#v", duplicate) - } -} - -func TestThresholdDoesNotAuthorizeIncompleteOrStaleCandidate(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - engine := NewConfiguredEngine(EngineConfig{FreshnessTTL: time.Hour}, nil, func() time.Time { return now }) - stale := now.Add(-2 * time.Hour) - decision := engine.EvaluateThreshold([]telemetry.AccountTelemetry{ - policyAccountWithWindows("account-a", []float64{95, 20}, now), - policyAccountWithWindows("account-b", []float64{1, 1}, stale), - {AccountID: "account-c", IsUsable: true, ObservedAt: now, Limits: map[string]telemetry.LimitTelemetry{ - "partial": {LimitID: "partial", Windows: nil}, - }}, - }, "account-a", Thresholds{PrimaryPercent: 80, SecondaryPercent: 90}, now) - if decision.Type != PolicyStay && decision.Type != PolicyNoTelemetry { - t.Fatalf("invalid candidate authorized a switch: %#v", decision) - } - if decision.AccountID != "" { - t.Fatalf("invalid candidate selected: %#v", decision) - } -} - -func TestPoolExhaustionKeepsEarliestResetAndRequestsRevalidation(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - first := now.Add(15 * time.Minute) - second := now.Add(30 * time.Minute) - engine := NewConfiguredEngine(EngineConfig{FreshnessTTL: time.Hour}, nil, func() time.Time { return now }) - decision := engine.EvaluateUsageLimitExceeded([]telemetry.AccountTelemetry{ - exhaustedPolicyAccount("account-b", second, now), - exhaustedPolicyAccount("account-a", first, now), - }, "account-a", "hard-event", now) - if decision.Type != PolicyWaitForReset || !decision.RefetchRequired { - t.Fatalf("unexpected exhaustion decision: %#v", decision) - } - if decision.CandidateAccountID != "account-a" || !decision.ResetAt.Equal(first) { - t.Fatalf("wrong earliest reset: %#v", decision) - } - if len(decision.RefreshAccountIDs) != 1 || decision.RefreshAccountIDs[0] != "account-a" { - t.Fatalf("candidate was not included in revalidation: %#v", decision.RefreshAccountIDs) - } -} - -func TestTransitionCooldownPreventsImmediateLoop(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - engine := NewConfiguredEngine(EngineConfig{FreshnessTTL: time.Hour, Cooldown: time.Minute}, nil, func() time.Time { return now }) - engine.RecordTransition("account-a", "account-b", now) - decision := engine.EvaluateThreshold([]telemetry.AccountTelemetry{ - policyAccountWithWindows("account-a", []float64{95, 96}, now), - policyAccountWithWindows("account-b", []float64{1, 2}, now), - }, "account-a", Thresholds{PrimaryPercent: 80, SecondaryPercent: 90}, now.Add(10*time.Second)) - if decision.Type != PolicyCooldown { - t.Fatalf("cooldown did not suppress repeated switch: %#v", decision) - } -} - -func policyAccount(accountID string, usage float64, resetAt, observedAt time.Time) telemetry.AccountTelemetry { - window := telemetry.WindowTelemetry{ - Kind: telemetry.PrimaryWindow, - UsedPercent: usage, - ResetsAt: &resetAt, - ObservedAt: observedAt, - Freshness: telemetry.WindowFresh, - } - return telemetry.AccountTelemetry{ - AccountID: accountID, - IsUsable: true, - ObservedAt: observedAt, - Limits: map[string]telemetry.LimitTelemetry{ - "codex": {LimitID: "codex", Windows: []telemetry.WindowTelemetry{window}}, - }, - } -} - -func policyAccountWithWindows(accountID string, usage []float64, observedAt time.Time) telemetry.AccountTelemetry { - windows := []telemetry.WindowTelemetry{{Kind: telemetry.PrimaryWindow, UsedPercent: usage[0], ObservedAt: observedAt, Freshness: telemetry.WindowFresh}, {Kind: telemetry.SecondaryWindow, UsedPercent: usage[1], ObservedAt: observedAt, Freshness: telemetry.WindowFresh}} - return telemetry.AccountTelemetry{AccountID: accountID, IsUsable: true, ObservedAt: observedAt, Limits: map[string]telemetry.LimitTelemetry{"codex": {LimitID: "codex", Windows: windows}}} -} - -func exhaustedPolicyAccount(accountID string, resetAt, observedAt time.Time) telemetry.AccountTelemetry { - windows := []telemetry.WindowTelemetry{{Kind: telemetry.PrimaryWindow, UsedPercent: 100, ResetsAt: &resetAt, ObservedAt: observedAt, Freshness: telemetry.WindowFresh}, {Kind: telemetry.SecondaryWindow, UsedPercent: 100, ResetsAt: &resetAt, ObservedAt: observedAt, Freshness: telemetry.WindowFresh}} - return telemetry.AccountTelemetry{AccountID: accountID, IsUsable: true, ObservedAt: observedAt, Limits: map[string]telemetry.LimitTelemetry{"codex": {LimitID: "codex", Windows: windows}}} -} diff --git a/controller/internal/quota/PROVENANCE.md b/controller/internal/quota/PROVENANCE.md deleted file mode 100644 index 9a427333..00000000 --- a/controller/internal/quota/PROVENANCE.md +++ /dev/null @@ -1,24 +0,0 @@ -# Quota implementation provenance - -`controller/internal/quota` is a product-owned, standard-library-only -adaptation of the public `humeo/codex-switch` quota checker at donor commit -`ca5c7dd454780272ed429f6fb1dceebda44397ae`. - -The low-level request/header behavior was adapted from: - -- `donor/codex-switch/internal/quota/quota.go` -- `donor/codex-switch/internal/quota/quota_test.go` -- `donor/codex-switch/internal/cli/list.go` (cache freshness and reset metadata) - -The account-selection policy, threshold, cooldown, event de-duplication, and -reset-wait behavior were independently adapted into the CodexMarathon -controller from the donor watcher package: - -- `donor/codex-switch/internal/watcher/watcher.go` -- `donor/codex-switch/internal/watcher/events.go` -- `donor/codex-switch/internal/watcher/state.go` - -CodexMarathon does not import donor modules at build time. The adapter keeps -credentials opaque, never writes tokens to telemetry/cache state, and redacts -credential-bearing values from provider errors. Changes made here should be -reviewed against the pinned donor commit when upstream behavior changes. diff --git a/controller/internal/quota/credential_source.go b/controller/internal/quota/credential_source.go deleted file mode 100644 index eaccf98d..00000000 --- a/controller/internal/quota/credential_source.go +++ /dev/null @@ -1,69 +0,0 @@ -package quota - -import ( - "context" - "encoding/json" - "errors" - - "codexmarathon/controller/internal/credentials" -) - -// VaultCredentialSource adapts CodexMarathon's opaque vault to the quota -// client's bearer-token seam. Parsing is deliberately limited to the fields -// required for the donor-compatible calibration request; all unknown auth -// fields remain owned by the embedded Codex runtime. -type VaultCredentialSource struct { - Vault credentials.SnapshotReader -} - -func (s VaultCredentialSource) Tokens(ctx context.Context, accountID string) (Tokens, error) { - if ctx == nil { - ctx = context.Background() - } - if err := ctx.Err(); err != nil { - return Tokens{}, err - } - if s.Vault == nil { - return Tokens{}, errors.New("credential vault is unavailable") - } - raw, err := s.Vault.Load(accountID) - if err != nil { - return Tokens{}, errors.New("credential snapshot is unavailable") - } - tokens, err := extractTokens(raw) - if err != nil { - return Tokens{}, err - } - if tokens.AccessToken == "" { - return Tokens{}, ErrMissingToken - } - return tokens, nil -} - -func extractTokens(raw []byte) (Tokens, error) { - var object map[string]json.RawMessage - if err := json.Unmarshal(raw, &object); err != nil || object == nil { - return Tokens{}, errors.New("credential snapshot is not a JSON object") - } - var tokensObject map[string]json.RawMessage - if nested, ok := object["tokens"]; ok && string(nested) != "null" { - if err := json.Unmarshal(nested, &tokensObject); err != nil || tokensObject == nil { - return Tokens{}, errors.New("credential snapshot tokens are invalid") - } - } else { - tokensObject = object - } - var tokens Tokens - if value, ok := tokensObject["access_token"]; ok { - _ = json.Unmarshal(value, &tokens.AccessToken) - } - if value, ok := tokensObject["account_id"]; ok { - _ = json.Unmarshal(value, &tokens.AccountID) - } - if tokens.AccountID == "" { - if value, ok := object["account_id"]; ok { - _ = json.Unmarshal(value, &tokens.AccountID) - } - } - return tokens, nil -} diff --git a/controller/internal/quota/quota.go b/controller/internal/quota/quota.go deleted file mode 100644 index 299463ce..00000000 --- a/controller/internal/quota/quota.go +++ /dev/null @@ -1,442 +0,0 @@ -// Package quota adapts the quota-calibration request from humeo/codex-switch -// to CodexMarathon's controller provider interface. It is product-owned code -// derived from the donor's internal/quota client: retry classification, -// header parsing, and 429 handling are retained, while results are normalized -// into the controller's multi-window telemetry model. No caller-facing error -// includes an authorization header or token value. -package quota - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "codexmarathon/controller/internal/telemetry" -) - -var ( - ErrMissingToken = errors.New("quota credentials are missing an access token") - ErrMissingUsage = errors.New("quota response did not include usage headers") - ErrUnexpectedResponse = errors.New("quota provider returned an unexpected response") - ErrIdentityMismatch = errors.New("quota credentials identify a different account") -) - -// DefaultModel mirrors codex-switch's default calibration model while keeping -// the model choice explicit for callers that want a different Codex model. -const ( - DefaultModel = "gpt-5.4-mini" - defaultRequestTimeout = 15 * time.Second -) - -// Tokens are the minimum bearer/account identity accepted by the quota -// endpoint. They are intentionally not JSON tagged or printable through this -// package's errors. -type Tokens struct { - AccessToken string - AccountID string -} - -// Snapshot preserves the donor client's two-window view and tracks header -// presence so omitted usage cannot be mistaken for a real zero observation. -type Snapshot struct { - Plan string - PrimaryUsedPercent float64 - SecondaryUsedPercent float64 - PrimaryResetAfter time.Duration - SecondaryResetAfter time.Duration - PrimaryResetAt time.Time - SecondaryResetAt time.Time - HasPrimary bool - HasSecondary bool - HasCredits bool - CreditsBalance string - RateLimited bool -} - -// ParseHeaders maps the Codex response headers used by codex-switch. It is -// deterministic and does not require a network request, making it suitable -// for fixture tests and for future runtime adapters. -func ParseHeaders(headers http.Header) (Snapshot, error) { - var snapshot Snapshot - if value := headers.Get("x-codex-plan-type"); value != "" { - snapshot.Plan = value - } - if value := headers.Get("x-codex-primary-used-percent"); value != "" { - percent, err := parsePercent(value, "x-codex-primary-used-percent") - if err != nil { - return Snapshot{}, err - } - snapshot.PrimaryUsedPercent = percent - snapshot.HasPrimary = true - } - if value := headers.Get("x-codex-secondary-used-percent"); value != "" { - percent, err := parsePercent(value, "x-codex-secondary-used-percent") - if err != nil { - return Snapshot{}, err - } - snapshot.SecondaryUsedPercent = percent - snapshot.HasSecondary = true - } - if value := headers.Get("x-codex-primary-reset-after-seconds"); value != "" { - seconds, err := strconv.ParseInt(value, 10, 64) - if err != nil || seconds < 0 { - return Snapshot{}, fmt.Errorf("parse x-codex-primary-reset-after-seconds") - } - snapshot.PrimaryResetAfter = time.Duration(seconds) * time.Second - } - if value := headers.Get("x-codex-secondary-reset-after-seconds"); value != "" { - seconds, err := strconv.ParseInt(value, 10, 64) - if err != nil || seconds < 0 { - return Snapshot{}, fmt.Errorf("parse x-codex-secondary-reset-after-seconds") - } - snapshot.SecondaryResetAfter = time.Duration(seconds) * time.Second - } - if value := headers.Get("x-codex-primary-reset-at"); value != "" { - seconds, err := strconv.ParseInt(value, 10, 64) - if err != nil || seconds < 0 { - return Snapshot{}, fmt.Errorf("parse x-codex-primary-reset-at") - } - snapshot.PrimaryResetAt = time.Unix(seconds, 0).UTC() - } - if value := headers.Get("x-codex-secondary-reset-at"); value != "" { - seconds, err := strconv.ParseInt(value, 10, 64) - if err != nil || seconds < 0 { - return Snapshot{}, fmt.Errorf("parse x-codex-secondary-reset-at") - } - snapshot.SecondaryResetAt = time.Unix(seconds, 0).UTC() - } - if value := headers.Get("x-codex-credits-has-credits"); value != "" { - credits, err := strconv.ParseBool(value) - if err != nil { - return Snapshot{}, fmt.Errorf("parse x-codex-credits-has-credits") - } - snapshot.HasCredits = credits - } - if value := headers.Get("x-codex-credits-balance"); value != "" { - snapshot.CreditsBalance = value - } - return snapshot, nil -} - -func parsePercent(value, field string) (float64, error) { - percent, err := strconv.ParseFloat(value, 64) - if err != nil || percent < 0 || percent > 100 { - return 0, fmt.Errorf("parse %s", field) - } - return percent, nil -} - -// Client performs the donor-compatible calibration request. BaseURL and -// HTTP are injectable for tests; production defaults target chatgpt.com. -type Client struct { - BaseURL string - HTTP *http.Client - Retries int - Now func() time.Time -} - -// Check sends the smallest supported streamed Codex request and extracts -// quota headers. The endpoint response body is discarded and never included -// in errors, preventing provider pages from leaking into logs. -func (c Client) Check(ctx context.Context, tokens Tokens, model string) (Snapshot, error) { - if strings.TrimSpace(tokens.AccessToken) == "" { - return Snapshot{}, ErrMissingToken - } - if ctx == nil { - ctx = context.Background() - } - baseURL := c.BaseURL - if baseURL == "" { - baseURL = "https://chatgpt.com" - } - client := c.HTTP - if client == nil { - client = &http.Client{Timeout: defaultRequestTimeout} - } - requestBody, err := json.Marshal(quotaRequest{ - Model: model, - Input: []quotaMessage{{Role: "user", Content: "hi"}}, - Instructions: ".", - Store: false, - Stream: true, - Reasoning: quotaReasoning{Effort: "none"}, - }) - if err != nil { - return Snapshot{}, errors.New("encode quota request") - } - endpoint, err := url.JoinPath(baseURL, "/backend-api/codex/responses") - if err != nil { - return Snapshot{}, errors.New("build quota endpoint") - } - retries := c.Retries - if retries <= 0 { - retries = 3 - } - var lastErr error - for attempt := 0; attempt < retries; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(requestBody)) - if err != nil { - return Snapshot{}, errors.New("build quota request") - } - req.Header.Set("Authorization", "Bearer "+tokens.AccessToken) - if tokens.AccountID != "" { - req.Header.Set("ChatGPT-Account-Id", tokens.AccountID) - } - req.Header.Set("Content-Type", "application/json") - response, err := client.Do(req) - if err != nil { - if !isRetryableError(err) || attempt == retries-1 { - return Snapshot{}, errors.New("quota request failed") - } - lastErr = errors.New("quota request temporarily unavailable") - sleepForAttempt(ctx, attempt) - continue - } - snapshot, retryable, err := snapshotFromResponse(response) - if err == nil && !retryable { - return snapshot, nil - } - if !retryable || attempt == retries-1 { - if err == nil { - err = ErrUnexpectedResponse - } - return Snapshot{}, err - } - lastErr = err - sleepForAttempt(ctx, attempt) - } - if lastErr != nil { - return Snapshot{}, lastErr - } - return Snapshot{}, ErrUnexpectedResponse -} - -type quotaRequest struct { - Model string `json:"model"` - Input []quotaMessage `json:"input"` - Instructions string `json:"instructions"` - Store bool `json:"store"` - Stream bool `json:"stream"` - Reasoning quotaReasoning `json:"reasoning"` -} - -type quotaMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type quotaReasoning struct { - Effort string `json:"effort"` -} - -func snapshotFromResponse(response *http.Response) (Snapshot, bool, error) { - if response == nil { - return Snapshot{}, true, ErrUnexpectedResponse - } - var body []byte - if response.Body != nil { - defer response.Body.Close() - var readErr error - body, readErr = io.ReadAll(io.LimitReader(response.Body, 64*1024)) - if readErr != nil { - return Snapshot{}, true, errors.New("read quota response") - } - } - switch response.StatusCode { - case http.StatusOK: - snapshot, err := ParseHeaders(response.Header) - if err != nil { - return Snapshot{}, false, err - } - // Preserve codex-switch's low-level client behavior: a successful - // response is returned even when a provider omits one usage header. - // Provider/SnapshotToTelemetry keeps that partial result unusable for - // policy, so an omitted header can never authorize a switch. - return snapshot, false, nil - case http.StatusTooManyRequests: - snapshot, err := ParseHeaders(response.Header) - if err != nil { - return Snapshot{}, false, err - } - // A 429 is authoritative evidence of exhaustion even when one of the - // usage headers is omitted. Preserve any supplied reset timestamps. - snapshot.PrimaryUsedPercent = 100 - snapshot.SecondaryUsedPercent = 100 - snapshot.HasPrimary = true - snapshot.HasSecondary = true - snapshot.RateLimited = true - return snapshot, false, nil - case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: - return Snapshot{}, true, fmt.Errorf("quota provider temporary status %d", response.StatusCode) - case http.StatusForbidden: - if looksLikeHTMLChallenge(response.Header, body) { - return Snapshot{}, true, errors.New("quota provider challenge") - } - return Snapshot{}, false, errors.New("quota provider rejected credentials") - default: - return Snapshot{}, false, fmt.Errorf("quota provider status %d", response.StatusCode) - } -} - -func looksLikeHTMLChallenge(headers http.Header, body []byte) bool { - contentType := strings.ToLower(headers.Get("Content-Type")) - if strings.Contains(contentType, "text/html") { - return true - } - normalized := strings.ToLower(string(body)) - return strings.Contains(normalized, " phaseRank(PhaseParked) { - return nil - } - state := existing - if !exists { - state = State{RecoveryID: payload.RecoveryID, Phase: PhaseParked} - } - state.RuntimeID = event.RuntimeID - if payload.ThreadID != "" { - state.ThreadID = payload.ThreadID - } - if payload.TurnID != "" { - state.TurnID = payload.TurnID - } - if payload.SourceAccountID != "" { - state.SourceAccountID = payload.SourceAccountID - } - state.Phase = PhaseParked - // Do not persist arbitrary runtime reason text: Codext owns the prompt and - // the controller journal is deliberately metadata-only. - state.Reason = "runtime parked usage-limit recovery" - state.UpdatedAt = m.nowUTC() - if exists && stateEqual(existing, state) { - return nil - } - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryParked, - RecoveryID: state.RecoveryID, - RuntimeID: state.RuntimeID, - ThreadID: state.ThreadID, - TurnID: state.TurnID, - AccountID: state.SourceAccountID, - AuthGeneration: event.AuthGeneration, - Reason: state.Reason, - }); err != nil { - return err - } - m.states[state.RecoveryID] = state - return nil -} - -func (m *Manager) observeStarted(event runtime.Event, payload runtime.RecoveryStartedEvent) error { - if strings.TrimSpace(payload.RecoveryID) == "" { - return errors.New("recovery_started has no recovery_id") - } - m.mu.Lock() - defer m.mu.Unlock() - existing, exists := m.states[payload.RecoveryID] - if !exists { - existing = State{RecoveryID: payload.RecoveryID} - } - if existing.RuntimeID != "" && existing.RuntimeID != event.RuntimeID { - // A runtime restart can change its process ID. The recovery ID remains - // the durable authority, so adopt the new runtime ID for reconciliation. - existing.RuntimeID = event.RuntimeID - } else if existing.RuntimeID == "" { - existing.RuntimeID = event.RuntimeID - } - if payload.ThreadID != "" { - if existing.ThreadID != "" && existing.ThreadID != payload.ThreadID { - return fmt.Errorf("%w: thread_id changed for %s", ErrRecoveryConflict, payload.RecoveryID) - } - existing.ThreadID = payload.ThreadID - } - if payload.TurnID != "" { - existing.TurnID = payload.TurnID - } - if phaseRank(existing.Phase) > phaseRank(PhaseStarted) { - return nil - } - old := existing - existing.Phase = PhaseStarted - existing.UpdatedAt = m.nowUTC() - if stateEqual(old, existing) { - return nil - } - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryStarted, - RecoveryID: existing.RecoveryID, - RuntimeID: existing.RuntimeID, - ThreadID: existing.ThreadID, - TurnID: existing.TurnID, - TransitionID: existing.TransitionID, - TargetAccountID: existing.TargetAccountID, - AuthGeneration: existing.ExpectedGeneration, - }); err != nil { - return err - } - m.states[existing.RecoveryID] = existing - return nil -} - -func (m *Manager) observeCompleted(event runtime.Event, payload runtime.RecoveryCompletedEvent) error { - if strings.TrimSpace(payload.RecoveryID) == "" { - return errors.New("recovery_completed has no recovery_id") - } - m.mu.Lock() - defer m.mu.Unlock() - existing, exists := m.states[payload.RecoveryID] - if !exists { - existing = State{RecoveryID: payload.RecoveryID} - } - if existing.RuntimeID == "" { - existing.RuntimeID = event.RuntimeID - } - if payload.ThreadID != "" { - existing.ThreadID = payload.ThreadID - } - if payload.TurnID != "" { - existing.TurnID = payload.TurnID - } - targetPhase := PhaseCompleted - if strings.EqualFold(payload.Outcome, "failed") || strings.EqualFold(payload.Outcome, "skipped") { - targetPhase = PhaseFailed - } - if phaseRank(existing.Phase) > phaseRank(targetPhase) { - return nil - } - old := existing - existing.Phase = targetPhase - if targetPhase == PhaseFailed { - existing.Reason = "runtime recovery completed without a continuation" - } else { - existing.Reason = "runtime recovery completed" - } - existing.UpdatedAt = m.nowUTC() - if stateEqual(old, existing) { - return nil - } - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryCompleted, - RecoveryID: existing.RecoveryID, - RuntimeID: existing.RuntimeID, - ThreadID: existing.ThreadID, - TurnID: existing.TurnID, - TransitionID: existing.TransitionID, - TargetAccountID: existing.TargetAccountID, - AuthGeneration: existing.ExpectedGeneration, - Outcome: strings.ToLower(payload.Outcome), - Reason: existing.Reason, - }); err != nil { - return err - } - m.states[existing.RecoveryID] = existing - return nil -} - -// MarkWaiting records that the recovery remains parked while every account -// is exhausted or until the earliest reset. It never invokes the runtime. -func (m *Manager) MarkWaiting(recoveryID, reason string) error { - if m == nil { - return ErrNilManager - } - m.mu.Lock() - defer m.mu.Unlock() - state, ok := m.states[recoveryID] - if !ok { - return fmt.Errorf("%w: %s", ErrRecoveryNotFound, recoveryID) - } - if isTerminal(state.Phase) { - return nil - } - old := state - state.Phase = PhaseWaiting - state.Reason = safeWaitingReason(reason) - state.UpdatedAt = m.nowUTC() - if stateEqual(old, state) { - return nil - } - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryWaiting, - RecoveryID: state.RecoveryID, - RuntimeID: state.RuntimeID, - ThreadID: state.ThreadID, - TurnID: state.TurnID, - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AuthGeneration: state.ExpectedGeneration, - Reason: state.Reason, - }); err != nil { - return err - } - m.states[recoveryID] = state - return nil -} - -// Bind associates a parked recovery with a specific transition. It is -// idempotent for identical metadata and rejects cross-thread/rebind attempts. -func (m *Manager) Bind(binding Binding) error { - if m == nil { - return ErrNilManager - } - if strings.TrimSpace(binding.RecoveryID) == "" || strings.TrimSpace(binding.TransitionID) == "" || strings.TrimSpace(binding.TargetAccountID) == "" { - return errors.New("recovery binding requires recovery_id, transition_id, and target_account_id") - } - if binding.ExpectedGeneration == 0 { - return errors.New("recovery binding requires expected_generation") - } - m.mu.Lock() - defer m.mu.Unlock() - state, ok := m.states[binding.RecoveryID] - if !ok { - return fmt.Errorf("%w: %s", ErrRecoveryNotFound, binding.RecoveryID) - } - if isTerminal(state.Phase) { - return nil - } - if state.TransitionID != "" { - if state.TransitionID == binding.TransitionID && state.TargetAccountID == binding.TargetAccountID && state.ExpectedGeneration == binding.ExpectedGeneration { - return nil - } - return fmt.Errorf("%w: %s already belongs to transition %s", ErrRecoveryConflict, binding.RecoveryID, state.TransitionID) - } - if state.ThreadID != "" && binding.ThreadID != "" && state.ThreadID != binding.ThreadID { - return fmt.Errorf("%w: recovery thread differs from binding", ErrRecoveryConflict) - } - state.RuntimeID = firstNonEmpty(binding.RuntimeID, state.RuntimeID) - state.ThreadID = firstNonEmpty(state.ThreadID, binding.ThreadID) - state.TurnID = firstNonEmpty(state.TurnID, binding.TurnID) - state.TransitionID = binding.TransitionID - state.TargetAccountID = binding.TargetAccountID - state.ExpectedGeneration = binding.ExpectedGeneration - state.Phase = PhaseBound - state.Reason = "recovery bound to verified transition" - state.UpdatedAt = m.nowUTC() - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryBound, - RecoveryID: state.RecoveryID, - RuntimeID: state.RuntimeID, - ThreadID: state.ThreadID, - TurnID: state.TurnID, - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AuthGeneration: state.ExpectedGeneration, - ExpectedGeneration: state.ExpectedGeneration, - Reason: state.Reason, - }); err != nil { - return err - } - m.states[state.RecoveryID] = state - return nil -} - -// PendingForTarget returns one deterministic parked recovery for a target. -// Callers should bind it before requesting a transition when they want the -// crash window between transition commit and binding to be zero. A false -// result is normal for ordinary transitions with no parked recovery. -func (m *Manager) PendingForTarget(targetAccountID, runtimeID, threadID string) (State, bool) { - if m == nil { - return State{}, false - } - m.mu.Lock() - defer m.mu.Unlock() - var chosen State - found := false - for _, state := range m.states { - if isTerminal(state.Phase) || state.TargetAccountID != "" { - continue - } - if runtimeID != "" && state.RuntimeID != "" && state.RuntimeID != runtimeID { - continue - } - if threadID != "" && state.ThreadID != "" && state.ThreadID != threadID { - continue - } - // An unbound recovery has no target yet; the target is supplied when - // the verified transition commits. Keep the parameter for callers that - // already know the intended target and reject only an empty request. - if targetAccountID == "" { - continue - } - if !found || state.RecoveryID < chosen.RecoveryID { - chosen, found = state, true - } - } - return chosen, found -} - -// OnTransitionCommitted is the only release authorization path. It refuses -// uncertain/rejected results, persists the release intent before IPC, and -// treats an already-released acknowledgement as success. Repeating it with -// the same transition is safe and never creates a second prompt. -func (m *Manager) OnTransitionCommitted(ctx context.Context, commit TransitionCommit) error { - if m == nil { - return ErrNilManager - } - if ctx == nil { - ctx = context.Background() - } - if !strings.EqualFold(commit.Outcome, "committed") { - return fmt.Errorf("%w: transition outcome is %q", ErrReleaseNotAuthorized, commit.Outcome) - } - if commit.TransitionID == "" || commit.TargetAccountID == "" || commit.ExpectedGeneration == 0 { - return fmt.Errorf("%w: incomplete committed transition", ErrReleaseNotAuthorized) - } - if commit.FinalGeneration != 0 && commit.FinalGeneration != commit.ExpectedGeneration { - return fmt.Errorf("%w: final generation %d does not match expected %d", ErrIdentityNotVerified, commit.FinalGeneration, commit.ExpectedGeneration) - } - // Snapshot matching records under lock. A normal transition with no parked - // recovery is a successful no-op for this manager. When the caller could - // not bind before requesting the transition, bind one unbound candidate - // only when the runtime/source correlation is unambiguous. - m.mu.Lock() - ids := make([]string, 0) - for id, state := range m.states { - if state.TransitionID == commit.TransitionID { - ids = append(ids, id) - } - } - if len(ids) == 0 { - candidates := make([]State, 0) - for _, state := range m.states { - if isTerminal(state.Phase) || state.TransitionID != "" || state.TargetAccountID != "" { - continue - } - if commit.RuntimeID != "" && state.RuntimeID != "" && state.RuntimeID != commit.RuntimeID { - continue - } - if commit.SourceAccountID != "" && state.SourceAccountID != "" && state.SourceAccountID != commit.SourceAccountID { - continue - } - candidates = append(candidates, state) - } - if len(candidates) == 1 { - candidate := candidates[0] - // Keep the lock while applying the binding so a concurrent commit - // cannot select the same parked recovery. - candidate.RuntimeID = firstNonEmpty(commit.RuntimeID, candidate.RuntimeID) - candidate.TransitionID = commit.TransitionID - candidate.TargetAccountID = commit.TargetAccountID - candidate.ExpectedGeneration = commit.ExpectedGeneration - candidate.Phase = PhaseBound - candidate.Reason = "recovery bound to verified transition" - candidate.UpdatedAt = m.nowUTC() - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryBound, - RecoveryID: candidate.RecoveryID, - RuntimeID: candidate.RuntimeID, - ThreadID: candidate.ThreadID, - TurnID: candidate.TurnID, - TransitionID: candidate.TransitionID, - TargetAccountID: candidate.TargetAccountID, - AuthGeneration: candidate.ExpectedGeneration, - ExpectedGeneration: candidate.ExpectedGeneration, - Reason: candidate.Reason, - }); err != nil { - m.mu.Unlock() - return err - } - m.states[candidate.RecoveryID] = candidate - ids = append(ids, candidate.RecoveryID) - } - } - sort.Strings(ids) - if len(ids) == 0 { - m.mu.Unlock() - return nil - } - rt := m.runtime - m.mu.Unlock() - if rt == nil { - // Persist the intent even when the runtime is temporarily detached. - // This is the crash boundary between a verified commit and the next - // IPC attempt; Reconcile will retry it after reconnecting. - for _, id := range ids { - if _, err := m.requestRelease(id, commit); err != nil { - return err - } - } - return ErrRuntimeUnavailable - } - for _, id := range ids { - if err := m.releaseOne(ctx, id, commit, rt); err != nil { - return err - } - } - return nil -} - -func (m *Manager) releaseOne(ctx context.Context, recoveryID string, commit TransitionCommit, rt Runtime) error { - state, err := m.requestRelease(recoveryID, commit) - if err != nil { - if errors.Is(err, errRecoveryTerminal) { - return nil - } - return err - } - - result, err := rt.ReleaseRecovery(ctx, runtime.RecoveryReleaseParams{ - RecoveryID: state.RecoveryID, - ThreadID: state.ThreadID, - TransitionID: state.TransitionID, - ExpectedGeneration: state.ExpectedGeneration, - }) - if err != nil { - return m.markUncertain(recoveryID, fmt.Errorf("release recovery: %w", err)) - } - if result.RecoveryID != "" && result.RecoveryID != recoveryID { - return m.markUncertain(recoveryID, fmt.Errorf("release response recovery_id %q does not match %q", result.RecoveryID, recoveryID)) - } - switch result.Outcome { - case runtime.RecoveryReleased, runtime.RecoveryAlreadyReleased: - return m.markReleased(recoveryID, result.Outcome) - case runtime.RecoveryReleaseUncertain: - return m.markUncertain(recoveryID, errors.New("runtime returned uncertain recovery release")) - case runtime.RecoveryReleaseRejected: - return m.markUncertain(recoveryID, errors.New("runtime rejected recovery release")) - default: - return m.markUncertain(recoveryID, fmt.Errorf("runtime returned unknown recovery release outcome %q", result.Outcome)) - } -} - -var errRecoveryTerminal = errors.New("recovery already terminal") - -// requestRelease durably records the authorization intent once. It is kept -// separate from the IPC call so a detached runtime or a controller crash -// cannot lose the fact that identity verification already happened. -func (m *Manager) requestRelease(recoveryID string, commit TransitionCommit) (State, error) { - m.mu.Lock() - defer m.mu.Unlock() - state, ok := m.states[recoveryID] - if !ok { - return State{}, fmt.Errorf("%w: %s", ErrRecoveryNotFound, recoveryID) - } - if state.TransitionID != commit.TransitionID || state.TargetAccountID != commit.TargetAccountID || state.ExpectedGeneration != commit.ExpectedGeneration { - return State{}, fmt.Errorf("%w: committed transition metadata differs for %s", ErrIdentityNotVerified, recoveryID) - } - if isTerminal(state.Phase) { - return State{}, errRecoveryTerminal - } - if state.Phase == PhaseReleaseRequested { - return state, nil - } - state.Phase = PhaseReleaseRequested - state.Reason = "verified identity transition; recovery release requested" - state.UpdatedAt = m.nowUTC() - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryReleaseRequested, - RecoveryID: state.RecoveryID, - RuntimeID: state.RuntimeID, - ThreadID: state.ThreadID, - TurnID: state.TurnID, - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AuthGeneration: state.ExpectedGeneration, - ExpectedGeneration: state.ExpectedGeneration, - Reason: state.Reason, - }); err != nil { - return State{}, err - } - m.states[recoveryID] = state - return state, nil -} - -func (m *Manager) markReleased(recoveryID string, outcome runtime.RecoveryReleaseOutcome) error { - m.mu.Lock() - defer m.mu.Unlock() - state, ok := m.states[recoveryID] - if !ok { - return fmt.Errorf("%w: %s", ErrRecoveryNotFound, recoveryID) - } - if state.Phase == PhaseReleased || state.Phase == PhaseStarted || state.Phase == PhaseCompleted || state.Phase == PhaseFailed { - return nil - } - state.Phase = PhaseReleased - state.Reason = "runtime acknowledged recovery release" - state.UpdatedAt = m.nowUTC() - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryReleased, - RecoveryID: state.RecoveryID, - RuntimeID: state.RuntimeID, - ThreadID: state.ThreadID, - TurnID: state.TurnID, - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AuthGeneration: state.ExpectedGeneration, - ExpectedGeneration: state.ExpectedGeneration, - Outcome: string(outcome), - Reason: state.Reason, - }); err != nil { - return err - } - m.states[recoveryID] = state - return nil -} - -func (m *Manager) markUncertain(recoveryID string, reason error) error { - if reason == nil { - reason = errors.New("recovery release acknowledgement is uncertain") - } - m.mu.Lock() - defer m.mu.Unlock() - state, ok := m.states[recoveryID] - if !ok { - return fmt.Errorf("%w: %s", ErrRecoveryNotFound, recoveryID) - } - state.Phase = PhaseReleaseRequested - state.Reason = safeFailureReason(reason.Error()) - state.UpdatedAt = m.nowUTC() - if err := m.appendLocked(journal.Event{ - Type: journal.RecoveryUncertain, - RecoveryID: state.RecoveryID, - RuntimeID: state.RuntimeID, - ThreadID: state.ThreadID, - TurnID: state.TurnID, - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AuthGeneration: state.ExpectedGeneration, - ExpectedGeneration: state.ExpectedGeneration, - Outcome: "uncertain", - Reason: state.Reason, - }); err != nil { - return err - } - m.states[recoveryID] = state - return reason -} - -// Reconcile reads runtime-owned recovery state after a controller or runtime -// restart. It imports a parked recovery not seen before, adopts observed -// started/completed phases, and retries only release intents whose verified -// transition metadata still matches the current runtime identity. -func (m *Manager) Reconcile(ctx context.Context) error { - if m == nil { - return ErrNilManager - } - if ctx == nil { - ctx = context.Background() - } - m.mu.Lock() - rt := m.runtime - m.mu.Unlock() - if rt == nil { - return ErrRuntimeUnavailable - } - remote, err := rt.GetRuntimeState(ctx) - if err != nil { - return err - } - remoteByID := make(map[string]runtime.RecoveryState, len(remote.Recoveries)) - for _, item := range remote.Recoveries { - if item.RecoveryID != "" { - remoteByID[item.RecoveryID] = item - } - } - // Import/reconcile the runtime view first. This also covers a controller - // crash before it consumed the initial recovery_parked notification. - for _, item := range remote.Recoveries { - if err := m.mergeRemote(remote.Identity.RuntimeID, item); err != nil { - return err - } - } - m.mu.Lock() - m.replayCommittedBindingsLocked() - m.mu.Unlock() - for _, state := range m.States() { - observed, exists := remoteByID[state.RecoveryID] - if exists { - switch observed.Phase { - case runtime.RecoveryStartedPhase: - if err := m.observeRemoteStarted(remote.Identity.RuntimeID, observed); err != nil { - return err - } - case runtime.RecoveryCompletedPhase: - if err := m.observeRemoteCompleted(remote.Identity.RuntimeID, observed); err != nil { - return err - } - case runtime.RecoveryReleasedPhase: - if err := m.markReleased(state.RecoveryID, runtime.RecoveryAlreadyReleased); err != nil && !errors.Is(err, ErrRecoveryNotFound) { - return err - } - } - } - current, ok := m.State(state.RecoveryID) - if !ok || current.Phase != PhaseReleaseRequested { - continue - } - // ReleaseRequested is durable evidence that the transition was already - // verified before the prior IPC call. Recheck the live identity before - // retrying so a stale controller cannot release under Account A. - if current.TargetAccountID == "" || current.ExpectedGeneration == 0 || accountID(remote.Identity.AccountID) != current.TargetAccountID || remote.Identity.AuthGeneration != current.ExpectedGeneration { - continue - } - if err := m.releaseOne(ctx, current.RecoveryID, TransitionCommit{ - TransitionID: current.TransitionID, - RuntimeID: remote.Identity.RuntimeID, - TargetAccountID: current.TargetAccountID, - ExpectedGeneration: current.ExpectedGeneration, - FinalGeneration: current.ExpectedGeneration, - Outcome: "committed", - }, rt); err != nil { - return err - } - } - return nil -} - -func (m *Manager) mergeRemote(runtimeID string, item runtime.RecoveryState) error { - m.mu.Lock() - defer m.mu.Unlock() - existing, exists := m.states[item.RecoveryID] - if !exists { - existing = State{RecoveryID: item.RecoveryID, RuntimeID: runtimeID, Phase: PhaseParked} - } - if runtimeID != "" { - existing.RuntimeID = runtimeID - } - if item.ThreadID != "" { - existing.ThreadID = item.ThreadID - } - if item.TurnID != "" { - existing.TurnID = item.TurnID - } - if item.SourceAccountID != "" { - existing.SourceAccountID = item.SourceAccountID - } - if item.TransitionID != "" { - existing.TransitionID = item.TransitionID - } - if item.TargetAccountID != "" { - existing.TargetAccountID = item.TargetAccountID - } - if item.ExpectedGeneration != 0 { - existing.ExpectedGeneration = item.ExpectedGeneration - } - remotePhase := phaseFromRuntime(item.Phase) - if !exists || phaseRank(remotePhase) >= phaseRank(existing.Phase) { - existing.Phase = remotePhase - } - existing.UpdatedAt = m.nowUTC() - if exists && stateEqual(existing, m.states[item.RecoveryID]) { - return nil - } - eventType := journal.RecoveryParked - if existing.Phase == PhaseBound { - eventType = journal.RecoveryBound - } else if existing.Phase == PhaseReleaseRequested { - eventType = journal.RecoveryReleaseRequested - } else if existing.Phase == PhaseReleased { - eventType = journal.RecoveryReleased - } else if existing.Phase == PhaseStarted { - eventType = journal.RecoveryStarted - } else if existing.Phase == PhaseCompleted { - eventType = journal.RecoveryCompleted - } - if err := m.appendLocked(journal.Event{ - Type: eventType, - RecoveryID: existing.RecoveryID, - RuntimeID: existing.RuntimeID, - ThreadID: existing.ThreadID, - TurnID: existing.TurnID, - TransitionID: existing.TransitionID, - TargetAccountID: existing.TargetAccountID, - AuthGeneration: existing.ExpectedGeneration, - ExpectedGeneration: existing.ExpectedGeneration, - Outcome: string(existing.Phase), - Reason: "runtime recovery state reconciled", - }); err != nil { - return err - } - m.states[item.RecoveryID] = existing - return nil -} - -func (m *Manager) observeRemoteStarted(runtimeID string, item runtime.RecoveryState) error { - return m.observeRemotePhase(runtimeID, item, PhaseStarted, journal.RecoveryStarted) -} - -func (m *Manager) observeRemoteCompleted(runtimeID string, item runtime.RecoveryState) error { - return m.observeRemotePhase(runtimeID, item, PhaseCompleted, journal.RecoveryCompleted) -} - -func (m *Manager) observeRemotePhase(runtimeID string, item runtime.RecoveryState, phase Phase, eventType journal.EventType) error { - m.mu.Lock() - defer m.mu.Unlock() - state, ok := m.states[item.RecoveryID] - if !ok { - return nil - } - if phaseRank(state.Phase) > phaseRank(phase) { - return nil - } - old := state - state.RuntimeID = firstNonEmpty(runtimeID, state.RuntimeID) - state.ThreadID = firstNonEmpty(item.ThreadID, state.ThreadID) - state.TurnID = firstNonEmpty(item.TurnID, state.TurnID) - state.Phase = phase - state.Reason = "runtime recovery state observed after restart" - state.UpdatedAt = m.nowUTC() - if stateEqual(old, state) { - return nil - } - if err := m.appendLocked(journal.Event{ - Type: eventType, - RecoveryID: state.RecoveryID, - RuntimeID: state.RuntimeID, - ThreadID: state.ThreadID, - TurnID: state.TurnID, - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AuthGeneration: state.ExpectedGeneration, - ExpectedGeneration: state.ExpectedGeneration, - Outcome: string(phase), - Reason: state.Reason, - }); err != nil { - return err - } - m.states[state.RecoveryID] = state - return nil -} - -func (m *Manager) replayEvent(event journal.Event) error { - if event.Type == journal.TransitionCommitted || event.Type == journal.TransitionReconciled { - if strings.EqualFold(event.Outcome, "committed") { - m.committed[event.TransitionID] = TransitionCommit{ - TransitionID: event.TransitionID, - RuntimeID: event.RuntimeID, - SourceAccountID: event.FromAccountID, - TargetAccountID: event.TargetAccountID, - ExpectedGeneration: firstNonZero(event.ExpectedGeneration, event.AuthGeneration), - FinalGeneration: event.AuthGeneration, - Outcome: "committed", - } - } - return nil - } - if !isRecoveryEvent(event.Type) { - return nil - } - if event.RecoveryID == "" { - return fmt.Errorf("%w: recovery event %s has no recovery_id", journal.ErrCorruptJournal, event.Type) - } - state := m.states[event.RecoveryID] - if state.RecoveryID == "" { - state.RecoveryID = event.RecoveryID - } - state.RuntimeID = firstNonEmpty(event.RuntimeID, state.RuntimeID) - state.ThreadID = firstNonEmpty(event.ThreadID, state.ThreadID) - state.TurnID = firstNonEmpty(event.TurnID, state.TurnID) - state.SourceAccountID = firstNonEmpty(event.AccountID, state.SourceAccountID) - state.TransitionID = firstNonEmpty(event.TransitionID, state.TransitionID) - state.TargetAccountID = firstNonEmpty(event.TargetAccountID, state.TargetAccountID) - state.ExpectedGeneration = firstNonZero(event.ExpectedGeneration, event.AuthGeneration, state.ExpectedGeneration) - switch event.Type { - case journal.RecoveryParked: - if phaseRank(state.Phase) <= phaseRank(PhaseParked) { - state.Phase = PhaseParked - } - state.Reason = "runtime parked usage-limit recovery" - case journal.RecoveryWaiting: - if phaseRank(state.Phase) <= phaseRank(PhaseWaiting) { - state.Phase = PhaseWaiting - } - state.Reason = safeWaitingReason(event.Reason) - case journal.RecoveryBound: - if phaseRank(state.Phase) <= phaseRank(PhaseBound) { - state.Phase = PhaseBound - } - state.Reason = "recovery bound to verified transition" - case journal.RecoveryReleaseRequested, journal.RecoveryUncertain: - if phaseRank(state.Phase) <= phaseRank(PhaseReleaseRequested) || state.Phase == PhaseReleaseRequested { - state.Phase = PhaseReleaseRequested - } - state.Reason = safeFailureReason(event.Reason) - case journal.RecoveryReleased: - if phaseRank(state.Phase) <= phaseRank(PhaseReleased) { - state.Phase = PhaseReleased - } - state.Reason = "runtime acknowledged recovery release" - case journal.RecoveryStarted: - if phaseRank(state.Phase) <= phaseRank(PhaseStarted) { - state.Phase = PhaseStarted - } - case journal.RecoveryCompleted: - if strings.EqualFold(event.Outcome, "failed") || strings.EqualFold(event.Outcome, "skipped") { - state.Phase = PhaseFailed - } else { - state.Phase = PhaseCompleted - } - } - state.UpdatedAt = event.At - m.states[event.RecoveryID] = state - return nil -} - -// replayCommittedBindings closes the crash window in which the transition -// coordinator durably committed an identity change but the controller had not -// yet written RecoveryBound. It only binds one unambiguous parked recovery; -// ambiguous records remain parked for an operator-visible reconciliation. -func (m *Manager) replayCommittedBindings() { - m.mu.Lock() - defer m.mu.Unlock() - m.replayCommittedBindingsLocked() -} - -func (m *Manager) replayCommittedBindingsLocked() { - for _, commit := range m.committed { - var candidates []State - for _, state := range m.states { - if isTerminal(state.Phase) || state.TransitionID != "" || state.TargetAccountID != "" { - continue - } - if commit.RuntimeID != "" && state.RuntimeID != "" && state.RuntimeID != commit.RuntimeID { - continue - } - if commit.SourceAccountID != "" && state.SourceAccountID != "" && state.SourceAccountID != commit.SourceAccountID { - continue - } - candidates = append(candidates, state) - } - if len(candidates) != 1 { - continue - } - state := candidates[0] - state.RuntimeID = firstNonEmpty(commit.RuntimeID, state.RuntimeID) - state.TransitionID = commit.TransitionID - state.TargetAccountID = commit.TargetAccountID - state.ExpectedGeneration = commit.ExpectedGeneration - state.Phase = PhaseBound - state.Reason = "recovery bound to replayed committed transition" - state.UpdatedAt = m.nowUTC() - m.states[state.RecoveryID] = state - } -} - -func (m *Manager) appendLocked(event journal.Event) error { - if m.journal == nil { - return nil - } - if event.At.IsZero() { - event.At = m.nowUTC() - } - return m.journal.Append(event) -} - -func (m *Manager) nowUTC() time.Time { - if m == nil || m.now == nil { - return time.Now().UTC() - } - return m.now().UTC() -} - -func phaseRank(phase Phase) int { - switch phase { - case PhaseParked: - return 1 - case PhaseWaiting: - return 2 - case PhaseBound: - return 3 - case PhaseReleaseRequested: - return 4 - case PhaseReleased: - return 5 - case PhaseStarted: - return 6 - case PhaseCompleted, PhaseFailed: - return 7 - default: - return 0 - } -} - -func isTerminal(phase Phase) bool { - return phase == PhaseStarted || phase == PhaseCompleted || phase == PhaseFailed || phase == PhaseReleased -} - -func phaseFromRuntime(phase runtime.RecoveryPhase) Phase { - switch phase { - case runtime.RecoveryReleasedPhase: - return PhaseReleased - case runtime.RecoveryStartedPhase: - return PhaseStarted - case runtime.RecoveryCompletedPhase: - return PhaseCompleted - default: - return PhaseParked - } -} - -func isRecoveryEvent(eventType journal.EventType) bool { - switch eventType { - case journal.RecoveryParked, journal.RecoveryWaiting, journal.RecoveryBound, - journal.RecoveryReleaseRequested, journal.RecoveryReleased, - journal.RecoveryStarted, journal.RecoveryCompleted, journal.RecoveryUncertain: - return true - default: - return false - } -} - -func stateEqual(left, right State) bool { - return left.RecoveryID == right.RecoveryID && left.RuntimeID == right.RuntimeID && left.ThreadID == right.ThreadID && left.TurnID == right.TurnID && left.SourceAccountID == right.SourceAccountID && left.TransitionID == right.TransitionID && left.TargetAccountID == right.TargetAccountID && left.ExpectedGeneration == right.ExpectedGeneration && left.Phase == right.Phase && left.Reason == right.Reason -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} - -func firstNonZero(values ...uint64) uint64 { - for _, value := range values { - if value != 0 { - return value - } - } - return 0 -} - -func accountID(value *string) string { - if value == nil { - return "" - } - return *value -} - -func safeWaitingReason(reason string) string { - if strings.TrimSpace(reason) == "" { - return "all accounts exhausted; recovery remains parked" - } - lower := strings.ToLower(reason) - for _, marker := range []string{"token", "prompt", "auth.json", "bearer", "secret", "password", "api_key"} { - if strings.Contains(lower, marker) { - return "all accounts exhausted; recovery remains parked" - } - } - if len(reason) > 160 || strings.ContainsAny(reason, "\r\n\x00") { - return "all accounts exhausted; recovery remains parked" - } - return reason -} - -func safeFailureReason(reason string) string { - if strings.TrimSpace(reason) == "" { - return "recovery release acknowledgement is uncertain" - } - return safeWaitingReason(reason) -} diff --git a/controller/internal/recovery/manager_test.go b/controller/internal/recovery/manager_test.go deleted file mode 100644 index 4906cb20..00000000 --- a/controller/internal/recovery/manager_test.go +++ /dev/null @@ -1,316 +0,0 @@ -package recovery - -import ( - "context" - "encoding/json" - "errors" - "sync" - "testing" - "time" - - "codexmarathon/controller/internal/journal" - "codexmarathon/controller/internal/runtime" -) - -type memoryJournal struct { - mu sync.Mutex - events []journal.Event -} - -func (j *memoryJournal) Append(event journal.Event) error { - if event.At.IsZero() { - event.At = time.Unix(1, 0).UTC() - } - if err := journal.ValidateEvent(event); err != nil { - return err - } - j.mu.Lock() - j.events = append(j.events, event) - j.mu.Unlock() - return nil -} - -func (j *memoryJournal) ReadAll() ([]journal.Event, error) { - j.mu.Lock() - defer j.mu.Unlock() - return append([]journal.Event(nil), j.events...), nil -} - -type fakeRuntime struct { - mu sync.Mutex - identity runtime.Identity - recoveries []runtime.RecoveryState - releaseCalls []runtime.RecoveryReleaseParams - release runtime.RecoveryReleaseResult - releaseErr error - applyBeforeErr bool -} - -func (f *fakeRuntime) GetRuntimeState(context.Context) (runtime.RuntimeState, error) { - f.mu.Lock() - defer f.mu.Unlock() - return runtime.RuntimeState{ - Identity: f.identity, - Recoveries: append([]runtime.RecoveryState(nil), f.recoveries...), - PendingTransition: nil, - }, nil -} - -func (f *fakeRuntime) ReleaseRecovery(_ context.Context, params runtime.RecoveryReleaseParams) (runtime.RecoveryReleaseResult, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.releaseCalls = append(f.releaseCalls, params) - if f.applyBeforeErr { - f.recoveries = []runtime.RecoveryState{{ - RecoveryID: params.RecoveryID, - ThreadID: params.ThreadID, - TransitionID: params.TransitionID, - TargetAccountID: "acct-b", - ExpectedGeneration: params.ExpectedGeneration, - Phase: runtime.RecoveryReleasedPhase, - }} - } - if f.releaseErr != nil { - return runtime.RecoveryReleaseResult{}, f.releaseErr - } - result := f.release - if result.RecoveryID == "" { - result.RecoveryID = params.RecoveryID - } - if result.ThreadID == "" { - result.ThreadID = params.ThreadID - } - return result, nil -} - -func (f *fakeRuntime) calls() []runtime.RecoveryReleaseParams { - f.mu.Lock() - defer f.mu.Unlock() - return append([]runtime.RecoveryReleaseParams(nil), f.releaseCalls...) -} - -func parkedEvent(t *testing.T, id, thread, turn, source, runtimeID string, generation uint64) runtime.Event { - t.Helper() - data, err := json.Marshal(runtime.RecoveryParkedEvent{ - RecoveryID: id, - ThreadID: thread, - TurnID: turn, - SourceAccountID: source, - }) - if err != nil { - t.Fatal(err) - } - return runtime.Event{ - EventBase: runtime.EventBase{ - EventType: runtime.EventRecoveryParked, - OccurredAt: 1, - RuntimeID: runtimeID, - AuthGeneration: generation, - }, - Payload: data, - } -} - -func startedEvent(t *testing.T, id, thread, turn, runtimeID string, generation uint64) runtime.Event { - t.Helper() - data, err := json.Marshal(runtime.RecoveryStartedEvent{RecoveryID: id, ThreadID: thread, TurnID: turn}) - if err != nil { - t.Fatal(err) - } - return runtime.Event{EventBase: runtime.EventBase{ - EventType: runtime.EventRecoveryStarted, OccurredAt: 2, RuntimeID: runtimeID, AuthGeneration: generation, - }, Payload: data} -} - -func newTestManager(t *testing.T, rt Runtime) (*Manager, *memoryJournal) { - t.Helper() - j := &memoryJournal{} - m, err := New(Config{Journal: j, Runtime: rt, Now: func() time.Time { return time.Unix(100, 0).UTC() }}) - if err != nil { - t.Fatal(err) - } - return m, j -} - -func bindForTest(t *testing.T, m *Manager) { - t.Helper() - if err := m.Bind(Binding{ - RecoveryID: "rec-1", RuntimeID: "runtime-a", ThreadID: "thread-1", TurnID: "turn-1", - TransitionID: "tx-1", TargetAccountID: "acct-b", ExpectedGeneration: 2, - }); err != nil { - t.Fatal(err) - } -} - -func commitForTest() TransitionCommit { - return TransitionCommit{ - TransitionID: "tx-1", RuntimeID: "runtime-a", SourceAccountID: "acct-a", TargetAccountID: "acct-b", - ExpectedGeneration: 2, FinalGeneration: 2, Outcome: "committed", - } -} - -func TestManagerResumesSameThreadExactlyOnceAndPreservesQueueOwner(t *testing.T) { - rt := &fakeRuntime{ - identity: runtime.Identity{RuntimeID: "runtime-a", AuthGeneration: 2, AccountID: stringPtr("acct-b")}, - release: runtime.RecoveryReleaseResult{Outcome: runtime.RecoveryReleased}, - } - m, journalLog := newTestManager(t, rt) - if err := m.HandleEvent(parkedEvent(t, "rec-1", "thread-1", "turn-1", "acct-a", "runtime-a", 1)); err != nil { - t.Fatal(err) - } - bindForTest(t, m) - if err := m.OnTransitionCommitted(context.Background(), commitForTest()); err != nil { - t.Fatal(err) - } - if err := m.OnTransitionCommitted(context.Background(), commitForTest()); err != nil { - t.Fatal(err) - } - state, ok := m.State("rec-1") - if !ok || state.Phase != PhaseReleased { - t.Fatalf("state = %#v, ok=%v, want released", state, ok) - } - calls := rt.calls() - if len(calls) != 1 { - t.Fatalf("release calls = %d, want exactly one", len(calls)) - } - if calls[0].ThreadID != "thread-1" || calls[0].RecoveryID != "rec-1" { - t.Fatalf("release params = %#v, want same thread/recovery", calls[0]) - } - // Runtime-owned input ordering is represented by started after release; - // the controller never submits a second user message or synthetic prompt. - if err := m.HandleEvent(startedEvent(t, "rec-1", "thread-1", "turn-2", "runtime-a", 2)); err != nil { - t.Fatal(err) - } - state, _ = m.State("rec-1") - if state.Phase != PhaseStarted { - t.Fatalf("post-start phase = %s, want started", state.Phase) - } - for _, event := range journalLog.events { - if event.Reason != "" && (event.Reason == "resume" || event.Reason == "prompt") { - t.Fatalf("journal contains a synthetic prompt reason: %#v", event) - } - } -} - -func TestManagerKeepsRecoveryParkedWhilePoolExhausted(t *testing.T) { - rt := &fakeRuntime{ - identity: runtime.Identity{RuntimeID: "runtime-a", AuthGeneration: 1, AccountID: stringPtr("acct-a")}, - release: runtime.RecoveryReleaseResult{Outcome: runtime.RecoveryReleased}, - } - m, _ := newTestManager(t, rt) - if err := m.HandleEvent(parkedEvent(t, "rec-1", "thread-1", "turn-1", "acct-a", "runtime-a", 1)); err != nil { - t.Fatal(err) - } - if err := m.MarkWaiting("rec-1", "all accounts exhausted until earliest reset"); err != nil { - t.Fatal(err) - } - if calls := rt.calls(); len(calls) != 0 { - t.Fatalf("release calls while waiting = %d, want 0", len(calls)) - } - state, _ := m.State("rec-1") - if state.Phase != PhaseWaiting { - t.Fatalf("waiting phase = %s, want %s", state.Phase, PhaseWaiting) - } - bindForTest(t, m) - if err := m.OnTransitionCommitted(context.Background(), commitForTest()); err != nil { - t.Fatal(err) - } - if calls := rt.calls(); len(calls) != 1 { - t.Fatalf("release calls after verified switch = %d, want 1", len(calls)) - } -} - -func TestManagerReconcilesLostReleaseAcknowledgementWithoutDuplicate(t *testing.T) { - rt := &fakeRuntime{ - identity: runtime.Identity{RuntimeID: "runtime-a", AuthGeneration: 2, AccountID: stringPtr("acct-b")}, - applyBeforeErr: true, - releaseErr: errors.New("ack lost after native release"), - } - m, _ := newTestManager(t, rt) - if err := m.HandleEvent(parkedEvent(t, "rec-1", "thread-1", "turn-1", "acct-a", "runtime-a", 1)); err != nil { - t.Fatal(err) - } - bindForTest(t, m) - if err := m.OnTransitionCommitted(context.Background(), commitForTest()); err == nil { - t.Fatal("OnTransitionCommitted() error = nil, want lost acknowledgement") - } - state, _ := m.State("rec-1") - if state.Phase != PhaseReleaseRequested { - t.Fatalf("lost-ack phase = %s, want release_requested", state.Phase) - } - // The runtime state says it applied the release. Reconcile adopts that - // result instead of calling the native queue a second time. - if err := m.Reconcile(context.Background()); err != nil { - t.Fatal(err) - } - state, _ = m.State("rec-1") - if state.Phase != PhaseReleased { - t.Fatalf("reconciled phase = %s, want released", state.Phase) - } - if calls := rt.calls(); len(calls) != 1 { - t.Fatalf("release calls after lost ack = %d, want 1", len(calls)) - } -} - -func TestManagerReplaysReleaseIntentAcrossControllerCrash(t *testing.T) { - j := &memoryJournal{} - first, err := New(Config{Journal: j, Now: func() time.Time { return time.Unix(100, 0).UTC() }}) - if err != nil { - t.Fatal(err) - } - if err := first.HandleEvent(parkedEvent(t, "rec-1", "thread-1", "turn-1", "acct-a", "runtime-a", 1)); err != nil { - t.Fatal(err) - } - bindForTest(t, first) - if err := first.OnTransitionCommitted(context.Background(), commitForTest()); !errors.Is(err, ErrRuntimeUnavailable) { - t.Fatalf("detached commit error = %v, want ErrRuntimeUnavailable", err) - } - state, _ := first.State("rec-1") - if state.Phase != PhaseReleaseRequested { - t.Fatalf("pre-crash phase = %s, want release_requested", state.Phase) - } - secondRuntime := &fakeRuntime{ - identity: runtime.Identity{RuntimeID: "runtime-after-restart", AuthGeneration: 2, AccountID: stringPtr("acct-b")}, - recoveries: []runtime.RecoveryState{{ - RecoveryID: "rec-1", ThreadID: "thread-1", TransitionID: "tx-1", TargetAccountID: "acct-b", - ExpectedGeneration: 2, Phase: runtime.RecoveryParkedPhase, - }}, - release: runtime.RecoveryReleaseResult{Outcome: runtime.RecoveryAlreadyReleased}, - } - second, err := New(Config{Journal: j, Runtime: secondRuntime, Now: func() time.Time { return time.Unix(200, 0).UTC() }}) - if err != nil { - t.Fatal(err) - } - if err := second.Reconcile(context.Background()); err != nil { - t.Fatal(err) - } - state, _ = second.State("rec-1") - if state.Phase != PhaseReleased { - t.Fatalf("post-crash phase = %s, want released", state.Phase) - } - if calls := secondRuntime.calls(); len(calls) != 1 { - t.Fatalf("post-crash release calls = %d, want 1", len(calls)) - } - if calls := secondRuntime.calls(); calls[0].ExpectedGeneration != 2 || calls[0].TransitionID != "tx-1" { - t.Fatalf("post-crash params = %#v, want persisted transition metadata", calls[0]) - } -} - -func TestManagerRejectsMismatchedThreadBeforeRelease(t *testing.T) { - rt := &fakeRuntime{ - identity: runtime.Identity{RuntimeID: "runtime-a", AuthGeneration: 2, AccountID: stringPtr("acct-b")}, - release: runtime.RecoveryReleaseResult{Outcome: runtime.RecoveryReleased}, - } - m, _ := newTestManager(t, rt) - if err := m.HandleEvent(parkedEvent(t, "rec-1", "thread-1", "turn-1", "acct-a", "runtime-a", 1)); err != nil { - t.Fatal(err) - } - if err := m.Bind(Binding{RecoveryID: "rec-1", RuntimeID: "runtime-a", ThreadID: "thread-2", TransitionID: "tx-1", TargetAccountID: "acct-b", ExpectedGeneration: 2}); !errors.Is(err, ErrRecoveryConflict) { - t.Fatalf("Bind() error = %v, want ErrRecoveryConflict", err) - } - if calls := rt.calls(); len(calls) != 0 { - t.Fatalf("release calls after mismatch = %d, want 0", len(calls)) - } -} - -func stringPtr(value string) *string { return &value } diff --git a/controller/internal/reset/scheduler.go b/controller/internal/reset/scheduler.go deleted file mode 100644 index b3d4094d..00000000 --- a/controller/internal/reset/scheduler.go +++ /dev/null @@ -1,473 +0,0 @@ -package reset - -import ( - "context" - "errors" - "sort" - "sync" - "time" - - "codexmarathon/controller/internal/telemetry" -) - -type resetCandidate struct { - accountID string - limitID string - kind telemetry.WindowKind - resetAt time.Time - duration int -} - -const ( - defaultDataBackoff = 5 * time.Second - defaultMaxDataBackoff = 2 * time.Minute - defaultWaitSlice = 30 * time.Second -) - -// SchedulerConfig controls waiting when the pool has no trustworthy future -// reset. Reset timestamps themselves remain authoritative and are never -// shortened; the data backoff is only a bounded retry hint for missing or -// stale observations. -type SchedulerConfig struct { - DataBackoff time.Duration - MaxDataBackoff time.Duration - WaitSlice time.Duration -} - -// Scheduler chooses the earliest trustworthy future reset and records when -// the controller should re-observe the pool. It is safe for a policy loop and -// timer loop to call its methods concurrently. -type Scheduler struct { - mu sync.Mutex - now func() time.Time - config SchedulerConfig - next time.Time - hasNext bool - dataNext time.Time - hasDataNext bool - dataBackoff time.Duration - wait ResetWaitState - state ResetState -} - -// NewScheduler creates a reset scheduler. An optional clock is used by -// Evaluate when its now argument is zero and by wait-state entry timestamps. -func NewScheduler(clock ...func() time.Time) *Scheduler { - return NewSchedulerWithConfig(SchedulerConfig{}, clock...) -} - -// NewResetScheduler is a descriptive alias for NewScheduler. -func NewResetScheduler(clock ...func() time.Time) *Scheduler { return NewScheduler(clock...) } - -// NewSchedulerWithConfig constructs a scheduler with explicit bounded data -// retry settings. A zero value selects conservative defaults. -func NewSchedulerWithConfig(config SchedulerConfig, clock ...func() time.Time) *Scheduler { - if config.DataBackoff <= 0 { - config.DataBackoff = defaultDataBackoff - } - if config.MaxDataBackoff <= 0 { - config.MaxDataBackoff = defaultMaxDataBackoff - } - if config.MaxDataBackoff < config.DataBackoff { - config.MaxDataBackoff = config.DataBackoff - } - if config.WaitSlice <= 0 { - config.WaitSlice = defaultWaitSlice - } - return &Scheduler{ - now: selectClock(clock...), - config: config, - dataBackoff: config.DataBackoff, - state: ResetReady, - } -} - -// Evaluate inspects all currently observed accounts. It never treats an -// elapsed reset timestamp as proof of availability: such windows are marked -// stale on the local copy and the decision requests fresh telemetry. -func (s *Scheduler) Evaluate(accounts []telemetry.AccountTelemetry, now time.Time) ResetDecision { - if s == nil { - return ResetDecision{ - State: ResetWaitForData, - PoolExhausted: true, - RefetchRequired: true, - Reason: "reset scheduler is nil", - } - } - if now.IsZero() { - now = s.currentTime() - } - - // Work from independent copies so evaluating a decision cannot alter the - // caller's snapshots or accidentally clear its reset metadata. - clones := make([]telemetry.AccountTelemetry, len(accounts)) - for i, account := range accounts { - clones[i] = account.Clone() - clones[i].RefreshStaleness(now) - } - sort.SliceStable(clones, func(i, j int) bool { return clones[i].AccountID < clones[j].AccountID }) - - for _, account := range clones { - if account.HasFreshCapacity(now) { - s.clearWithState(ResetReady) - return ResetDecision{State: ResetReady, Reason: "eligible account has fresh capacity"} - } - } - - var candidates []resetCandidate - refreshIDs := make(map[string]struct{}) - usableData := false - for _, account := range clones { - if !account.IsUsable { - if account.AccountID != "" { - refreshIDs[account.AccountID] = struct{}{} - } - continue - } - windowsForAccount := account.WindowList() - if len(windowsForAccount) == 0 { - if account.AccountID != "" { - refreshIDs[account.AccountID] = struct{}{} - } - continue - } - usableData = true - for _, limit := range account.LimitList() { - windows := limit.WindowList() - for _, window := range windows { - if window.IsStaleAt(now) { - // Any stale window invalidates the observation. Ask the provider - // to refresh rather than declaring this account ready. A stale - // marker may be explicit even when no reset timestamp is present - // (or when the timestamp is still in the future), so do not limit - // refresh targeting to elapsed reset metadata. - if account.AccountID != "" { - refreshIDs[account.AccountID] = struct{}{} - } - continue - } - if !window.IsExhausted() { - continue - } - if !window.HasFutureReset(now) { - if account.AccountID != "" { - refreshIDs[account.AccountID] = struct{}{} - } - continue - } - duration := 0 - if window.WindowDurationMins != nil { - duration = safeDuration(*window.WindowDurationMins) - } - candidates = append(candidates, resetCandidate{ - accountID: account.AccountID, - limitID: limit.LimitID, - kind: window.Kind, - resetAt: *window.ResetsAt, - duration: duration, - }) - } - } - } - - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].resetAt != candidates[j].resetAt { - return candidates[i].resetAt.Before(candidates[j].resetAt) - } - if candidates[i].accountID != candidates[j].accountID { - return candidates[i].accountID < candidates[j].accountID - } - if candidates[i].limitID != candidates[j].limitID { - return candidates[i].limitID < candidates[j].limitID - } - return candidates[i].kind < candidates[j].kind - }) - - refreshAccountIDs := sortedKeys(refreshIDs) - if len(candidates) > 0 { - candidate := candidates[0] - wait := ResetWaitState{ - CandidateAccountID: candidate.accountID, - LimitID: candidate.limitID, - WindowDurationMins: candidate.duration, - ExpectedResetAt: candidate.resetAt, - EnteredAt: now, - } - s.mu.Lock() - s.state = ResetWaitForReset - s.wait = wait - s.next = candidate.resetAt - s.hasNext = true - s.dataNext = time.Time{} - s.hasDataNext = false - s.dataBackoff = s.config.DataBackoff - s.mu.Unlock() - // The account whose boundary selected this wake-up is always included - // in post-reset revalidation. Other stale accounts are included below. - refreshAccountIDs = appendUnique(refreshAccountIDs, candidate.accountID) - sort.Strings(refreshAccountIDs) - return ResetDecision{ - State: ResetWaitForReset, - PoolExhausted: true, - CandidateAccountID: candidate.accountID, - LimitID: candidate.limitID, - DurationMins: candidate.duration, - ResetAt: candidate.resetAt, - RefetchRequired: len(refreshAccountIDs) > 0, - RefreshAccountIDs: refreshAccountIDs, - Reason: "all eligible accounts are exhausted; waiting for earliest server reset", - } - } - - // No future reset can be trusted. This includes expired reset metadata and - // accounts for which the provider did not supply a timestamp. - s.scheduleDataWake(now) - return ResetDecision{ - State: ResetWaitForData, - PoolExhausted: true, - RefetchRequired: len(refreshAccountIDs) > 0 || usableData, - RefreshAccountIDs: refreshAccountIDs, - Reason: "all eligible accounts are unavailable without a trustworthy future reset", - } -} - -// NextRecheck returns the earliest future reset selected by Evaluate. -func (s *Scheduler) NextRecheck() (time.Time, bool) { - if s == nil { - return time.Time{}, false - } - s.mu.Lock() - next, ok := s.next, s.hasNext - s.mu.Unlock() - return next, ok -} - -// NextDataRecheck returns the bounded wake-up used when telemetry is missing, -// stale, or ambiguous and no authoritative future reset is available. It is -// intentionally separate from NextRecheck so existing callers that interpret -// NextRecheck as a server reset remain correct. -func (s *Scheduler) NextDataRecheck() (time.Time, bool) { - if s == nil { - return time.Time{}, false - } - s.mu.Lock() - next, ok := s.dataNext, s.hasDataNext - s.mu.Unlock() - return next, ok -} - -// NextWake returns the earliest reset or data-refresh wake-up. It is a timer -// hint only; callers must revalidate telemetry before selecting an account. -func (s *Scheduler) NextWake() (time.Time, bool) { - if s == nil { - return time.Time{}, false - } - s.mu.Lock() - var next time.Time - var ok bool - if s.hasNext { - next, ok = s.next, true - } - if s.hasDataNext && (!ok || s.dataNext.Before(next)) { - next, ok = s.dataNext, true - } - s.mu.Unlock() - return next, ok -} - -// Wait sleeps until the next bounded wake-up. It is cancellation-aware and -// uses WaitSlice for long server reset durations, which keeps shutdown and -// reconnect responsive without busy polling. The caller should reevaluate -// after every return, including a WaitSlice return that precedes a reset. -func (s *Scheduler) Wait(ctx context.Context) error { - if s == nil { - return errors.New("nil reset scheduler") - } - if ctx == nil { - ctx = context.Background() - } - next, ok := s.NextWake() - if !ok { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(s.waitSlice()): - return nil - } - } - delay := time.Until(next) - if delay < 0 { - delay = 0 - } - if slice := s.waitSlice(); delay > slice { - delay = slice - } - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -// Due reports whether a scheduled boundary has been reached. -func (s *Scheduler) Due(now time.Time) bool { - if s == nil { - return false - } - if now.IsZero() { - now = s.currentTime() - } - next, ok := s.NextRecheck() - return ok && !next.After(now) -} - -// WaitState returns a copy of the current advisory waiting metadata. -func (s *Scheduler) WaitState() (ResetWaitState, bool) { - if s == nil { - return ResetWaitState{}, false - } - s.mu.Lock() - wait, ok := s.wait, s.state == ResetWaitForReset - s.mu.Unlock() - return wait, ok -} - -// State reports the last scheduler state. -func (s *Scheduler) State() ResetState { - if s == nil { - return ResetWaitForData - } - s.mu.Lock() - state := s.state - s.mu.Unlock() - return state -} - -// RevalidationRequest returns the account(s) that must be refreshed once the -// selected reset boundary has elapsed. It does not mark them usable. -func (s *Scheduler) RevalidationRequest(accounts []telemetry.AccountTelemetry, now time.Time) RevalidationRequest { - if s == nil { - return RevalidationRequest{} - } - if now.IsZero() { - now = s.currentTime() - } - ids := make(map[string]struct{}) - for _, account := range accounts { - clone := account.Clone() - clone.RefreshStaleness(now) - for _, window := range clone.WindowList() { - if window.ResetsAt != nil && !window.ResetsAt.After(now) { - ids[clone.AccountID] = struct{}{} - break - } - } - } - return RevalidationRequest{AccountIDs: sortedKeys(ids), DueAt: now} -} - -// Clear forgets any scheduled reset and returns the scheduler to READY. It -// does not alter caller-owned telemetry. -func (s *Scheduler) Clear() { - if s == nil { - return - } - s.clearWithState(ResetReady) -} - -func (s *Scheduler) clearWithState(state ResetState) { - s.mu.Lock() - s.state = state - s.next = time.Time{} - s.hasNext = false - s.dataNext = time.Time{} - s.hasDataNext = false - s.dataBackoff = s.config.DataBackoff - s.wait = ResetWaitState{} - s.mu.Unlock() -} - -func (s *Scheduler) scheduleDataWake(now time.Time) { - s.mu.Lock() - backoff := s.dataBackoff - if backoff <= 0 { - backoff = s.config.DataBackoff - } - if backoff <= 0 { - backoff = defaultDataBackoff - } - maxBackoff := s.config.MaxDataBackoff - if maxBackoff <= 0 { - maxBackoff = defaultMaxDataBackoff - } - s.state = ResetWaitForData - s.next = time.Time{} - s.hasNext = false - s.dataNext = now.Add(backoff) - s.hasDataNext = true - nextBackoff := backoff * 2 - if nextBackoff < backoff || nextBackoff > maxBackoff { - nextBackoff = maxBackoff - } - s.dataBackoff = nextBackoff - s.wait = ResetWaitState{} - s.mu.Unlock() -} - -func (s *Scheduler) waitSlice() time.Duration { - if s == nil || s.config.WaitSlice <= 0 { - return defaultWaitSlice - } - return s.config.WaitSlice -} - -func appendUnique(values []string, value string) []string { - if value == "" { - return values - } - for _, existing := range values { - if existing == value { - return values - } - } - return append(values, value) -} - -func safeDuration(value int64) int { - if value <= 0 { - return 0 - } - maxInt := int64(^uint(0) >> 1) - if value > maxInt { - return int(maxInt) - } - return int(value) -} - -func sortedKeys(values map[string]struct{}) []string { - keys := make([]string, 0, len(values)) - for key := range values { - if key != "" { - keys = append(keys, key) - } - } - sort.Strings(keys) - return keys -} - -func selectClock(clocks ...func() time.Time) func() time.Time { - if len(clocks) > 0 && clocks[0] != nil { - return clocks[0] - } - return func() time.Time { return time.Now().UTC() } -} - -func (s *Scheduler) currentTime() time.Time { - if s == nil || s.now == nil { - return time.Now().UTC() - } - return s.now() -} diff --git a/controller/internal/reset/scheduler_test.go b/controller/internal/reset/scheduler_test.go deleted file mode 100644 index f6ae7dd5..00000000 --- a/controller/internal/reset/scheduler_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package reset - -import ( - "testing" - "time" - - "codexmarathon/controller/internal/telemetry" -) - -func TestSchedulerChoosesEarliestTrustworthyReset(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - first := now.Add(15 * time.Minute) - second := now.Add(35 * time.Minute) - scheduler := NewScheduler(func() time.Time { return now }) - - decision := scheduler.Evaluate([]telemetry.AccountTelemetry{ - exhaustedAccount("account-b", "codex", second, now), - exhaustedAccount("account-a", "codex", first, now), - }, now) - if decision.State != ResetWaitForReset || !decision.PoolExhausted { - t.Fatalf("expected pool wait, got %#v", decision) - } - if decision.CandidateAccountID != "account-a" || !decision.ResetAt.Equal(first) { - t.Fatalf("wrong earliest candidate: %#v", decision) - } - next, ok := scheduler.NextRecheck() - if !ok || !next.Equal(first) { - t.Fatalf("wrong next recheck: %v, %v", next, ok) - } -} - -func TestElapsedResetRequiresFreshTelemetry(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 31, 0, 0, time.UTC) - resetAt := now.Add(-time.Minute) - scheduler := NewScheduler(func() time.Time { return now }) - decision := scheduler.Evaluate([]telemetry.AccountTelemetry{ - exhaustedAccount("account-a", "codex", resetAt, now), - }, now) - if decision.State != ResetWaitForData { - t.Fatalf("elapsed reset should wait for data, got %#v", decision) - } - if !decision.RefetchRequired { - t.Fatal("elapsed reset did not require revalidation") - } - if len(decision.RefreshAccountIDs) != 1 || decision.RefreshAccountIDs[0] != "account-a" { - t.Fatalf("wrong revalidation accounts: %#v", decision.RefreshAccountIDs) - } - if _, ok := scheduler.NextRecheck(); ok { - t.Fatal("elapsed reset left a stale scheduled wake-up") - } -} - -func TestMissingResetDoesNotSynthesizeFutureTime(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - scheduler := NewScheduler(func() time.Time { return now }) - account := telemetry.AccountTelemetry{ - AccountID: "account-a", - IsUsable: true, - Limits: map[string]telemetry.LimitTelemetry{ - "codex": {LimitID: "codex", Windows: []telemetry.WindowTelemetry{ - {Kind: telemetry.PrimaryWindow, UsedPercent: 100, Freshness: telemetry.WindowFresh}, - }}, - }, - } - decision := scheduler.Evaluate([]telemetry.AccountTelemetry{account}, now) - if decision.State != ResetWaitForData || !decision.RefetchRequired { - t.Fatalf("missing reset metadata should wait for data, got %#v", decision) - } - if !decision.ResetAt.IsZero() { - t.Fatalf("scheduler synthesized a reset time: %v", decision.ResetAt) - } -} - -func TestExplicitlyStaleWindowRequestsAccountRefresh(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - scheduler := NewScheduler(func() time.Time { return now }) - account := telemetry.AccountTelemetry{ - AccountID: "account-a", - IsUsable: true, - Limits: map[string]telemetry.LimitTelemetry{ - "codex": {LimitID: "codex", Windows: []telemetry.WindowTelemetry{ - {Kind: telemetry.PrimaryWindow, UsedPercent: 20, Freshness: telemetry.WindowStale}, - }}, - }, - } - decision := scheduler.Evaluate([]telemetry.AccountTelemetry{account}, now) - if decision.State != ResetWaitForData || !decision.RefetchRequired { - t.Fatalf("stale window should wait for data, got %#v", decision) - } - if len(decision.RefreshAccountIDs) != 1 || decision.RefreshAccountIDs[0] != "account-a" { - t.Fatalf("stale window refresh targets = %#v, want [account-a]", decision.RefreshAccountIDs) - } -} - -func TestSchedulerRevalidationRequestNeverMarksAccountReady(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 31, 0, 0, time.UTC) - resetAt := now.Add(-time.Minute) - scheduler := NewScheduler(func() time.Time { return now }) - account := exhaustedAccount("account-a", "codex", resetAt, now) - request := scheduler.RevalidationRequest([]telemetry.AccountTelemetry{account}, now) - if len(request.AccountIDs) != 1 || request.AccountIDs[0] != "account-a" { - t.Fatalf("unexpected revalidation request: %#v", request) - } - // The old observation remains exhausted and stale until a provider returns - // a new full snapshot; RevalidationRequest is data-only by design. - if account.Limits["codex"].Windows[0].UsedPercent != 100 { - t.Fatal("revalidation request mutated caller telemetry") - } -} - -func TestSchedulerSchedulesBoundedDataWakeWithoutInventingReset(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - scheduler := NewSchedulerWithConfig(SchedulerConfig{DataBackoff: 10 * time.Second, MaxDataBackoff: 20 * time.Second, WaitSlice: time.Second}, func() time.Time { return now }) - decision := scheduler.Evaluate([]telemetry.AccountTelemetry{{AccountID: "account-a", IsUsable: false}}, now) - if decision.State != ResetWaitForData || !decision.RefetchRequired { - t.Fatalf("unexpected data wait: %#v", decision) - } - if !decision.ResetAt.IsZero() { - t.Fatalf("data wait synthesized reset: %v", decision.ResetAt) - } - wake, ok := scheduler.NextDataRecheck() - if !ok || !wake.Equal(now.Add(10*time.Second)) { - t.Fatalf("data wake = %v, %v", wake, ok) - } -} - -func TestSchedulerIncludesEarliestResetAccountInRevalidation(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - resetAt := now.Add(time.Hour) - scheduler := NewScheduler(func() time.Time { return now }) - decision := scheduler.Evaluate([]telemetry.AccountTelemetry{exhaustedAccount("account-a", "codex", resetAt, now)}, now) - if len(decision.RefreshAccountIDs) != 1 || decision.RefreshAccountIDs[0] != "account-a" { - t.Fatalf("reset candidate missing mandatory refresh: %#v", decision) - } -} - -func exhaustedAccount(accountID, limitID string, resetAt, observedAt time.Time) telemetry.AccountTelemetry { - window := telemetry.WindowTelemetry{ - Kind: telemetry.PrimaryWindow, - UsedPercent: 100, - ResetsAt: &resetAt, - ObservedAt: observedAt, - Freshness: telemetry.WindowFresh, - } - return telemetry.AccountTelemetry{ - AccountID: accountID, - IsUsable: true, - ObservedAt: observedAt, - Limits: map[string]telemetry.LimitTelemetry{ - limitID: {LimitID: limitID, Windows: []telemetry.WindowTelemetry{window}}, - }, - } -} diff --git a/controller/internal/reset/state.go b/controller/internal/reset/state.go deleted file mode 100644 index 34850746..00000000 --- a/controller/internal/reset/state.go +++ /dev/null @@ -1,106 +0,0 @@ -// Package reset owns pool-exhaustion waiting and reset-boundary -// revalidation. It does not deploy credentials or reload the runtime. -package reset - -import ( - "context" - "time" - - "codexmarathon/controller/internal/telemetry" -) - -// ResetState is the scheduler's coarse state. WAIT_FOR_RESET and -// WAIT_FOR_DATA are substates of POOL_EXHAUSTED; PoolExhausted is also set on -// decisions in either waiting state for callers that only need the coarse -// signal. -type ResetState uint8 - -const ( - ResetReady ResetState = iota - ResetPoolExhausted - ResetWaitForReset - ResetWaitForData -) - -const ( - StateReady = ResetReady - StatePoolExhausted = ResetPoolExhausted - StateWaitForReset = ResetWaitForReset - StateWaitForData = ResetWaitForData -) - -func (s ResetState) String() string { - switch s { - case ResetReady: - return "READY" - case ResetPoolExhausted: - return "POOL_EXHAUSTED" - case ResetWaitForReset: - return "WAIT_FOR_RESET" - case ResetWaitForData: - return "WAIT_FOR_DATA" - default: - return "UNKNOWN" - } -} - -// ResetWaitState is advisory metadata describing the last authoritative reset -// timestamp observed by the server. Reaching ExpectedResetAt never proves -// capacity; callers must request fresh telemetry first. -type ResetWaitState struct { - CandidateAccountID string - LimitID string - WindowDurationMins int - ExpectedResetAt time.Time - EnteredAt time.Time -} - -// ResetDecision is returned by Scheduler.Evaluate. -type ResetDecision struct { - State ResetState - PoolExhausted bool - CandidateAccountID string - LimitID string - DurationMins int - ResetAt time.Time - RefetchRequired bool - RefreshAccountIDs []string - Reason string -} - -// IsWaiting reports whether the decision asks the controller to pause normal -// account selection. -func (d ResetDecision) IsWaiting() bool { - return d.State == ResetWaitForReset || d.State == ResetWaitForData || d.State == ResetPoolExhausted -} - -// HasResetCandidate reports whether a future server-provided reset was found. -func (d ResetDecision) HasResetCandidate() bool { - return !d.ResetAt.IsZero() && d.CandidateAccountID != "" -} - -// RevalidationRequest identifies accounts that must be refreshed after a -// reset boundary. It is intentionally data-only; the provider/router owns -// the actual network request. -type RevalidationRequest struct { - AccountIDs []string - DueAt time.Time -} - -// ResetScheduler is the narrow interface consumed by policy code. -type ResetScheduler interface { - Evaluate(accounts []telemetry.AccountTelemetry, now time.Time) ResetDecision - NextRecheck() (time.Time, bool) - Clear() -} - -// Waiter is the optional timer surface implemented by Scheduler. Keeping it -// separate from ResetScheduler preserves compatibility with small test -// doubles and lets policy depend only on decision-making. -type Waiter interface { - // NextWake returns either the selected authoritative reset boundary or the - // bounded data-refresh wake-up. It never implies that quota is restored. - NextWake() (time.Time, bool) - // Wait blocks until the next bounded wake-up or context cancellation. - Wait(context.Context) error -} diff --git a/controller/internal/runtime/client.go b/controller/internal/runtime/client.go deleted file mode 100644 index 726e73cd..00000000 --- a/controller/internal/runtime/client.go +++ /dev/null @@ -1,648 +0,0 @@ -package runtime - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "strconv" - "strings" - "sync" - "sync/atomic" -) - -const ( - defaultEventBuffer = 32 - defaultErrorBuffer = 4 - defaultMaxLineBytes = 4 << 20 -) - -var ( - // ErrClientClosed is returned when a call cannot be sent because the - // transport has already been closed. - ErrClientClosed = errors.New("runtime client is closed") - // ErrInvalidMessage is reported for malformed or unsupported wire messages. - ErrInvalidMessage = errors.New("invalid runtime protocol message") -) - -// RPCError is the JSON-RPC error object returned by the runtime. -type RPCError struct { - Code int `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data,omitempty"` -} - -func (e *RPCError) Error() string { - if e == nil { - return "runtime RPC error" - } - if e.Message == "" { - return fmt.Sprintf("runtime RPC error (code %d)", e.Code) - } - return fmt.Sprintf("runtime RPC error (code %d): %s", e.Code, e.Message) -} - -// ClientOption configures a Client. -type ClientOption func(*clientOptions) - -type clientOptions struct { - eventBuffer int - errorBuffer int - maxLineBytes int -} - -// WithEventBuffer controls the number of decoded events buffered before the -// reader waits for the controller to consume them. -func WithEventBuffer(size int) ClientOption { - return func(options *clientOptions) { - if size > 0 { - options.eventBuffer = size - } - } -} - -// WithMaxLineBytes bounds one newline-delimited JSON message. -func WithMaxLineBytes(size int) ClientOption { - return func(options *clientOptions) { - if size > 0 { - options.maxLineBytes = size - } - } -} - -// WithErrorBuffer controls the number of asynchronous protocol errors kept -// for callers that inspect Errors. -func WithErrorBuffer(size int) ClientOption { - return func(options *clientOptions) { - if size > 0 { - options.errorBuffer = size - } - } -} - -// RequestID is the client-generated JSON-RPC identifier. It is a string on -// the wire, while responses with integer IDs are also accepted for peers that -// use the broader JSON-RPC identifier shape. -type RequestID string - -type requestEnvelope struct { - JSONRPC string `json:"jsonrpc"` - ID RequestID `json:"id"` - Method Method `json:"method"` - Params any `json:"params,omitempty"` -} - -type notificationEnvelope struct { - JSONRPC string `json:"jsonrpc"` - Method Method `json:"method"` - Params any `json:"params"` -} - -type rawEnvelope struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Method string `json:"method"` - Params json.RawMessage `json:"params"` - Result json.RawMessage `json:"result"` - Error *RPCError `json:"error"` -} - -type callResponse struct { - result json.RawMessage - err error -} - -// Client is a concurrent, newline-delimited JSON-RPC client for a Marathon -// runtime adapter. The transport must support both reads and writes (for -// example a Unix socket, named pipe, or net.Pipe in tests). -type Client struct { - conn io.ReadWriteCloser - - writeMu sync.Mutex - stateMu sync.Mutex - channelMu sync.Mutex - closed bool - pending map[RequestID]chan callResponse - - sequence atomic.Uint64 - done chan struct{} - closeOnce sync.Once - events chan Event - errors chan error - - maxLineBytes int -} - -// NewClient starts a reader for conn. Ownership of conn transfers to Client; -// Close must be called by the owner when the session ends. -func NewClient(conn io.ReadWriteCloser, options ...ClientOption) *Client { - if conn == nil { - panic("runtime.NewClient: nil transport") - } - config := clientOptions{ - eventBuffer: defaultEventBuffer, - errorBuffer: defaultErrorBuffer, - maxLineBytes: defaultMaxLineBytes, - } - for _, option := range options { - if option != nil { - option(&config) - } - } - client := &Client{ - conn: conn, - pending: make(map[RequestID]chan callResponse), - done: make(chan struct{}), - events: make(chan Event, config.eventBuffer), - errors: make(chan error, config.errorBuffer), - maxLineBytes: config.maxLineBytes, - } - go client.readLoop() - return client -} - -// Events returns decoded runtime notifications. The channel is closed after -// the transport terminates or Close is called. -func (c *Client) Events() <-chan Event { - return c.events -} - -// Errors returns asynchronous protocol or transport errors. A call-specific -// error is delivered by Call and is not duplicated here. -func (c *Client) Errors() <-chan error { - return c.errors -} - -// Done returns a channel that is closed when the runtime transport terminates -// or the client is explicitly closed. Supervisors use this as a -// transport-liveness signal without consuming the event or error streams. -func (c *Client) Done() <-chan struct{} { - if c == nil { - return nil - } - return c.done -} - -// Closed reports whether the client has observed a transport termination. -// It is intentionally a snapshot; callers that need notification should -// select on Done instead. -func (c *Client) Closed() bool { - if c == nil { - return true - } - c.stateMu.Lock() - closed := c.closed - c.stateMu.Unlock() - return closed -} - -// Close terminates the transport and completes outstanding calls with -// ErrClientClosed. It is safe to call more than once. -func (c *Client) Close() error { - c.shutdown(ErrClientClosed) - return nil -} - -// Call sends a typed JSON-RPC request and decodes its result into out. A nil -// out is valid when the caller only needs the success/failure status. -func (c *Client) Call(ctx context.Context, method Method, params any, out any) error { - if method == "" { - return errors.New("runtime method is required") - } - if ctx == nil { - ctx = context.Background() - } - id := RequestID(strconv.FormatUint(c.sequence.Add(1), 10)) - responseCh := make(chan callResponse, 1) - - c.stateMu.Lock() - if c.closed { - c.stateMu.Unlock() - return ErrClientClosed - } - c.pending[id] = responseCh - c.stateMu.Unlock() - - request := requestEnvelope{ - JSONRPC: "2.0", - ID: id, - Method: method, - Params: params, - } - if err := c.write(request); err != nil { - c.removePending(id) - return err - } - - select { - case <-ctx.Done(): - c.removePending(id) - return ctx.Err() - case response := <-responseCh: - if response.err != nil { - return response.err - } - if out == nil || len(response.result) == 0 || string(response.result) == "null" { - return nil - } - if err := json.Unmarshal(response.result, out); err != nil { - return fmt.Errorf("decode %s result: %w", method, err) - } - return nil - } -} - -// Notify sends a JSON-RPC notification. Notifications are not correlated and -// therefore do not produce a response. -func (c *Client) Notify(ctx context.Context, method Method, params any) error { - if method == "" { - return errors.New("runtime method is required") - } - if ctx == nil { - ctx = context.Background() - } - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - return c.write(notificationEnvelope{ - JSONRPC: "2.0", - Method: method, - Params: params, - }) -} - -// NegotiateVersion selects the highest locally supported version shared with -// the runtime. A result outside the offered set is rejected as a protocol -// error instead of being silently interpreted as v1. -func (c *Client) NegotiateVersion(ctx context.Context, supported []int) (VersionNegotiationResult, error) { - params := VersionNegotiationParams{SupportedVersions: append([]int(nil), supported...)} - if err := params.Validate(); err != nil { - return VersionNegotiationResult{}, err - } - var result VersionNegotiationResult - if err := c.Call(ctx, MethodNegotiateVersion, params, &result); err != nil { - return VersionNegotiationResult{}, err - } - for _, version := range supported { - if version == result.ProtocolVersion { - return result, nil - } - } - return VersionNegotiationResult{}, fmt.Errorf("runtime selected unsupported protocol version %d", result.ProtocolVersion) -} - -// GetRuntimeState reads the state required for reconciliation. -func (c *Client) GetRuntimeState(ctx context.Context) (RuntimeState, error) { - var result RuntimeState - if err := c.Call(ctx, MethodGetRuntimeState, struct{}{}, &result); err != nil { - return RuntimeState{}, err - } - return result, nil -} - -// PrepareAuthTransition records transition intent in the runtime. -func (c *Client) PrepareAuthTransition(ctx context.Context, params AuthTransitionParams) (TransitionResult, error) { - if err := params.Validate(); err != nil { - return TransitionResult{}, err - } - var result TransitionResult - if err := c.Call(ctx, MethodPrepareAuthTransition, params, &result); err != nil { - return TransitionResult{}, err - } - return result, nil -} - -// CommitAuthTransition asks the runtime to apply a prepared transition after -// the controller has atomically deployed the target auth snapshot. -func (c *Client) CommitAuthTransition(ctx context.Context, params AuthTransitionParams) (TransitionResult, error) { - if err := params.Validate(); err != nil { - return TransitionResult{}, err - } - var result TransitionResult - if err := c.Call(ctx, MethodCommitAuthTransition, params, &result); err != nil { - return TransitionResult{}, err - } - return result, nil -} - -// CancelAuthTransition abandons a prepared transition at the expected -// generation. -func (c *Client) CancelAuthTransition(ctx context.Context, params CancelAuthTransitionParams) (TransitionResult, error) { - if err := params.Validate(); err != nil { - return TransitionResult{}, err - } - var result TransitionResult - if err := c.Call(ctx, MethodCancelAuthTransition, params, &result); err != nil { - return TransitionResult{}, err - } - return result, nil -} - -// GetIdentity returns the identity currently held by Codext's AuthManager. -func (c *Client) GetIdentity(ctx context.Context) (Identity, error) { - var result Identity - if err := c.Call(ctx, MethodGetIdentity, struct{}{}, &result); err != nil { - return Identity{}, err - } - return result, nil -} - -// GetAuthGeneration returns the runtime's monotonic identity generation. -func (c *Client) GetAuthGeneration(ctx context.Context) (AuthGenerationResult, error) { - var result AuthGenerationResult - if err := c.Call(ctx, MethodGetAuthGeneration, struct{}{}, &result); err != nil { - return AuthGenerationResult{}, err - } - return result, nil -} - -// ReadAuthSnapshot reads the active opaque snapshot held by Codex's native -// AuthManager. Callers must keep the result in memory only long enough to -// synchronize the protected account vault; it must not be logged or journalled. -func (c *Client) ReadAuthSnapshot(ctx context.Context) (NativeAuthSnapshotResult, error) { - var result NativeAuthSnapshotResult - if err := c.Call(ctx, MethodReadAuthSnapshot, struct{}{}, &result); err != nil { - return NativeAuthSnapshotResult{}, err - } - if result.AccountID == "" { - return NativeAuthSnapshotResult{}, errors.New("native auth snapshot has no account_id") - } - if err := validateRuntimeAccountID(result.AccountID); err != nil { - return NativeAuthSnapshotResult{}, err - } - if len(result.AuthJSON) == 0 || string(result.AuthJSON) == "null" || string(result.AuthJSON) == "{}" { - return NativeAuthSnapshotResult{}, errors.New("native auth snapshot has no auth_json") - } - var object map[string]json.RawMessage - if err := json.Unmarshal(result.AuthJSON, &object); err != nil || object == nil { - return NativeAuthSnapshotResult{}, errors.New("native auth snapshot auth_json is not an object") - } - return result, nil -} - -// ReleaseRecovery authorizes Codex's already-parked UsageLimitExceeded -// recovery turn after the controller has verified an identity-changing -// transition. The runtime remains the sole owner of the queued prompt, -// thread, and actual submission. The request is idempotent by recovery ID on -// a compliant runtime, so a lost response can be retried during reconciliation -// without duplicating the continuation. -func (c *Client) ReleaseRecovery(ctx context.Context, params RecoveryReleaseParams) (RecoveryReleaseResult, error) { - if err := params.Validate(); err != nil { - return RecoveryReleaseResult{}, err - } - var result RecoveryReleaseResult - if err := c.Call(ctx, MethodReleaseRecovery, params, &result); err != nil { - return RecoveryReleaseResult{}, err - } - if result.RecoveryID != params.RecoveryID { - return RecoveryReleaseResult{}, fmt.Errorf("recovery release response id %q does not match %q", result.RecoveryID, params.RecoveryID) - } - if result.Outcome == "" { - return RecoveryReleaseResult{}, errors.New("recovery release response has no outcome") - } - if result.ThreadID != "" && params.ThreadID != "" && result.ThreadID != params.ThreadID { - return RecoveryReleaseResult{}, fmt.Errorf("recovery release response thread %q does not match %q", result.ThreadID, params.ThreadID) - } - return result, nil -} - -// LoginAccount delegates browser/device/API login to the embedded Codex -// runtime. The returned auth snapshot is an opaque in-memory handoff; callers -// must persist it only in a protected vault and must not log it. -func (c *Client) LoginAccount(ctx context.Context, params NativeLoginParams) (NativeLoginResult, error) { - if params.AccountID != "" { - if err := validateRuntimeAccountID(params.AccountID); err != nil { - return NativeLoginResult{}, err - } - } - var result NativeLoginResult - if err := c.Call(ctx, MethodLoginAccount, params, &result); err != nil { - return NativeLoginResult{}, err - } - if result.AccountID == "" { - return NativeLoginResult{}, errors.New("native login response has no account_id") - } - if len(result.AuthJSON) == 0 || string(result.AuthJSON) == "null" { - return NativeLoginResult{}, errors.New("native login response has no auth_json") - } - return result, nil -} - -// RefreshAccount delegates token refresh to the embedded Codex login runtime. -// The opaque snapshot and returned tokens stay in memory until the controller -// performs validated write-back. -func (c *Client) RefreshAccount(ctx context.Context, params NativeRefreshParams) (NativeRefreshResult, error) { - if params.AccountID == "" { - return NativeRefreshResult{}, errors.New("account_id is required") - } - if len(params.AuthJSON) == 0 || string(params.AuthJSON) == "null" { - return NativeRefreshResult{}, errors.New("auth_json is required") - } - if err := validateRuntimeAccountID(params.AccountID); err != nil { - return NativeRefreshResult{}, err - } - var result NativeRefreshResult - if err := c.Call(ctx, MethodRefreshAccount, params, &result); err != nil { - return NativeRefreshResult{}, err - } - if result.AccessToken == "" && result.IDToken == "" && result.RefreshToken == "" { - return NativeRefreshResult{}, errors.New("native refresh response has no updated token fields") - } - if result.AccountID != "" && result.AccountID != params.AccountID { - return NativeRefreshResult{}, fmt.Errorf("native refresh response account_id %q does not match %q", result.AccountID, params.AccountID) - } - return result, nil -} - -func validateRuntimeAccountID(accountID string) error { - if accountID == "" || accountID == "." || accountID == ".." { - return errors.New("account_id is invalid") - } - for _, r := range accountID { - if r < 0x20 || r == 0x7f { - return errors.New("account_id contains control characters") - } - } - if strings.ContainsAny(accountID, `/\\<>:\"|?*`) { - return errors.New("account_id contains path characters") - } - return nil -} - -func (c *Client) write(message any) error { - payload, err := json.Marshal(message) - if err != nil { - return fmt.Errorf("encode runtime message: %w", err) - } - payload = append(payload, '\n') - - c.writeMu.Lock() - defer c.writeMu.Unlock() - c.stateMu.Lock() - closed := c.closed - c.stateMu.Unlock() - if closed { - return ErrClientClosed - } - if _, err := c.conn.Write(payload); err != nil { - wrapped := fmt.Errorf("write runtime message: %w", err) - c.shutdown(wrapped) - return wrapped - } - return nil -} - -func (c *Client) readLoop() { - scanner := bufio.NewScanner(c.conn) - scanner.Buffer(make([]byte, 4096), c.maxLineBytes) - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - continue - } - if err := c.handleMessage(line); err != nil { - c.reportError(err) - } - } - if err := scanner.Err(); err != nil { - c.shutdown(fmt.Errorf("read runtime message: %w", err)) - return - } - c.shutdown(io.EOF) -} - -func (c *Client) handleMessage(line []byte) error { - var envelope rawEnvelope - if err := json.Unmarshal(line, &envelope); err != nil { - return fmt.Errorf("%w: decode JSON: %v", ErrInvalidMessage, err) - } - if envelope.JSONRPC != "2.0" { - return fmt.Errorf("%w: jsonrpc must be 2.0", ErrInvalidMessage) - } - if envelope.Method != "" { - if len(envelope.ID) != 0 { - return fmt.Errorf("%w: server requests are not supported", ErrInvalidMessage) - } - if Method(envelope.Method) != EventNotificationMethod { - return fmt.Errorf("%w: unsupported notification method %q", ErrInvalidMessage, envelope.Method) - } - event, err := DecodeEvent(envelope.Params) - if err != nil { - return err - } - c.channelMu.Lock() - defer c.channelMu.Unlock() - select { - case c.events <- event: - return nil - case <-c.done: - return ErrClientClosed - } - } - if len(envelope.ID) == 0 { - return fmt.Errorf("%w: response id is missing", ErrInvalidMessage) - } - id, err := decodeRequestID(envelope.ID) - if err != nil { - return fmt.Errorf("%w: %v", ErrInvalidMessage, err) - } - if envelope.Error != nil && len(envelope.Result) > 0 { - return fmt.Errorf("%w: response has both result and error", ErrInvalidMessage) - } - if envelope.Error != nil && envelope.Error.Message == "" { - return fmt.Errorf("%w: response error message is missing", ErrInvalidMessage) - } - if envelope.Error == nil && len(envelope.Result) == 0 { - return fmt.Errorf("%w: response has neither result nor error", ErrInvalidMessage) - } - c.stateMu.Lock() - responseCh := c.pending[id] - if responseCh != nil { - delete(c.pending, id) - } - c.stateMu.Unlock() - if responseCh == nil { - return fmt.Errorf("%w: response id %q is not pending", ErrInvalidMessage, id) - } - select { - case responseCh <- callResponse{result: envelope.Result, err: responseError(envelope.Error)}: - case <-c.done: - } - return nil -} - -func responseError(err *RPCError) error { - if err == nil { - return nil - } - return err -} - -func (c *Client) reportError(err error) { - if err == nil { - return - } - c.channelMu.Lock() - defer c.channelMu.Unlock() - c.stateMu.Lock() - closed := c.closed - c.stateMu.Unlock() - if closed { - return - } - select { - case c.errors <- err: - default: - } -} - -func (c *Client) removePending(id RequestID) { - c.stateMu.Lock() - delete(c.pending, id) - c.stateMu.Unlock() -} - -func (c *Client) shutdown(cause error) { - if cause == nil { - cause = ErrClientClosed - } - c.closeOnce.Do(func() { - c.stateMu.Lock() - c.closed = true - pending := c.pending - c.pending = make(map[RequestID]chan callResponse) - c.stateMu.Unlock() - close(c.done) - _ = c.conn.Close() - for _, responseCh := range pending { - responseCh <- callResponse{err: cause} - } - c.channelMu.Lock() - close(c.events) - close(c.errors) - c.channelMu.Unlock() - }) -} - -func decodeRequestID(raw json.RawMessage) (RequestID, error) { - trimmed := raw - if len(trimmed) == 0 { - return "", errors.New("response id is empty") - } - if trimmed[0] == '"' { - var value string - if err := json.Unmarshal(trimmed, &value); err != nil || value == "" { - return "", errors.New("response id string is invalid") - } - return RequestID(value), nil - } - value, err := strconv.ParseInt(string(trimmed), 10, 64) - if err != nil { - return "", fmt.Errorf("response id must be a string or integer: %w", err) - } - return RequestID(strconv.FormatInt(value, 10)), nil -} diff --git a/controller/internal/runtime/client_test.go b/controller/internal/runtime/client_test.go deleted file mode 100644 index e34500ee..00000000 --- a/controller/internal/runtime/client_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package runtime - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "net" - "os" - "path/filepath" - "testing" - "time" -) - -func TestClientCallAndDecodeEvent(t *testing.T) { - clientConn, serverConn := net.Pipe() - client := NewClient(clientConn) - defer client.Close() - defer serverConn.Close() - - serverErr := make(chan error, 1) - go func() { - reader := bufio.NewReader(serverConn) - line, err := reader.ReadBytes('\n') - if err != nil { - serverErr <- err - return - } - var request map[string]any - if err := json.Unmarshal(line, &request); err != nil { - serverErr <- err - return - } - if request["method"] != string(MethodGetRuntimeState) { - serverErr <- errors.New("unexpected method") - return - } - id, ok := request["id"].(string) - if !ok || id == "" { - serverErr <- errors.New("request id was not a string") - return - } - response := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": RuntimeState{ - Identity: Identity{ - RuntimeID: "runtime-test", - AccountID: stringPointer("account-a"), - AuthGeneration: 7, - }, - ActiveTurnCount: 0, - PendingTransition: nil, - }, - } - if err := writeJSONLine(serverConn, response); err != nil { - serverErr <- err - return - } - event := map[string]any{ - "jsonrpc": "2.0", - "method": string(EventNotificationMethod), - "params": map[string]any{ - "event_type": string(EventIdentityChanged), - "occurred_at": 1725451200, - "runtime_id": "runtime-test", - "auth_generation": 8, - "transition_id": "tx-8", - "previous_account_id": "account-a", - "account_id": "account-b", - }, - } - serverErr <- writeJSONLine(serverConn, event) - }() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - state, err := client.GetRuntimeState(ctx) - if err != nil { - t.Fatalf("GetRuntimeState() error = %v", err) - } - if state.Identity.RuntimeID != "runtime-test" || state.Identity.AuthGeneration != 7 { - t.Fatalf("unexpected runtime state: %+v", state) - } - select { - case event := <-client.Events(): - if event.EventType != EventIdentityChanged || event.TransitionID != "tx-8" { - t.Fatalf("unexpected event: %+v", event) - } - decoded, err := event.DecodePayload() - if err != nil { - t.Fatalf("DecodePayload() error = %v", err) - } - identity, ok := decoded.(*IdentityChangedEvent) - if !ok || identity.AccountID == nil || *identity.AccountID != "account-b" { - t.Fatalf("unexpected decoded identity event: %#v", decoded) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for identity event") - } - if err := <-serverErr; err != nil { - t.Fatalf("server error = %v", err) - } -} - -func TestClientRejectsUnsupportedNegotiatedVersion(t *testing.T) { - clientConn, serverConn := net.Pipe() - client := NewClient(clientConn) - defer client.Close() - defer serverConn.Close() - - go func() { - reader := bufio.NewReader(serverConn) - line, err := reader.ReadBytes('\n') - if err != nil { - return - } - var request map[string]any - if json.Unmarshal(line, &request) != nil { - return - } - _ = writeJSONLine(serverConn, map[string]any{ - "jsonrpc": "2.0", - "id": request["id"], - "result": map[string]any{ - "protocol_version": 2, - "server_versions": []int{2}, - }, - }) - }() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if _, err := client.NegotiateVersion(ctx, []int{ProtocolVersion}); err == nil { - t.Fatal("NegotiateVersion() error = nil, want unsupported version error") - } -} - -func TestClientNativeAccountMethodsKeepOpaquePayloadsInMemory(t *testing.T) { - clientConn, serverConn := net.Pipe() - client := NewClient(clientConn) - defer client.Close() - defer serverConn.Close() - - serverErr := make(chan error, 1) - go func() { - reader := bufio.NewReader(serverConn) - for index, expectedMethod := range []string{string(MethodLoginAccount), string(MethodRefreshAccount)} { - line, err := reader.ReadBytes('\n') - if err != nil { - serverErr <- err - return - } - var request map[string]any - if err := json.Unmarshal(line, &request); err != nil { - serverErr <- err - return - } - if request["method"] != expectedMethod { - serverErr <- errors.New("unexpected native account method") - return - } - result := map[string]any{"account_id": "account-a"} - if index == 0 { - result["auth_json"] = map[string]any{"tokens": map[string]string{"account_id": "account-a"}} - } else { - result["access_token"] = "refreshed-in-memory" - } - if err := writeJSONLine(serverConn, map[string]any{ - "jsonrpc": "2.0", - "id": request["id"], - "result": result, - }); err != nil { - serverErr <- err - return - } - } - serverErr <- nil - }() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - login, err := client.LoginAccount(ctx, NativeLoginParams{Alias: "work"}) - if err != nil { - t.Fatalf("LoginAccount() error = %v", err) - } - if login.AccountID != "account-a" || len(login.AuthJSON) == 0 { - t.Fatalf("LoginAccount() = %#v, want opaque account snapshot", login) - } - refresh, err := client.RefreshAccount(ctx, NativeRefreshParams{ - AccountID: "account-a", - AuthJSON: login.AuthJSON, - }) - if err != nil { - t.Fatalf("RefreshAccount() error = %v", err) - } - if refresh.AccessToken != "refreshed-in-memory" || refresh.AccountID != "account-a" { - t.Fatalf("RefreshAccount() = %#v, want refreshed account token fields", refresh) - } - if err := <-serverErr; err != nil { - t.Fatalf("server error = %v", err) - } -} - -func TestTransitionParamsValidation(t *testing.T) { - if err := (AuthTransitionParams{}).Validate(); err == nil { - t.Fatal("empty transition params unexpectedly validated") - } - if err := (AuthTransitionParams{ - TransitionID: "tx-1", - TargetAccountID: "account-b", - ExpectedGeneration: 2, - }).Validate(); err != nil { - t.Fatalf("valid transition params rejected: %v", err) - } - if err := (VersionNegotiationParams{SupportedVersions: []int{1, 1}}).Validate(); err == nil { - t.Fatal("duplicate protocol versions unexpectedly validated") - } -} - -func TestDecodeEventPreservesUnknownFieldsAndNarrowRateShape(t *testing.T) { - raw := []byte(`{ - "event_type":"rate_limits_updated", - "occurred_at":1725451200, - "runtime_id":"runtime-test", - "auth_generation":3, - "account_id":"account-a", - "rateLimits":{"limitId":"codex","limitName":"Codex","planType":"pro","rateLimitReachedType":null,"primary":{"usedPercent":91.5,"windowDurationMins":300,"resetsAt":1725454800,"futureField":"ignored"},"secondary":null}, - "futureEventField":{"kept":true} - }`) - event, err := DecodeEvent(raw) - if err != nil { - t.Fatalf("DecodeEvent() error = %v", err) - } - if event.EventType != EventRateLimitsUpdated || len(event.Payload) == 0 { - t.Fatalf("unexpected event: %+v", event) - } - decoded, err := event.DecodePayload() - if err != nil { - t.Fatalf("DecodePayload() error = %v", err) - } - update := decoded.(*RateLimitsUpdatedEvent) - if update.RateLimits.Primary == nil || update.RateLimits.Primary.UsedPercent != 91.5 { - t.Fatalf("unexpected rate-limit update: %+v", update) - } - var payload map[string]any - if err := json.Unmarshal(event.Payload, &payload); err != nil { - t.Fatal(err) - } - if _, ok := payload["futureEventField"]; !ok { - t.Fatal("unknown event field was not preserved") - } -} - -func TestDecodeEventRequiresCommonAndTransitionCorrelation(t *testing.T) { - if _, err := DecodeEvent([]byte(`{"event_type":"runtime_ready","runtime_id":"runtime-test","auth_generation":1}`)); err == nil { - t.Fatal("DecodeEvent() accepted an event without occurred_at") - } - if _, err := DecodeEvent([]byte(`{"event_type":"identity_changed","occurred_at":1,"runtime_id":"runtime-test","auth_generation":2}`)); err == nil { - t.Fatal("DecodeEvent() accepted a transition event without transition_id") - } -} - -func TestClientRejectsResponseWithBothResultAndError(t *testing.T) { - clientConn, serverConn := net.Pipe() - client := NewClient(clientConn) - defer client.Close() - defer serverConn.Close() - - err := client.handleMessage([]byte(`{"jsonrpc":"2.0","id":"1","result":{},"error":{"code":-1,"message":"failed"}}`)) - if !errors.Is(err, ErrInvalidMessage) { - t.Fatalf("handleMessage() error = %v, want ErrInvalidMessage", err) - } - err = client.handleMessage([]byte(`{"jsonrpc":"2.0","id":"1","error":{"code":-1}}`)) - if !errors.Is(err, ErrInvalidMessage) { - t.Fatalf("handleMessage(empty error message) error = %v, want ErrInvalidMessage", err) - } -} - -func TestProtocolSchemaFilesAreValidJSON(t *testing.T) { - protocolDir := filepath.Join("..", "..", "..", "protocol") - for _, name := range []string{"protocol.json", "commands.json", "events.json"} { - raw, err := os.ReadFile(filepath.Join(protocolDir, name)) - if err != nil { - t.Fatalf("read %s: %v", name, err) - } - if !json.Valid(raw) { - t.Fatalf("%s is not valid JSON", name) - } - } -} - -func writeJSONLine(writer interface{ Write([]byte) (int, error) }, value any) error { - raw, err := json.Marshal(value) - if err != nil { - return err - } - raw = append(raw, '\n') - _, err = writer.Write(raw) - return err -} - -func stringPointer(value string) *string { - return &value -} diff --git a/controller/internal/runtime/installed.go b/controller/internal/runtime/installed.go deleted file mode 100644 index dec92925..00000000 --- a/controller/internal/runtime/installed.go +++ /dev/null @@ -1,1138 +0,0 @@ -package runtime - -// This file contains the companion-runtime boundary. CodexMarathon can be -// installed next to an existing Codex CLI; it must not start a second copy of -// the Codex runtime just to change authentication. Recent Codex releases -// expose the app-server control socket, which is a private WebSocket carrying -// the normal app-server JSON protocol. The small client below speaks that -// protocol directly so the controller does not need a Rust runtime or a -// third-party WebSocket dependency. - -import ( - "context" - "crypto/rand" - "crypto/sha1" // #nosec G505 -- SHA-1 is required by RFC 6455's accept key. - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/url" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" -) - -const ( - defaultInstalledProbeTimeout = 3 * time.Second - defaultInstalledMessageLimit = 64 << 20 - websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" -) - -var ( - // ErrInstalledRuntimeNotFound means no usable Codex executable was found. - ErrInstalledRuntimeNotFound = errors.New("installed Codex CLI was not found") - // ErrAppServerUnavailable means the CLI is installed but its app-server - // control socket is not currently serving requests. - ErrAppServerUnavailable = errors.New("installed Codex app-server is unavailable") - // ErrCapabilityUnsupported is returned when an installed runtime does not - // implement a requested app-server method. - ErrCapabilityUnsupported = errors.New("installed Codex runtime capability is unsupported") - // ErrActiveTurn prevents a caller from treating a no-op auth reload as a - // successful identity transition while a model turn is still running. - ErrActiveTurn = errors.New("Codex has an active turn") - // ErrInstalledClientClosed reports use of a disconnected app-server client. - ErrInstalledClientClosed = errors.New("installed Codex app-server client is closed") -) - -// InstalledCapability names a feature discovered on the installed Codex -// executable or confirmed by an app-server request. -type InstalledCapability string - -const ( - CapabilityAppServer InstalledCapability = "app-server" - CapabilityAppServerDaemon InstalledCapability = "app-server-daemon" - CapabilityAppServerProxy InstalledCapability = "app-server-proxy" - CapabilityControlSocket InstalledCapability = "control-socket" - CapabilityAuthReload InstalledCapability = "auth-reload" - CapabilityThreadResume InstalledCapability = "thread-resume" - CapabilityRateLimits InstalledCapability = "rate-limits" - CapabilityTurnEvents InstalledCapability = "turn-events" -) - -// Installation describes an existing Codex CLI without starting it. Paths -// and version metadata are safe to display; no credential or environment -// value is retained in this type. -type Installation struct { - Executable string `json:"executable"` - Version string `json:"version,omitempty"` - CodexHome string `json:"codex_home"` - ControlSocket string `json:"control_socket"` - Capabilities []InstalledCapability `json:"capabilities,omitempty"` -} - -// HasCapability reports whether discovery or a live probe confirmed name. -func (i Installation) HasCapability(name InstalledCapability) bool { - for _, capability := range i.Capabilities { - if capability == name { - return true - } - } - return false -} - -// ControlEndpoint returns the local WebSocket endpoint accepted by -// ConnectInstalledEndpoint. It is useful for diagnostics and avoids callers -// duplicating the CodexHome socket layout. -func (i Installation) ControlEndpoint() string { - if strings.TrimSpace(i.ControlSocket) == "" { - return "" - } - return "unix://" + filepath.ToSlash(i.ControlSocket) -} - -// DiscoveryOptions customizes installed CLI discovery. Empty fields follow -// the user's normal environment (CODEX_BIN, PATH, CODEX_HOME, and HOME). -type DiscoveryOptions struct { - Executable string - CodexHome string - Env []string - ProbeTimeout time.Duration -} - -// DiscoverInstalled locates the user's Codex CLI and determines whether it -// has the standard app-server and local-control commands. Discovery is -// side-effect free: it runs only --version/--help and never edits auth.json. -func DiscoverInstalled(ctx context.Context, options DiscoveryOptions) (Installation, error) { - if ctx == nil { - ctx = context.Background() - } - if options.ProbeTimeout <= 0 { - options.ProbeTimeout = defaultInstalledProbeTimeout - } - - executable, err := resolveCodexExecutable(options.Executable, options.Env) - if err != nil { - return Installation{}, err - } - codexHome, err := resolveCodexHome(options.CodexHome, options.Env) - if err != nil { - return Installation{}, err - } - - versionOutput, err := runInstalledCommand(ctx, options.ProbeTimeout, executable, options.Env, "--version") - if err != nil { - return Installation{}, fmt.Errorf("%w: run %q --version: %v", ErrInstalledRuntimeNotFound, executable, err) - } - version := parseCodexVersion(string(versionOutput)) - - capabilities := make([]InstalledCapability, 0, 5) - appServerHelp, appServerOK := runInstalledHelp(ctx, options, executable, "app-server", "--help") - if appServerOK || looksLikeCodexAppServerHelp(appServerHelp) { - capabilities = append(capabilities, CapabilityAppServer) - } - if daemonHelp, ok := runInstalledHelp(ctx, options, executable, "app-server", "daemon", "--help"); ok || looksLikeSubcommandHelp(daemonHelp, "daemon") { - capabilities = append(capabilities, CapabilityAppServerDaemon) - } - if proxyHelp, ok := runInstalledHelp(ctx, options, executable, "app-server", "proxy", "--help"); ok || looksLikeSubcommandHelp(proxyHelp, "proxy") { - capabilities = append(capabilities, CapabilityAppServerProxy) - } - - socket := appServerControlSocketPath(codexHome) - if fileInfo, statErr := os.Stat(socket); statErr == nil && !fileInfo.IsDir() && (fileInfo.Mode()&os.ModeSocket != 0 || os.PathSeparator == '\\') { - capabilities = append(capabilities, CapabilityControlSocket) - } - if hasCapability(capabilities, CapabilityAppServer) { - // These methods are part of the standard app-server protocol. A live - // connection downgrades them if a very old server returns method-not-found. - capabilities = append(capabilities, CapabilityAuthReload, CapabilityThreadResume, CapabilityRateLimits, CapabilityTurnEvents) - } - - return Installation{ - Executable: executable, - Version: version, - CodexHome: codexHome, - ControlSocket: socket, - Capabilities: uniqueCapabilities(capabilities), - }, nil -} - -func resolveCodexExecutable(explicit string, envs ...[]string) (string, error) { - var env []string - if len(envs) > 0 { - env = envs[0] - } - if strings.TrimSpace(explicit) == "" { - explicit = strings.TrimSpace(environmentValue(env, "CODEX_BIN")) - if explicit == "" { - explicit = strings.TrimSpace(os.Getenv("CODEX_BIN")) - } - } - if explicit == "" { - path, err := exec.LookPath("codex") - if err != nil { - return "", fmt.Errorf("%w: %v", ErrInstalledRuntimeNotFound, err) - } - return path, nil - } - path, err := exec.LookPath(explicit) - if err != nil { - // LookPath does not resolve a relative path containing a separator on - // every platform; try an absolute path after the normal lookup. - absolute, absErr := filepath.Abs(explicit) - if absErr != nil { - return "", fmt.Errorf("%w: %v", ErrInstalledRuntimeNotFound, err) - } - info, statErr := os.Stat(absolute) - if statErr != nil { - return "", fmt.Errorf("%w: %v", ErrInstalledRuntimeNotFound, err) - } - if info.IsDir() { - return "", fmt.Errorf("%w: %q is a directory", ErrInstalledRuntimeNotFound, absolute) - } - path = absolute - } - absolute, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("%w: resolve %q: %v", ErrInstalledRuntimeNotFound, path, err) - } - return filepath.Clean(absolute), nil -} - -func resolveCodexHome(explicit string, envs ...[]string) (string, error) { - var env []string - if len(envs) > 0 { - env = envs[0] - } - if strings.TrimSpace(explicit) == "" { - explicit = strings.TrimSpace(environmentValue(env, "CODEX_HOME")) - if explicit == "" { - explicit = strings.TrimSpace(os.Getenv("CODEX_HOME")) - } - } - if explicit == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve Codex home: %w", err) - } - explicit = filepath.Join(home, ".codex") - } - abs, err := filepath.Abs(explicit) - if err != nil { - return "", fmt.Errorf("resolve Codex home: %w", err) - } - return filepath.Clean(abs), nil -} - -func environmentValue(env []string, key string) string { - for index := len(env) - 1; index >= 0; index-- { - name, value, ok := strings.Cut(env[index], "=") - if ok && name == key { - return value - } - } - return "" -} - -func appServerControlSocketPath(codexHome string) string { - return filepath.Join(codexHome, "app-server-control", "app-server-control.sock") -} - -func runInstalledHelp(ctx context.Context, options DiscoveryOptions, executable string, args ...string) ([]byte, bool) { - output, err := runInstalledCommand(ctx, options.ProbeTimeout, executable, options.Env, args...) - return output, err == nil -} - -func runInstalledCommand(parent context.Context, timeout time.Duration, executable string, env []string, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(parent, timeout) - defer cancel() - command := exec.CommandContext(ctx, executable, args...) - if len(env) > 0 { - command.Env = append(os.Environ(), env...) - } - output, err := command.CombinedOutput() - if err != nil { - // Some wrappers print usage to stderr before returning a non-zero - // status. Keep that output for capability detection while still - // reporting the failed command to callers. - return output, err - } - return output, nil -} - -var codexVersionPattern = regexp.MustCompile(`(?i)(?:^|[^0-9])v?([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)`) - -func parseCodexVersion(output string) string { - match := codexVersionPattern.FindStringSubmatch(output) - if len(match) > 1 { - return match[1] - } - line := strings.TrimSpace(strings.SplitN(output, "\n", 2)[0]) - return line -} - -func looksLikeCodexAppServerHelp(output []byte) bool { - value := strings.ToLower(string(output)) - return strings.Contains(value, "app-server") || strings.Contains(value, "app server") -} - -func looksLikeSubcommandHelp(output []byte, command string) bool { - value := strings.ToLower(string(output)) - return strings.Contains(value, command) -} - -func hasCapability(capabilities []InstalledCapability, wanted InstalledCapability) bool { - for _, capability := range capabilities { - if capability == wanted { - return true - } - } - return false -} - -func uniqueCapabilities(capabilities []InstalledCapability) []InstalledCapability { - seen := make(map[InstalledCapability]struct{}, len(capabilities)) - result := make([]InstalledCapability, 0, len(capabilities)) - for _, capability := range capabilities { - if _, ok := seen[capability]; ok { - continue - } - seen[capability] = struct{}{} - result = append(result, capability) - } - return result -} - -// AppServerInitializeResult is the standard Codex app-server handshake -// response. It intentionally uses strings for paths to keep the controller -// independent of Rust's AbsolutePathBuf representation. -type AppServerInitializeResult struct { - UserAgent string `json:"userAgent"` - CodexHome string `json:"codexHome"` - PlatformFamily string `json:"platformFamily"` - PlatformOS string `json:"platformOs"` -} - -// InstalledNotification is one standard app-server notification. Params are -// retained as JSON because the companion only needs a few stable fields and -// must tolerate new Codex notification shapes. -type InstalledNotification struct { - Method string - Params json.RawMessage -} - -// InstalledClient is a serialized request client for one existing Codex -// app-server connection. Calls are serialized because the control socket is -// primarily an operator channel; notifications are still retained and -// delivered while requests wait for their response. -type InstalledClient struct { - conn net.Conn - - callMu sync.Mutex // serializes callers while preserving request order - writeMu sync.Mutex // protects WebSocket writes (requests and pong frames) - pendingMu sync.Mutex // owns pending responses consumed by the reader - stateMu sync.RWMutex - closed bool - readerStarted bool - closeOnce sync.Once - readerOnce sync.Once - notificationCloseOnce sync.Once - sequence atomic.Uint64 - - maxMessageBytes int - info AppServerInitializeResult - version string - capabilities map[InstalledCapability]bool - activeTurns int - notifications chan InstalledNotification - pending map[uint64]chan installedResponse -} - -type installedResponse struct { - result json.RawMessage - rpcError *AppServerRPCError - err error -} - -// ConnectInstalled attaches to an already-running Codex app-server control -// socket and performs the standard initialize/initialized handshake. -func ConnectInstalled(ctx context.Context, installation Installation) (*InstalledClient, error) { - if ctx == nil { - ctx = context.Background() - } - if strings.TrimSpace(installation.ControlSocket) == "" { - if installation.CodexHome == "" { - return nil, fmt.Errorf("%w: control socket path is empty", ErrAppServerUnavailable) - } - installation.ControlSocket = appServerControlSocketPath(installation.CodexHome) - } - conn, err := dialInstalledEndpoint(ctx, installation.ControlSocket) - if err != nil { - return nil, fmt.Errorf("%w at %q: %v", ErrAppServerUnavailable, installation.ControlSocket, err) - } - client, err := newInstalledClient(conn) - if err != nil { - _ = conn.Close() - return nil, err - } - if err := client.initialize(ctx); err != nil { - _ = client.Close() - return nil, fmt.Errorf("initialize installed Codex app-server: %w", err) - } - return client, nil -} - -// ConnectInstalledEndpoint is useful for tests and local operators that have -// an explicit Unix or loopback WebSocket endpoint. Network endpoints are -// restricted to loopback; a companion must never silently create an -// unauthenticated network control channel. -func ConnectInstalledEndpoint(ctx context.Context, endpoint string) (*InstalledClient, error) { - if ctx == nil { - ctx = context.Background() - } - conn, err := dialInstalledEndpoint(ctx, endpoint) - if err != nil { - return nil, err - } - client, err := newInstalledClient(conn) - if err != nil { - _ = conn.Close() - return nil, err - } - if err := client.initialize(ctx); err != nil { - _ = client.Close() - return nil, fmt.Errorf("initialize installed Codex app-server: %w", err) - } - return client, nil -} - -func dialInstalledEndpoint(ctx context.Context, endpoint string) (net.Conn, error) { - endpoint = strings.TrimSpace(endpoint) - if endpoint == "" { - return nil, fmt.Errorf("%w: endpoint is empty", ErrAppServerUnavailable) - } - if !strings.Contains(endpoint, "://") { - endpoint = "unix://" + endpoint - } - parsed, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("%w: invalid endpoint: %v", ErrAppServerUnavailable, err) - } - scheme := strings.ToLower(parsed.Scheme) - var network, address string - switch scheme { - case "unix": - if parsed.Host != "" || parsed.Path == "" { - return nil, fmt.Errorf("%w: unix endpoint must contain an absolute path", ErrAppServerUnavailable) - } - address = filepath.Clean(filepath.FromSlash(parsed.Path)) - if !filepath.IsAbs(address) { - return nil, fmt.Errorf("%w: Unix control socket path must be absolute", ErrAppServerUnavailable) - } - network = "unix" - case "ws", "http": - if parsed.User != nil || parsed.Host == "" || parsed.Path == "" && parsed.RawPath == "" { - return nil, fmt.Errorf("%w: invalid WebSocket endpoint", ErrAppServerUnavailable) - } - host, _, splitErr := net.SplitHostPort(parsed.Host) - if splitErr != nil || !isLoopbackHost(host) { - return nil, fmt.Errorf("%w: WebSocket endpoint must be loopback", ErrAppServerUnavailable) - } - if scheme == "http" { - parsed.Scheme = "ws" - } - address = parsed.Host - network = "tcp" - default: - return nil, fmt.Errorf("%w: unsupported endpoint scheme %q", ErrAppServerUnavailable, parsed.Scheme) - } - dialer := net.Dialer{} - conn, err := dialer.DialContext(ctx, network, address) - if err != nil { - return nil, err - } - if err := websocketClientHandshake(conn, endpoint); err != nil { - _ = conn.Close() - return nil, err - } - return conn, nil -} - -func isLoopbackHost(host string) bool { - if host == "localhost" { - return true - } - parsed := net.ParseIP(host) - return parsed != nil && parsed.IsLoopback() -} - -func newInstalledClient(conn net.Conn) (*InstalledClient, error) { - if conn == nil { - return nil, errors.New("installed app-server transport is nil") - } - return &InstalledClient{ - conn: conn, - maxMessageBytes: defaultInstalledMessageLimit, - capabilities: make(map[InstalledCapability]bool), - notifications: make(chan InstalledNotification, 64), - pending: make(map[uint64]chan installedResponse), - }, nil -} - -func (c *InstalledClient) initialize(ctx context.Context) error { - // ConnectInstalled calls this only after the HTTP upgrade. Keeping reader - // startup here also permits tests and local adapters to construct a client, - // perform their own upgrade, and then use the same production path. - c.startReader() - result, err := c.call(ctx, "initialize", map[string]any{ - "clientInfo": map[string]string{ - "name": "codexmarathon", - "title": "CodexMarathon companion", - "version": "0.1.0", - }, - "capabilities": map[string]any{ - "experimentalApi": true, - }, - }) - if err != nil { - return err - } - var info AppServerInitializeResult - if err := json.Unmarshal(result, &info); err != nil { - return fmt.Errorf("decode app-server initialize response: %w", err) - } - if strings.TrimSpace(info.UserAgent) == "" { - return errors.New("app-server initialize response has no userAgent") - } - c.stateMu.Lock() - c.info = info - c.version = parseCodexVersion(info.UserAgent) - c.capabilities[CapabilityAppServer] = true - c.capabilities[CapabilityAuthReload] = true - c.capabilities[CapabilityThreadResume] = true - c.capabilities[CapabilityRateLimits] = true - c.capabilities[CapabilityTurnEvents] = true - c.stateMu.Unlock() - if err := c.notify(ctx, "initialized", nil); err != nil { - return fmt.Errorf("send app-server initialized notification: %w", err) - } - return nil -} - -// Info returns the metadata from the app-server handshake. -func (c *InstalledClient) Info() AppServerInitializeResult { - if c == nil { - return AppServerInitializeResult{} - } - c.stateMu.RLock() - defer c.stateMu.RUnlock() - return c.info -} - -// Version returns the server-reported version, falling back to the user-agent -// string when an installation uses a non-semver build label. -func (c *InstalledClient) Version() string { - if c == nil { - return "" - } - c.stateMu.RLock() - defer c.stateMu.RUnlock() - return c.version -} - -// Capabilities returns a copy of the live capability view. A caller can -// safely expose it in status output without racing a method probe. -func (c *InstalledClient) Capabilities() []InstalledCapability { - if c == nil { - return nil - } - c.stateMu.RLock() - capabilities := make([]InstalledCapability, 0, len(c.capabilities)) - for capability, enabled := range c.capabilities { - if enabled { - capabilities = append(capabilities, capability) - } - } - c.stateMu.RUnlock() - sort.Slice(capabilities, func(left, right int) bool { return capabilities[left] < capabilities[right] }) - return capabilities -} - -// HasCapability reports the current live capability view. -func (c *InstalledClient) HasCapability(name InstalledCapability) bool { - if c == nil { - return false - } - c.stateMu.RLock() - value := c.capabilities[name] - c.stateMu.RUnlock() - return value -} - -// ActiveTurnCount is maintained from standard turn lifecycle notifications. -// It is a conservative guard: when no notification stream is available the -// account/read request still asks the server's own turn guard to reload safely. -func (c *InstalledClient) ActiveTurnCount() int { - if c == nil { - return 0 - } - c.stateMu.RLock() - count := c.activeTurns - c.stateMu.RUnlock() - return count -} - -// Notifications returns standard app-server notifications observed while -// servicing requests. The channel is closed by Close. -func (c *InstalledClient) Notifications() <-chan InstalledNotification { - if c == nil { - return nil - } - return c.notifications -} - -// Close disconnects from the existing app-server. It does not stop Codex or -// modify any Codex files. -func (c *InstalledClient) Close() error { - if c == nil { - return nil - } - c.closeOnce.Do(func() { - c.stateMu.Lock() - c.closed = true - readerStarted := c.readerStarted - c.stateMu.Unlock() - // Closing the underlying connection is deliberate. A peer may be - // blocked in an active request and waiting for a close frame, so a - // graceful frame here could deadlock the companion's shutdown path. - _ = c.conn.Close() - if !readerStarted { - c.notificationCloseOnce.Do(func() { close(c.notifications) }) - } - }) - return nil -} - -// ReloadAuth asks the installed app-server to reload auth.json through its -// supported account/read interface. The app-server performs its own safe-turn -// check and invalidates account-bound transport caches when auth_changed is -// true; the companion never mutates the running process's memory directly. -func (c *InstalledClient) ReloadAuth(ctx context.Context) (AccountReadResult, error) { - if c == nil { - return AccountReadResult{}, ErrInstalledClientClosed - } - if c.ActiveTurnCount() > 0 { - return AccountReadResult{}, ErrActiveTurn - } - result, err := c.call(ctx, "account/read", AccountReadParams{ReloadAuthFromStorage: true}) - if err != nil { - if isUnsupportedRPCError(err) { - c.setCapability(CapabilityAuthReload, false) - return AccountReadResult{}, fmt.Errorf("%w: account/read reloadAuthFromStorage: %v", ErrCapabilityUnsupported, err) - } - return AccountReadResult{}, err - } - var account AccountReadResult - if err := json.Unmarshal(result, &account); err != nil { - return AccountReadResult{}, fmt.Errorf("decode account/read response: %w", err) - } - c.setCapability(CapabilityAuthReload, true) - return account, nil -} - -// ReadAccount reads account metadata without causing an auth reload. -func (c *InstalledClient) ReadAccount(ctx context.Context) (AccountReadResult, error) { - result, err := c.call(ctx, "account/read", AccountReadParams{}) - if err != nil { - return AccountReadResult{}, err - } - var account AccountReadResult - if err := json.Unmarshal(result, &account); err != nil { - return AccountReadResult{}, fmt.Errorf("decode account/read response: %w", err) - } - return account, nil -} - -// ReadRateLimits obtains the standard active-account quota snapshot. It is -// returned as JSON to remain compatible with fields added by Codex releases. -func (c *InstalledClient) ReadRateLimits(ctx context.Context) (json.RawMessage, error) { - result, err := c.call(ctx, "account/rateLimits/read", nil) - if err != nil { - if isUnsupportedRPCError(err) { - c.setCapability(CapabilityRateLimits, false) - return nil, fmt.Errorf("%w: account/rateLimits/read: %v", ErrCapabilityUnsupported, err) - } - return nil, err - } - c.setCapability(CapabilityRateLimits, true) - return append(json.RawMessage(nil), result...), nil -} - -// ResumeThread asks the standard app-server to load a persisted conversation -// after a controlled Codex process restart. It never creates a new prompt. -func (c *InstalledClient) ResumeThread(ctx context.Context, threadID string) (json.RawMessage, error) { - if strings.TrimSpace(threadID) == "" { - return nil, errors.New("thread id is required") - } - result, err := c.call(ctx, "thread/resume", map[string]string{"threadId": threadID}) - if err != nil { - if isUnsupportedRPCError(err) { - c.setCapability(CapabilityThreadResume, false) - return nil, fmt.Errorf("%w: thread/resume: %v", ErrCapabilityUnsupported, err) - } - return nil, err - } - c.setCapability(CapabilityThreadResume, true) - return append(json.RawMessage(nil), result...), nil -} - -// AccountReadParams is the stable subset of standard account/read parameters -// needed by a companion CLI. -type AccountReadParams struct { - RefreshToken bool `json:"refreshToken,omitempty"` - ReloadAuthFromStorage bool `json:"reloadAuthFromStorage,omitempty"` -} - -// AccountReadResult is the stable account/read response. Account is opaque -// JSON because providers may add account fields over time. -type AccountReadResult struct { - Account json.RawMessage `json:"account"` - RequiresOpenAIAuth bool `json:"requiresOpenaiAuth"` - AuthChanged bool `json:"authChanged"` -} - -func (c *InstalledClient) setCapability(name InstalledCapability, value bool) { - c.stateMu.Lock() - c.capabilities[name] = value - c.stateMu.Unlock() -} - -func (c *InstalledClient) notify(ctx context.Context, method string, params any) error { - message := map[string]any{"method": method} - if params != nil { - message["params"] = params - } - return c.writeMessage(ctx, message) -} - -func (c *InstalledClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) { - if strings.TrimSpace(method) == "" { - return nil, errors.New("app-server method is required") - } - if ctx == nil { - ctx = context.Background() - } - c.callMu.Lock() - defer c.callMu.Unlock() - - c.stateMu.RLock() - closed := c.closed - c.stateMu.RUnlock() - if closed { - return nil, ErrInstalledClientClosed - } - id := c.sequence.Add(1) - responseCh := make(chan installedResponse, 1) - c.pendingMu.Lock() - c.pending[id] = responseCh - c.pendingMu.Unlock() - if err := c.writeMessage(ctx, map[string]any{"id": id, "method": method, "params": params}); err != nil { - c.removePending(id) - return nil, err - } - select { - case response := <-responseCh: - if response.err != nil { - return nil, response.err - } - if response.rpcError != nil { - return nil, response.rpcError - } - if len(response.result) == 0 { - return nil, errors.New("app-server response has no result") - } - return response.result, nil - case <-ctx.Done(): - c.removePending(id) - return nil, ctx.Err() - } -} - -func (c *InstalledClient) removePending(id uint64) { - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() -} - -func (c *InstalledClient) startReader() { - if c == nil { - return - } - c.readerOnce.Do(func() { - c.stateMu.Lock() - c.readerStarted = true - c.stateMu.Unlock() - go c.readLoop() - }) -} - -func (c *InstalledClient) readLoop() { - var terminalErr error - for { - frame, err := readWebsocketFrame(c.conn, c.maxMessageBytes) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { - terminalErr = fmt.Errorf("%w: %v", ErrAppServerUnavailable, err) - } else { - terminalErr = err - } - break - } - switch frame.opcode { - case websocketPing: - if err := c.writeControlFrame(websocketPong, frame.payload); err != nil { - terminalErr = fmt.Errorf("write app-server pong: %w", err) - break - } - continue - case websocketPong: - continue - case websocketClose: - terminalErr = fmt.Errorf("%w: app-server closed the control socket", ErrAppServerUnavailable) - default: - if frame.opcode != websocketText { - continue - } - if err := c.routeMessage(frame.payload); err != nil { - terminalErr = err - } - } - if terminalErr != nil { - break - } - } - c.finishReader(terminalErr) -} - -func (c *InstalledClient) routeMessage(payload []byte) error { - var envelope struct { - ID json.RawMessage `json:"id"` - Method string `json:"method"` - Result json.RawMessage `json:"result"` - Error *AppServerRPCError `json:"error"` - Params json.RawMessage `json:"params"` - } - if err := json.Unmarshal(payload, &envelope); err != nil { - return fmt.Errorf("decode app-server message: %w", err) - } - if envelope.Method != "" { - c.observeNotification(InstalledNotification{Method: envelope.Method, Params: append(json.RawMessage(nil), envelope.Params...)}) - return nil - } - id, ok := jsonIDValue(envelope.ID) - if !ok { - return nil - } - c.pendingMu.Lock() - responseCh := c.pending[id] - delete(c.pending, id) - c.pendingMu.Unlock() - if responseCh == nil { - return nil - } - responseCh <- installedResponse{ - result: append(json.RawMessage(nil), envelope.Result...), - rpcError: envelope.Error, - } - return nil -} - -func (c *InstalledClient) finishReader(err error) { - if err == nil { - err = ErrAppServerUnavailable - } - c.stateMu.Lock() - c.closed = true - c.stateMu.Unlock() - c.pendingMu.Lock() - pending := c.pending - c.pending = make(map[uint64]chan installedResponse) - c.pendingMu.Unlock() - for _, responseCh := range pending { - responseCh <- installedResponse{err: err} - } - c.notificationCloseOnce.Do(func() { close(c.notifications) }) -} - -func (c *InstalledClient) observeNotification(notification InstalledNotification) { - switch notification.Method { - case "turn/started": - c.stateMu.Lock() - c.activeTurns++ - c.stateMu.Unlock() - case "turn/completed", "turn/aborted", "turn/failed": - c.stateMu.Lock() - if c.activeTurns > 0 { - c.activeTurns-- - } - c.stateMu.Unlock() - } - select { - case c.notifications <- notification: - default: - // Notifications are advisory. Never block a response behind an unread - // stream; the active-turn counter has already been updated above. - } -} - -func (c *InstalledClient) writeMessage(ctx context.Context, value any) error { - payload, err := json.Marshal(value) - if err != nil { - return fmt.Errorf("encode app-server message: %w", err) - } - if len(payload) > c.maxMessageBytes { - return fmt.Errorf("app-server message exceeds %d bytes", c.maxMessageBytes) - } - c.writeMu.Lock() - defer c.writeMu.Unlock() - if err := c.setWriteDeadline(ctx); err != nil { - return err - } - if err := c.writeFrame(websocketText, payload); err != nil { - return fmt.Errorf("write app-server message: %w", err) - } - _ = c.conn.SetWriteDeadline(time.Time{}) - return nil -} - -func (c *InstalledClient) setWriteDeadline(ctx context.Context) error { - if ctx == nil { - return c.conn.SetWriteDeadline(time.Time{}) - } - if deadline, ok := ctx.Deadline(); ok { - return c.conn.SetWriteDeadline(deadline) - } - return c.conn.SetWriteDeadline(time.Time{}) -} - -func (c *InstalledClient) writeControlFrame(opcode byte, payload []byte) error { - c.writeMu.Lock() - defer c.writeMu.Unlock() - return c.writeFrame(opcode, payload) -} - -type AppServerRPCError struct { - Code int64 `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data,omitempty"` -} - -func (e *AppServerRPCError) Error() string { - if e == nil { - return "app-server RPC error" - } - return fmt.Sprintf("app-server RPC error (code %d): %s", e.Code, e.Message) -} - -func isUnsupportedRPCError(err error) bool { - var rpcError *AppServerRPCError - if errors.As(err, &rpcError) { - return rpcError.Code == -32601 || rpcError.Code == -32602 - } - value := strings.ToLower(err.Error()) - return strings.Contains(value, "method not found") || strings.Contains(value, "unsupported method") -} - -func jsonIDMatches(raw json.RawMessage, wanted uint64) bool { - value, ok := jsonIDValue(raw) - return ok && value == wanted -} - -func jsonIDValue(raw json.RawMessage) (uint64, bool) { - if len(raw) == 0 { - return 0, false - } - if value, err := strconv.ParseUint(string(raw), 10, 64); err == nil { - return value, true - } - var stringID string - if json.Unmarshal(raw, &stringID) == nil { - value, err := strconv.ParseUint(stringID, 10, 64) - if err == nil { - return value, true - } - } - return 0, false -} - -const ( - websocketContinuation byte = 0x0 - websocketText byte = 0x1 - websocketClose byte = 0x8 - websocketPing byte = 0x9 - websocketPong byte = 0xA -) - -type websocketFrame struct { - opcode byte - payload []byte -} - -func websocketClientHandshake(conn net.Conn, endpoint string) error { - keyBytes := make([]byte, 16) - if _, err := rand.Read(keyBytes); err != nil { - return fmt.Errorf("create WebSocket handshake key: %w", err) - } - key := base64.StdEncoding.EncodeToString(keyBytes) - host := "localhost" - if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" { - host = parsed.Host - } - request := "GET / HTTP/1.1\r\n" + - "Host: " + host + "\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n" + - "Sec-WebSocket-Key: " + key + "\r\n" + - "Sec-WebSocket-Version: 13\r\n\r\n" - if _, err := io.WriteString(conn, request); err != nil { - return fmt.Errorf("write WebSocket handshake: %w", err) - } - response, err := readHTTPHeaders(conn, 64<<10) - if err != nil { - return fmt.Errorf("read WebSocket handshake: %w", err) - } - lines := strings.Split(response, "\r\n") - if len(lines) == 0 || !strings.Contains(lines[0], " 101 ") { - return fmt.Errorf("WebSocket handshake rejected: %s", strings.TrimSpace(firstLine(response))) - } - accept := "" - for _, line := range lines[1:] { - name, value, ok := strings.Cut(line, ":") - if ok && strings.EqualFold(strings.TrimSpace(name), "Sec-WebSocket-Accept") { - accept = strings.TrimSpace(value) - break - } - } - hash := sha1.Sum([]byte(key + websocketGUID)) // #nosec G401 -- mandated by RFC 6455. - want := base64.StdEncoding.EncodeToString(hash[:]) - if accept == "" || accept != want { - return errors.New("WebSocket handshake has an invalid Sec-WebSocket-Accept") - } - return nil -} - -func readHTTPHeaders(reader io.Reader, limit int) (string, error) { - buffer := make([]byte, 0, 1024) - chunk := []byte{0} - for len(buffer) < limit { - if _, err := io.ReadFull(reader, chunk); err != nil { - return "", err - } - buffer = append(buffer, chunk[0]) - if len(buffer) >= 4 && string(buffer[len(buffer)-4:]) == "\r\n\r\n" { - return string(buffer), nil - } - } - return "", errors.New("WebSocket handshake headers exceed limit") -} - -func firstLine(value string) string { - if index := strings.Index(value, "\r\n"); index >= 0 { - return value[:index] - } - return value -} - -func (c *InstalledClient) writeFrame(opcode byte, payload []byte) error { - if len(payload) > c.maxMessageBytes { - return fmt.Errorf("WebSocket frame exceeds %d bytes", c.maxMessageBytes) - } - maskKey := make([]byte, 4) - if _, err := rand.Read(maskKey); err != nil { - return err - } - header := make([]byte, 0, 14) - header = append(header, 0x80|opcode) - switch { - case len(payload) < 126: - header = append(header, byte(0x80|len(payload))) - case len(payload) <= 0xffff: - header = append(header, 0x80|126, byte(len(payload)>>8), byte(len(payload))) - default: - header = append(header, 0x80|127) - var length [8]byte - binary.BigEndian.PutUint64(length[:], uint64(len(payload))) - header = append(header, length[:]...) - } - header = append(header, maskKey...) - masked := make([]byte, len(payload)) - for index, value := range payload { - masked[index] = value ^ maskKey[index%4] - } - if _, err := c.conn.Write(append(header, masked...)); err != nil { - return err - } - return nil -} - -func readWebsocketFrame(reader io.Reader, maxBytes int) (websocketFrame, error) { - var header [2]byte - if _, err := io.ReadFull(reader, header[:]); err != nil { - return websocketFrame{}, err - } - fin := header[0]&0x80 != 0 - opcode := header[0] & 0x0f - if !fin && opcode != websocketContinuation { - return websocketFrame{}, errors.New("fragmented WebSocket control/data frame is unsupported") - } - masked := header[1]&0x80 != 0 - length := int64(header[1] & 0x7f) - if length == 126 { - var extended [2]byte - if _, err := io.ReadFull(reader, extended[:]); err != nil { - return websocketFrame{}, err - } - length = int64(binary.BigEndian.Uint16(extended[:])) - } else if length == 127 { - var extended [8]byte - if _, err := io.ReadFull(reader, extended[:]); err != nil { - return websocketFrame{}, err - } - value := binary.BigEndian.Uint64(extended[:]) - if value > uint64(maxBytes) { - return websocketFrame{}, fmt.Errorf("WebSocket frame exceeds %d bytes", maxBytes) - } - length = int64(value) - } - if length < 0 || length > int64(maxBytes) { - return websocketFrame{}, fmt.Errorf("WebSocket frame exceeds %d bytes", maxBytes) - } - var mask [4]byte - if masked { - if _, err := io.ReadFull(reader, mask[:]); err != nil { - return websocketFrame{}, err - } - } - payload := make([]byte, int(length)) - if _, err := io.ReadFull(reader, payload); err != nil { - return websocketFrame{}, err - } - if masked { - for index := range payload { - payload[index] ^= mask[index%4] - } - } - if opcode == websocketContinuation { - return websocketFrame{}, errors.New("fragmented WebSocket messages are unsupported") - } - return websocketFrame{opcode: opcode, payload: payload}, nil -} diff --git a/controller/internal/runtime/installed_test.go b/controller/internal/runtime/installed_test.go deleted file mode 100644 index b324f0fb..00000000 --- a/controller/internal/runtime/installed_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package runtime - -import ( - "bufio" - "context" - "crypto/sha1" // #nosec G505 -- test verifies RFC 6455 handshake behavior. - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" -) - -func TestParseCodexVersion(t *testing.T) { - for _, test := range []struct { - name string - output string - want string - }{ - {name: "cli", output: "codex-cli 0.48.0\n", want: "0.48.0"}, - {name: "user agent", output: "codex_app_server/1.2.3 (Linux aarch64)", want: "1.2.3"}, - {name: "development label", output: "local-build\n", want: "local-build"}, - } { - t.Run(test.name, func(t *testing.T) { - if got := parseCodexVersion(test.output); got != test.want { - t.Fatalf("parseCodexVersion(%q) = %q, want %q", test.output, got, test.want) - } - }) - } -} - -func TestResolveCodexHomeUsesExplicitPath(t *testing.T) { - path, err := resolveCodexHome("./codex-home") - if err != nil { - t.Fatal(err) - } - want, err := filepath.Abs("./codex-home") - if err != nil { - t.Fatal(err) - } - if path != filepath.Clean(want) { - t.Fatalf("resolveCodexHome() = %q, want %q", path, want) - } -} - -func TestDiscoverInstalledRunsOnlyReadOnlyCommands(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell fixture uses a POSIX executable") - } - dir := t.TempDir() - logPath := filepath.Join(dir, "commands.log") - bin := filepath.Join(dir, "codex") - script := fmt.Sprintf(`#!/bin/sh -printf '%%s\n' "$*" >> %q -case "$*" in - --version) printf 'codex 9.8.7\n' ;; - 'app-server --help') printf 'app-server\n' ;; - 'app-server daemon --help') printf 'daemon\n' ;; - 'app-server proxy --help') printf 'proxy\n' ;; - *) exit 2 ;; -esac -`, logPath) - if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - installation, err := DiscoverInstalled(context.Background(), DiscoveryOptions{ - Executable: bin, - CodexHome: filepath.Join(dir, ".codex"), - ProbeTimeout: time.Second, - }) - if err != nil { - t.Fatal(err) - } - if installation.Version != "9.8.7" { - t.Fatalf("installation version = %q", installation.Version) - } - for _, capability := range []InstalledCapability{ - CapabilityAppServer, - CapabilityAppServerDaemon, - CapabilityAppServerProxy, - CapabilityAuthReload, - CapabilityThreadResume, - } { - if !installation.HasCapability(capability) { - t.Fatalf("discovery omitted capability %q: %#v", capability, installation.Capabilities) - } - } - log, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(log), "auth") || strings.Contains(string(log), "login") { - t.Fatalf("discovery ran a mutating command: %s", log) - } -} - -func TestInstalledClientReloadsAuthThroughStandardAccountRead(t *testing.T) { - clientConn, serverConn := net.Pipe() - client, err := newInstalledClient(clientConn) - if err != nil { - t.Fatal(err) - } - defer client.Close() - defer serverConn.Close() - - go func() { - _ = serveFakeAppServer(serverConn, func(request map[string]json.RawMessage) (map[string]any, bool) { - var method string - if raw := request["method"]; len(raw) > 0 { - _ = json.Unmarshal(raw, &method) - } - id := request["id"] - switch method { - case "initialize": - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{ - "userAgent": "codex_app_server/4.5.6 (Linux aarch64)", - "codexHome": "/tmp/codex", - "platformFamily": "unix", - "platformOs": "linux", - }}, true - case "account/read": - var params AccountReadParams - if err := json.Unmarshal(request["params"], ¶ms); err != nil { - return map[string]any{"id": json.RawMessage(id), "error": map[string]any{"code": -32602, "message": err.Error()}}, true - } - if !params.ReloadAuthFromStorage { - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{ - "account": nil, "requiresOpenaiAuth": true, "authChanged": false, - }}, true - } - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{ - "account": map[string]any{"type": "chatgpt", "email": "operator@example.test"}, - "requiresOpenaiAuth": true, - "authChanged": true, - }}, true - default: - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{}}, true - } - }) - }() - if err := websocketClientHandshake(clientConn, ""); err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := client.initialize(ctx); err != nil { - t.Fatal(err) - } - if client.Version() != "4.5.6" || !client.HasCapability(CapabilityAuthReload) { - t.Fatalf("handshake metadata missing: version=%q authReload=%t", client.Version(), client.HasCapability(CapabilityAuthReload)) - } - result, err := client.ReloadAuth(ctx) - if err != nil { - t.Fatalf("ReloadAuth() error = %v", err) - } - if !result.AuthChanged || !json.Valid(result.Account) { - t.Fatalf("ReloadAuth() = %#v", result) - } -} - -func TestInstalledClientRejectsReloadDuringObservedTurn(t *testing.T) { - clientConn, serverConn := net.Pipe() - client, err := newInstalledClient(clientConn) - if err != nil { - t.Fatal(err) - } - defer client.Close() - defer serverConn.Close() - - go func() { - _ = serveFakeAppServer(serverConn, func(request map[string]json.RawMessage) (map[string]any, bool) { - var method string - _ = json.Unmarshal(request["method"], &method) - id := request["id"] - if method == "initialize" { - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{"userAgent": "codex/1.0.0"}}, true - } - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{}}, true - }) - }() - if err := websocketClientHandshake(clientConn, ""); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := client.initialize(ctx); err != nil { - t.Fatal(err) - } - // Injecting the standard notification through the same decoder path is - // equivalent to an app-server turn/started notification and avoids a - // second test server just for this guard. - client.observeNotification(InstalledNotification{Method: "turn/started"}) - if client.ActiveTurnCount() != 1 { - t.Fatalf("ActiveTurnCount() = %d, want 1", client.ActiveTurnCount()) - } - if _, err := client.ReloadAuth(ctx); !errors.Is(err, ErrActiveTurn) { - t.Fatalf("ReloadAuth() error = %v, want ErrActiveTurn", err) - } -} - -func TestInstalledClientMarksUnsupportedMethods(t *testing.T) { - clientConn, serverConn := net.Pipe() - client, err := newInstalledClient(clientConn) - if err != nil { - t.Fatal(err) - } - defer client.Close() - defer serverConn.Close() - - go func() { - _ = serveFakeAppServer(serverConn, func(request map[string]json.RawMessage) (map[string]any, bool) { - var method string - _ = json.Unmarshal(request["method"], &method) - id := request["id"] - if method == "initialize" { - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{"userAgent": "codex/1.0.0"}}, true - } - return map[string]any{"id": json.RawMessage(id), "error": map[string]any{"code": -32601, "message": "method not found"}}, true - }) - }() - if err := websocketClientHandshake(clientConn, ""); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := client.initialize(ctx); err != nil { - t.Fatal(err) - } - if _, err := client.ResumeThread(ctx, "thread-1"); !errors.Is(err, ErrCapabilityUnsupported) { - t.Fatalf("ResumeThread() error = %v, want ErrCapabilityUnsupported", err) - } - if client.HasCapability(CapabilityThreadResume) { - t.Fatal("thread resume capability remained enabled after method-not-found") - } -} - -func TestInstalledClientReceivesAsynchronousTurnNotifications(t *testing.T) { - clientConn, serverConn := net.Pipe() - client, err := newInstalledClient(clientConn) - if err != nil { - t.Fatal(err) - } - serverDone := make(chan struct{}) - defer func() { - // Close the peer first so the fake server cannot remain blocked in its - // read loop while the test returns. - _ = serverConn.Close() - _ = client.Close() - <-serverDone - }() - - go func() { - defer close(serverDone) - _ = serveFakeAppServer(serverConn, func(request map[string]json.RawMessage) (map[string]any, bool) { - var method string - _ = json.Unmarshal(request["method"], &method) - id := request["id"] - if method == "initialize" { - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{"userAgent": "codex/1.0.0"}}, true - } - return map[string]any{"id": json.RawMessage(id), "result": map[string]any{}}, true - }) - }() - if err := websocketClientHandshake(clientConn, ""); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := client.initialize(ctx); err != nil { - t.Fatal(err) - } - - started := []byte(`{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}}`) - if err := writeServerFrame(serverConn, websocketText, started); err != nil { - t.Fatal(err) - } - select { - case notification := <-client.Notifications(): - if notification.Method != "turn/started" { - t.Fatalf("first notification method = %q", notification.Method) - } - case <-ctx.Done(): - t.Fatalf("waiting for turn/started notification: %v", ctx.Err()) - } - deadline := time.Now().Add(time.Second) - for client.ActiveTurnCount() != 1 && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - if got := client.ActiveTurnCount(); got != 1 { - t.Fatalf("ActiveTurnCount() after turn/started = %d, want 1", got) - } - completed := []byte(`{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}}`) - if err := writeServerFrame(serverConn, websocketText, completed); err != nil { - t.Fatal(err) - } - select { - case notification := <-client.Notifications(): - if notification.Method != "turn/completed" { - t.Fatalf("second notification method = %q", notification.Method) - } - case <-ctx.Done(): - t.Fatalf("waiting for turn/completed notification: %v", ctx.Err()) - } - deadline = time.Now().Add(time.Second) - for client.ActiveTurnCount() != 0 && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - if got := client.ActiveTurnCount(); got != 0 { - t.Fatalf("ActiveTurnCount() after turn/completed = %d, want 0", got) - } -} - -// serveFakeAppServer implements enough RFC 6455 to exercise the client over -// net.Pipe. It deliberately validates that requests are masked by the client. -func serveFakeAppServer(conn net.Conn, respond func(map[string]json.RawMessage) (map[string]any, bool)) error { - requestHeaders, err := readHTTPHeaders(bufio.NewReader(conn), 64<<10) - if err != nil { - return err - } - key := "" - for _, line := range strings.Split(requestHeaders, "\r\n") { - name, value, ok := strings.Cut(line, ":") - if ok && strings.EqualFold(strings.TrimSpace(name), "Sec-WebSocket-Key") { - key = strings.TrimSpace(value) - } - } - if key == "" { - return errors.New("fake server received no WebSocket key") - } - hash := sha1.Sum([]byte(key + websocketGUID)) // #nosec G401 -- mandated by RFC 6455. - response := "HTTP/1.1 101 Switching Protocols\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n" + - "Sec-WebSocket-Accept: " + base64.StdEncoding.EncodeToString(hash[:]) + "\r\n\r\n" - if _, err := io.WriteString(conn, response); err != nil { - return err - } - for { - frame, err := readWebsocketFrame(conn, defaultInstalledMessageLimit) - if err != nil { - return err - } - if frame.opcode == websocketClose { - return nil - } - if frame.opcode != websocketText { - continue - } - var request map[string]json.RawMessage - if err := json.Unmarshal(frame.payload, &request); err != nil { - return err - } - // Notifications, including the required initialized handshake - // message, do not receive a response. - if len(request["id"]) == 0 || string(request["id"]) == "null" { - continue - } - if response, ok := respond(request); ok { - payload, err := json.Marshal(response) - if err != nil { - return err - } - if err := writeServerFrame(conn, websocketText, payload); err != nil { - return err - } - } - } -} - -func writeServerFrame(conn io.Writer, opcode byte, payload []byte) error { - header := []byte{0x80 | opcode} - switch { - case len(payload) < 126: - header = append(header, byte(len(payload))) - case len(payload) <= 0xffff: - header = append(header, 126, byte(len(payload)>>8), byte(len(payload))) - default: - header = append(header, 127) - var length [8]byte - binary.BigEndian.PutUint64(length[:], uint64(len(payload))) - header = append(header, length[:]...) - } - _, err := conn.Write(append(header, payload...)) - return err -} diff --git a/controller/internal/runtime/wire.go b/controller/internal/runtime/wire.go deleted file mode 100644 index 32417a63..00000000 --- a/controller/internal/runtime/wire.go +++ /dev/null @@ -1,594 +0,0 @@ -// Package runtime contains the typed controller-side boundary for a -// CodexMarathon runtime adapter. The package intentionally models the small -// Marathon contract, not Codext's internal AuthManager or recovery machinery. -package runtime - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" -) - -// ProtocolVersion is the currently supported Marathon wire version. -const ProtocolVersion = 1 - -// ProtocolName is the JSON-RPC notification namespace owned by Marathon. -const ProtocolName = "codexmarathon" - -// Method is a namespaced JSON-RPC method name. -type Method string - -const ( - // MethodNegotiateVersion selects one protocol version for a connection. - MethodNegotiateVersion Method = "protocol/negotiate" - // MethodGetRuntimeState reads identity and the authoritative turn count. - MethodGetRuntimeState Method = "runtime/state/read" - // MethodPrepareAuthTransition records intent before the controller deploys auth. - MethodPrepareAuthTransition Method = "auth/transition/prepare" - // MethodCommitAuthTransition asks Codext to reload the deployed auth at a safe boundary. - MethodCommitAuthTransition Method = "auth/transition/commit" - // MethodCancelAuthTransition abandons a pending transition. - MethodCancelAuthTransition Method = "auth/transition/cancel" - // MethodGetIdentity reads the runtime's observed identity. - MethodGetIdentity Method = "runtime/identity/read" - // MethodGetAuthGeneration reads the runtime's monotonic auth generation. - MethodGetAuthGeneration Method = "runtime/authGeneration/read" - // MethodLoginAccount starts a native Codex login flow in the embedded - // runtime and returns the resulting opaque auth snapshot in memory. The - // controller persists it only in its protected credential vault. - MethodLoginAccount Method = "account/login" - // MethodRefreshAccount asks the embedded runtime's native AuthManager/login - // crate to refresh one opaque account snapshot. - MethodRefreshAccount Method = "account/refresh" - // MethodReadAuthSnapshot reads the active opaque AuthManager snapshot so - // the controller can synchronize refreshed Account A credentials before a - // live switch deploys Account B. - MethodReadAuthSnapshot Method = "account/authSnapshot/read" - // MethodReleaseRecovery releases one recovery turn that Codex parked after - // UsageLimitExceeded. The runtime owns the queued prompt and the - // conversation; the controller only authorizes its already-correlated - // dispatch after an identity-changing transition is verified. - MethodReleaseRecovery Method = "recovery/release" -) - -// EventNotificationMethod is the JSON-RPC method used for runtime events. -const EventNotificationMethod Method = "codexmarathon/event" - -// EventType identifies an event in an event notification's params object. -type EventType string - -const ( - EventRuntimeReady EventType = "runtime_ready" - EventRateLimitsSnapshot EventType = "rate_limits_snapshot" - EventRateLimitsUpdated EventType = "rate_limits_updated" - EventTurnStarted EventType = "turn_started" - EventTurnCompleted EventType = "turn_completed" - EventSafeBoundaryReached EventType = "safe_boundary_reached" - EventAuthReloadStarted EventType = "auth_reload_started" - EventAuthReloadSucceeded EventType = "auth_reload_succeeded" - EventAuthReloadFailed EventType = "auth_reload_failed" - EventIdentityChanged EventType = "identity_changed" - EventRecoveryParked EventType = "recovery_parked" - EventRecoveryStarted EventType = "recovery_started" - EventRecoveryCompleted EventType = "recovery_completed" -) - -// TransitionOutcome describes the result observed by the runtime. -type TransitionOutcome string - -const ( - TransitionCommitted TransitionOutcome = "committed" - TransitionRejected TransitionOutcome = "rejected" - TransitionUncertain TransitionOutcome = "uncertain" -) - -// Identity is the runtime-observed account identity and generation. -// -// AccountID is nullable because a runtime may be unauthenticated or may not -// yet have a backend identity. RuntimeID and AuthGeneration remain present in -// that state so controller reconciliation can still identify the runtime. -type Identity struct { - RuntimeID string `json:"runtime_id"` - AccountID *string `json:"account_id"` - AuthGeneration uint64 `json:"auth_generation"` -} - -// PendingTransition is the runtime's prepared but not yet committed intent. -type PendingTransition struct { - TransitionID string `json:"transition_id"` - TargetAccountID string `json:"target_account_id"` - ExpectedGeneration uint64 `json:"expected_generation"` -} - -// RuntimeState is the minimum state needed to coordinate a safe transition. -type RuntimeState struct { - Identity Identity `json:"identity"` - ActiveTurnCount int `json:"active_turn_count"` - PendingTransition *PendingTransition `json:"pending_transition"` - // Recoveries is runtime-owned recovery state. It is optional for older - // peers; when present it lets a restarted controller reconcile a release - // acknowledgement without submitting a second continuation. - Recoveries []RecoveryState `json:"recoveries,omitempty"` -} - -// VersionNegotiationParams advertises versions supported by a peer. -type VersionNegotiationParams struct { - SupportedVersions []int `json:"supported_versions"` -} - -// VersionNegotiationResult is returned after selecting the common version. -type VersionNegotiationResult struct { - ProtocolVersion int `json:"protocol_version"` - ServerVersions []int `json:"server_versions"` -} - -// AuthTransitionParams correlates prepare and commit with one controller-owned -// transition and rejects commands that target an old generation. -type AuthTransitionParams struct { - TransitionID string `json:"transition_id"` - TargetAccountID string `json:"target_account_id"` - ExpectedGeneration uint64 `json:"expected_generation"` -} - -// CancelAuthTransitionParams identifies a pending transition to cancel. -type CancelAuthTransitionParams struct { - TransitionID string `json:"transition_id"` - ExpectedGeneration uint64 `json:"expected_generation"` - Reason string `json:"reason,omitempty"` -} - -// TransitionResult is the typed acknowledgment returned by transition -// commands. Events provide the subsequent state changes and carry the same -// transition_id/runtime_id/auth_generation correlation fields. -type TransitionResult struct { - TransitionID string `json:"transition_id"` - RuntimeID string `json:"runtime_id"` - ExpectedGeneration uint64 `json:"expected_generation,omitempty"` - AuthGeneration uint64 `json:"auth_generation"` - Outcome TransitionOutcome `json:"outcome"` - AccountID *string `json:"account_id"` - ErrorCode *string `json:"error_code,omitempty"` - ErrorMessage *string `json:"error_message,omitempty"` -} - -// AuthGenerationResult is returned by runtime/authGeneration/read. -type AuthGenerationResult struct { - AuthGeneration uint64 `json:"auth_generation"` -} - -// NativeLoginParams is the non-secret operator intent sent to the embedded -// Codex login runtime. The runtime owns OAuth/browser/device login. -type NativeLoginParams struct { - AccountID string `json:"account_id,omitempty"` - Alias string `json:"alias,omitempty"` - Overwrite bool `json:"overwrite,omitempty"` -} - -// NativeLoginResult is the in-memory handoff from the integrated runtime. -// AuthJSON is intentionally never logged or included in journal records. -type NativeLoginResult struct { - AccountID string `json:"account_id"` - Alias string `json:"alias,omitempty"` - AuthJSON json.RawMessage `json:"auth_json"` - Metadata map[string]string `json:"metadata,omitempty"` -} - -// NativeRefreshParams supplies the target and opaque snapshot to the native -// runtime. This keeps provider-specific token parsing in Codex's login crate. -type NativeRefreshParams struct { - AccountID string `json:"account_id"` - AuthJSON json.RawMessage `json:"auth_json"` -} - -// NativeRefreshResult contains only token fields needed for controller-side -// write-back. It is never printed by the CLI. -type NativeRefreshResult struct { - AccessToken string `json:"access_token,omitempty"` - IDToken string `json:"id_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - AccountID string `json:"account_id,omitempty"` -} - -// NativeAuthSnapshotResult is the in-memory handoff of the active native -// AuthManager snapshot. AuthJSON is never written to transition journals or -// normal logs; the coordinator writes it only to the protected account vault. -type NativeAuthSnapshotResult struct { - AccountID string `json:"account_id"` - AuthJSON json.RawMessage `json:"auth_json"` -} - -// RecoveryPhase is the runtime's observable lifecycle for Codext's parked -// UsageLimitExceeded recovery. The controller never supplies prompt text; -// Codext retains and dispatches that native synthetic turn. -type RecoveryPhase string - -const ( - RecoveryParkedPhase RecoveryPhase = "parked" - RecoveryReleasedPhase RecoveryPhase = "released" - RecoveryStartedPhase RecoveryPhase = "started" - RecoveryCompletedPhase RecoveryPhase = "completed" -) - -// RecoveryState contains only correlation metadata. It intentionally has no -// prompt or credential fields so it is safe to persist in a recovery journal. -type RecoveryState struct { - RecoveryID string `json:"recovery_id"` - ThreadID string `json:"thread_id,omitempty"` - TurnID string `json:"turn_id,omitempty"` - SourceAccountID string `json:"source_account_id,omitempty"` - TransitionID string `json:"transition_id,omitempty"` - TargetAccountID string `json:"target_account_id,omitempty"` - ExpectedGeneration uint64 `json:"expected_generation,omitempty"` - Phase RecoveryPhase `json:"phase"` -} - -// RecoveryReleaseParams identifies the one native parked recovery to release. -// TransitionID and ExpectedGeneration bind the release to a verified account -// switch and prevent a stale controller from releasing under Account A. -type RecoveryReleaseParams struct { - RecoveryID string `json:"recovery_id"` - ThreadID string `json:"thread_id,omitempty"` - TransitionID string `json:"transition_id"` - ExpectedGeneration uint64 `json:"expected_generation"` -} - -// RecoveryReleaseOutcome is the runtime acknowledgement of a release request. -type RecoveryReleaseOutcome string - -const ( - RecoveryReleased RecoveryReleaseOutcome = "released" - RecoveryAlreadyReleased RecoveryReleaseOutcome = "already_released" - RecoveryReleaseRejected RecoveryReleaseOutcome = "rejected" - RecoveryReleaseUncertain RecoveryReleaseOutcome = "uncertain" -) - -// RecoveryReleaseResult is secret-free and idempotently repeatable for one -// recovery ID. A repeated request may return already_released without -// dispatching the native prompt again. -type RecoveryReleaseResult struct { - RecoveryID string `json:"recovery_id"` - ThreadID string `json:"thread_id,omitempty"` - Outcome RecoveryReleaseOutcome `json:"outcome"` - ErrorCode *string `json:"error_code,omitempty"` - ErrorMessage *string `json:"error_message,omitempty"` -} - -// RateLimitWindowWire is the intentionally narrow Marathon representation of -// an upstream rate-limit window. Unknown upstream fields are ignored by Go's -// JSON decoder and can be added later as real typed fields when consumed. -type RateLimitWindowWire struct { - UsedPercent float64 `json:"usedPercent"` - WindowDurationMins *int64 `json:"windowDurationMins"` - ResetsAt *int64 `json:"resetsAt"` -} - -// RateLimitSnapshotWire contains only fields currently used for policy and -// reset scheduling. The pointers preserve upstream null versus a value. -type RateLimitSnapshotWire struct { - LimitID *string `json:"limitId"` - LimitName *string `json:"limitName"` - PlanType *string `json:"planType"` - RateLimitReachedType *string `json:"rateLimitReachedType"` - Primary *RateLimitWindowWire `json:"primary"` - Secondary *RateLimitWindowWire `json:"secondary"` -} - -// GetAccountRateLimitsResponseWire is the full active-account snapshot. The -// by-ID map is optional on older runtimes; a nil map is not an empty update. -type GetAccountRateLimitsResponseWire struct { - RateLimits RateLimitSnapshotWire `json:"rateLimits"` - RateLimitsByID map[string]RateLimitSnapshotWire `json:"rateLimitsByLimitId"` -} - -// AccountRateLimitsUpdatedWire is a sparse rolling update. Callers must merge -// it against prior state or request a full snapshot when attribution is -// ambiguous; null metadata does not clear a prior value. -type AccountRateLimitsUpdatedWire struct { - RateLimits RateLimitSnapshotWire `json:"rateLimits"` -} - -// EventBase is shared by all runtime events. Transition events additionally -// require a non-empty TransitionID. -type EventBase struct { - EventType EventType `json:"event_type"` - OccurredAt int64 `json:"occurred_at"` - RuntimeID string `json:"runtime_id"` - AuthGeneration uint64 `json:"auth_generation"` - TransitionID string `json:"transition_id,omitempty"` -} - -// Event is the forward-compatible event representation delivered by Client. -// Payload contains the event-specific params after the common fields have -// been removed. DecodePayload provides typed values for known v1 events. -type Event struct { - EventBase - Payload json.RawMessage `json:"-"` -} - -// RuntimeReadyEvent announces the runtime identity and negotiated protocol. -type RuntimeReadyEvent struct { - EventBase - ProtocolVersion int `json:"protocol_version"` - AccountID *string `json:"account_id"` - Capabilities []string `json:"capabilities,omitempty"` -} - -// RateLimitsSnapshotEvent is a complete active-account snapshot. -type RateLimitsSnapshotEvent struct { - EventBase - AccountID *string `json:"account_id"` - RateLimits RateLimitSnapshotWire `json:"rateLimits"` - RateLimitsByID map[string]RateLimitSnapshotWire `json:"rateLimitsByLimitId"` -} - -// RateLimitsUpdatedEvent is a sparse active-account update. -type RateLimitsUpdatedEvent struct { - EventBase - AccountID *string `json:"account_id"` - RateLimits RateLimitSnapshotWire `json:"rateLimits"` -} - -// TurnStartedEvent marks the beginning of a turn under Codext's turn guard. -type TurnStartedEvent struct { - EventBase - TurnID string `json:"turn_id"` - ThreadID *string `json:"thread_id"` -} - -// TurnCompletedEvent marks the end of a turn and its result. -type TurnCompletedEvent struct { - EventBase - TurnID string `json:"turn_id"` - ThreadID *string `json:"thread_id"` - Outcome string `json:"outcome"` - ErrorCode *string `json:"error_code,omitempty"` -} - -// SafeBoundaryReachedEvent is emitted only when Codext's authoritative -// running-turn count reaches zero for a prepared transition. -type SafeBoundaryReachedEvent struct { - EventBase - Reason string `json:"reason"` - TurnID *string `json:"turn_id"` -} - -// AuthReloadStartedEvent marks the beginning of AuthManager reload. -type AuthReloadStartedEvent struct { - EventBase - TargetAccountID string `json:"target_account_id"` -} - -// AuthReloadSucceededEvent reports the observed identity after reload. -type AuthReloadSucceededEvent struct { - EventBase - AccountID *string `json:"account_id"` - PreviousAccountID *string `json:"previous_account_id"` - Changed bool `json:"changed"` -} - -// AuthReloadFailedEvent reports a reload failure without exposing credentials. -type AuthReloadFailedEvent struct { - EventBase - ErrorCode string `json:"error_code"` - ErrorMessage string `json:"error_message"` -} - -// IdentityChangedEvent confirms the identity observed after a transition. -type IdentityChangedEvent struct { - EventBase - PreviousAccountID *string `json:"previous_account_id"` - AccountID *string `json:"account_id"` -} - -// RecoveryParkedEvent observes runtime-owned recovery parking. -type RecoveryParkedEvent struct { - EventBase - RecoveryID string `json:"recovery_id"` - Reason string `json:"reason"` - ThreadID string `json:"thread_id,omitempty"` - TurnID string `json:"turn_id,omitempty"` - SourceAccountID string `json:"source_account_id,omitempty"` -} - -// RecoveryStartedEvent observes dispatch of a parked runtime recovery. -type RecoveryStartedEvent struct { - EventBase - RecoveryID string `json:"recovery_id"` - ThreadID string `json:"thread_id,omitempty"` - TurnID string `json:"turn_id,omitempty"` -} - -// RecoveryCompletedEvent observes completion of a runtime-owned recovery. -type RecoveryCompletedEvent struct { - EventBase - RecoveryID string `json:"recovery_id"` - Outcome string `json:"outcome"` - ErrorCode *string `json:"error_code,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - TurnID string `json:"turn_id,omitempty"` -} - -// Validate checks controller-owned transition fields before sending them. -func (p AuthTransitionParams) Validate() error { - if p.TransitionID == "" { - return errors.New("transition_id is required") - } - if p.TargetAccountID == "" { - return errors.New("target_account_id is required") - } - if p.ExpectedGeneration == 0 { - return errors.New("expected_generation must be greater than zero") - } - return nil -} - -// Validate checks a cancellation request before sending it. -func (p CancelAuthTransitionParams) Validate() error { - if p.TransitionID == "" { - return errors.New("transition_id is required") - } - if p.ExpectedGeneration == 0 { - return errors.New("expected_generation must be greater than zero") - } - return nil -} - -// Validate checks a native recovery release request before sending it. -func (p RecoveryReleaseParams) Validate() error { - if p.RecoveryID == "" { - return errors.New("recovery_id is required") - } - if p.TransitionID == "" { - return errors.New("transition_id is required") - } - if p.ExpectedGeneration == 0 { - return errors.New("expected_generation must be greater than zero") - } - return nil -} - -// Validate checks a version advertisement before sending it. -func (p VersionNegotiationParams) Validate() error { - if len(p.SupportedVersions) == 0 { - return errors.New("supported_versions must not be empty") - } - seen := make(map[int]struct{}, len(p.SupportedVersions)) - for _, version := range p.SupportedVersions { - if version < 1 { - return fmt.Errorf("supported version %d is invalid", version) - } - if _, ok := seen[version]; ok { - return fmt.Errorf("supported version %d is duplicated", version) - } - seen[version] = struct{}{} - } - return nil -} - -// DecodeEvent decodes the flat event params object while preserving unknown -// event-specific fields for forward-compatible logging or future adapters. -func DecodeEvent(raw []byte) (Event, error) { - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return Event{}, fmt.Errorf("decode event fields: %w", err) - } - if fields == nil { - return Event{}, errors.New("event params must be an object") - } - for _, key := range []string{"event_type", "occurred_at", "runtime_id", "auth_generation"} { - if _, ok := fields[key]; !ok { - return Event{}, fmt.Errorf("event %s is required", key) - } - } - var base EventBase - if err := json.Unmarshal(raw, &base); err != nil { - return Event{}, fmt.Errorf("decode event base: %w", err) - } - if base.EventType == "" { - return Event{}, errors.New("event_type is required") - } - if base.RuntimeID == "" { - return Event{}, errors.New("runtime_id is required") - } - if requiresTransitionID(base.EventType) { - if base.TransitionID == "" { - return Event{}, fmt.Errorf("event %s requires transition_id", base.EventType) - } - } - for _, key := range []string{"event_type", "occurred_at", "runtime_id", "auth_generation", "transition_id"} { - delete(fields, key) - } - payload, err := json.Marshal(fields) - if err != nil { - return Event{}, fmt.Errorf("encode event payload: %w", err) - } - return Event{EventBase: base, Payload: payload}, nil -} - -func requiresTransitionID(eventType EventType) bool { - switch eventType { - case EventSafeBoundaryReached, - EventAuthReloadStarted, - EventAuthReloadSucceeded, - EventAuthReloadFailed, - EventIdentityChanged: - return true - default: - return false - } -} - -// DecodePayload converts a known event into its typed v1 payload. Unknown -// event types are returned as an error while DecodeEvent remains available for -// forward-compatible consumers. -func (e Event) DecodePayload() (any, error) { - var target any - switch e.EventType { - case EventRuntimeReady: - target = &RuntimeReadyEvent{} - case EventRateLimitsSnapshot: - target = &RateLimitsSnapshotEvent{} - case EventRateLimitsUpdated: - target = &RateLimitsUpdatedEvent{} - case EventTurnStarted: - target = &TurnStartedEvent{} - case EventTurnCompleted: - target = &TurnCompletedEvent{} - case EventSafeBoundaryReached: - target = &SafeBoundaryReachedEvent{} - case EventAuthReloadStarted: - target = &AuthReloadStartedEvent{} - case EventAuthReloadSucceeded: - target = &AuthReloadSucceededEvent{} - case EventAuthReloadFailed: - target = &AuthReloadFailedEvent{} - case EventIdentityChanged: - target = &IdentityChangedEvent{} - case EventRecoveryParked: - target = &RecoveryParkedEvent{} - case EventRecoveryStarted: - target = &RecoveryStartedEvent{} - case EventRecoveryCompleted: - target = &RecoveryCompletedEvent{} - default: - return nil, fmt.Errorf("unsupported event type %q", e.EventType) - } - if len(bytes.TrimSpace(e.Payload)) == 0 || bytes.Equal(bytes.TrimSpace(e.Payload), []byte("{}")) { - return target, nil - } - if err := json.Unmarshal(e.Payload, target); err != nil { - return nil, fmt.Errorf("decode %s payload: %w", e.EventType, err) - } - // Reapply common fields because DecodeEvent removes them before retaining - // the event-specific payload. - switch value := target.(type) { - case *RuntimeReadyEvent: - value.EventBase = e.EventBase - case *RateLimitsSnapshotEvent: - value.EventBase = e.EventBase - case *RateLimitsUpdatedEvent: - value.EventBase = e.EventBase - case *TurnStartedEvent: - value.EventBase = e.EventBase - case *TurnCompletedEvent: - value.EventBase = e.EventBase - case *SafeBoundaryReachedEvent: - value.EventBase = e.EventBase - case *AuthReloadStartedEvent: - value.EventBase = e.EventBase - case *AuthReloadSucceededEvent: - value.EventBase = e.EventBase - case *AuthReloadFailedEvent: - value.EventBase = e.EventBase - case *IdentityChangedEvent: - value.EventBase = e.EventBase - case *RecoveryParkedEvent: - value.EventBase = e.EventBase - case *RecoveryStartedEvent: - value.EventBase = e.EventBase - case *RecoveryCompletedEvent: - value.EventBase = e.EventBase - } - return target, nil -} diff --git a/controller/internal/telemetry/cache.go b/controller/internal/telemetry/cache.go deleted file mode 100644 index 7c707074..00000000 --- a/controller/internal/telemetry/cache.go +++ /dev/null @@ -1,139 +0,0 @@ -package telemetry - -import ( - "sync" - "time" -) - -type cacheEntry struct { - snapshot AccountTelemetry - storedAt time.Time -} - -// SnapshotCache is a TTL cache of immutable-by-convention account snapshots. -// It deep-copies on both ingress and egress, which is required because maps, -// slices, and pointers in AccountTelemetry otherwise share backing storage. -type SnapshotCache struct { - mu sync.RWMutex - entries map[string]cacheEntry - ttl time.Duration - now func() time.Time -} - -// NewSnapshotCache creates a cache. A non-positive TTL disables age expiry; -// reset-boundary freshness is still computed independently on Get. -func NewSnapshotCache(ttl time.Duration, clock ...func() time.Time) *SnapshotCache { - return &SnapshotCache{ - entries: make(map[string]cacheEntry), - ttl: ttl, - now: selectClock(clock...), - } -} - -// TTL returns the configured age bound. -func (c *SnapshotCache) TTL() time.Duration { - if c == nil { - return 0 - } - return c.ttl -} - -// Set stores a deep copy. If the snapshot has no observation timestamp, the -// injected clock supplies one for TTL accounting and returned state. -func (c *SnapshotCache) Set(accountID string, snapshot AccountTelemetry) { - if c == nil { - return - } - clone := snapshot.Clone() - if clone.AccountID == "" { - clone.AccountID = accountID - } - storedAt := clone.ObservedAt - if storedAt.IsZero() { - storedAt = c.currentTime() - clone.ObservedAt = storedAt - } - if accountID == "" { - accountID = clone.AccountID - } - c.mu.Lock() - if c.entries == nil { - c.entries = make(map[string]cacheEntry) - } - c.entries[accountID] = cacheEntry{snapshot: clone, storedAt: storedAt} - c.mu.Unlock() -} - -// Put is an alias for Set. -func (c *SnapshotCache) Put(accountID string, snapshot AccountTelemetry) { c.Set(accountID, snapshot) } - -// Get returns a deep copy. An expired entry is removed and reported as a -// miss. Freshness is evaluated on the returned copy, leaving the cached -// snapshot itself suitable for another caller at a different time. -func (c *SnapshotCache) Get(accountID string) (AccountTelemetry, bool) { - if c == nil { - return AccountTelemetry{}, false - } - now := c.currentTime() - c.mu.Lock() - entry, ok := c.entries[accountID] - if !ok { - c.mu.Unlock() - return AccountTelemetry{}, false - } - if c.ttl > 0 && now.Sub(entry.storedAt) >= c.ttl { - delete(c.entries, accountID) - c.mu.Unlock() - return AccountTelemetry{}, false - } - clone := entry.snapshot.Clone() - c.mu.Unlock() - clone.RefreshStaleness(now) - return clone, true -} - -// Snapshot is an alias for Get. -func (c *SnapshotCache) Snapshot(accountID string) (AccountTelemetry, bool) { - return c.Get(accountID) -} - -// Invalidate removes one account's cached observation. -func (c *SnapshotCache) Invalidate(accountID string) { - if c == nil { - return - } - c.mu.Lock() - delete(c.entries, accountID) - c.mu.Unlock() -} - -// InvalidateAll removes all cached observations. It is the conservative -// option after an identity change when the controller cannot yet narrow the -// affected account set. -func (c *SnapshotCache) InvalidateAll() { - if c == nil { - return - } - c.mu.Lock() - c.entries = make(map[string]cacheEntry) - c.mu.Unlock() -} - -// Len returns the number of currently stored entries (including entries that -// may be age-expired but have not yet been requested). -func (c *SnapshotCache) Len() int { - if c == nil { - return 0 - } - c.mu.RLock() - length := len(c.entries) - c.mu.RUnlock() - return length -} - -func (c *SnapshotCache) currentTime() time.Time { - if c == nil || c.now == nil { - return time.Now().UTC() - } - return c.now() -} diff --git a/controller/internal/telemetry/domain.go b/controller/internal/telemetry/domain.go deleted file mode 100644 index 6729be82..00000000 --- a/controller/internal/telemetry/domain.go +++ /dev/null @@ -1,409 +0,0 @@ -package telemetry - -import ( - "fmt" - "sort" - "time" -) - -// WindowFreshness is tracked independently for every rate-limit window. A -// reset boundary invalidates only the window whose reset has elapsed. -type WindowFreshness uint8 - -const ( - WindowFresh WindowFreshness = iota - WindowStale -) - -const ( - Fresh = WindowFresh - Stale = WindowStale -) - -func (f WindowFreshness) String() string { - if f == WindowStale { - return "stale" - } - return "fresh" -} - -// WindowKind identifies the two conventional windows in a Codex rate-limit -// bucket. The kind is useful after normalization because the wire shape uses -// named primary/secondary fields while policy and reset scheduling iterate a -// flat list. -type WindowKind string - -const ( - PrimaryWindow WindowKind = "primary" - SecondaryWindow WindowKind = "secondary" -) - -// AggregateLimitKey is used only when the server supplies an aggregate view -// without a limit ID. It is intentionally impossible to confuse with a -// normal backend metered-limit identifier. -const AggregateLimitKey = "__aggregate__" - -// WindowTelemetry is the normalized form of one primary or secondary usage -// window. ResetsAt is nil when the server omitted reset metadata. -type WindowTelemetry struct { - Kind WindowKind - UsedPercent float64 - WindowDurationMins *int64 - ResetsAt *time.Time - ObservedAt time.Time - Freshness WindowFreshness -} - -// IsFreshAt applies both freshness guards that policy decisions need: the -// explicit reset-boundary marker and an optional age bound. A zero maxAge -// disables the age bound, but an elapsed reset is always stale. The method is -// intentionally side-effect free so callers can evaluate a cache snapshot at -// different times without mutating the stored copy. -func (w WindowTelemetry) IsFreshAt(now time.Time, maxAge time.Duration) bool { - if w.IsStaleAt(now) { - return false - } - if maxAge <= 0 { - return true - } - if w.ObservedAt.IsZero() || w.ObservedAt.After(now) { - return false - } - return now.Sub(w.ObservedAt) < maxAge -} - -// Clone returns a fully independent copy, including the reset timestamp. -func (w WindowTelemetry) Clone() WindowTelemetry { - clone := w - if w.WindowDurationMins != nil { - value := *w.WindowDurationMins - clone.WindowDurationMins = &value - } - if w.ResetsAt != nil { - value := *w.ResetsAt - clone.ResetsAt = &value - } - return clone -} - -// IsStaleAt evaluates reset-time staleness without mutating the value. An -// elapsed reset is an instruction to re-observe state; it never implies that -// usage has reset to zero. -func (w WindowTelemetry) IsStaleAt(now time.Time) bool { - return w.Freshness == WindowStale || (w.ResetsAt != nil && !w.ResetsAt.After(now)) -} - -// RefreshStaleness updates only this window's freshness marker. -func (w *WindowTelemetry) RefreshStaleness(now time.Time) { - if w == nil { - return - } - if w.IsStaleAt(now) { - w.Freshness = WindowStale - } else { - w.Freshness = WindowFresh - } -} - -// MarkStale is a concise alias used by reset-boundary callers. -func (w *WindowTelemetry) MarkStale(now time.Time) { w.RefreshStaleness(now) } - -// IsExhausted reports the server's conventional exhausted threshold. A -// caller still needs to check freshness before relying on the result. -func (w WindowTelemetry) IsExhausted() bool { return w.UsedPercent >= 100 } - -// HasFutureReset reports whether this window carries an authoritative reset -// timestamp strictly after now. -func (w WindowTelemetry) HasFutureReset(now time.Time) bool { - return w.ResetsAt != nil && w.ResetsAt.After(now) -} - -// LimitTelemetry is one normalized metered bucket. Windows is the canonical -// representation used by policy and reset scheduling. Primary and Secondary -// are retained as convenience views for adapters and callers that prefer the -// original wire shape. -type LimitTelemetry struct { - LimitID string - LimitName string - PlanType string - RateLimitReachedType string - Windows []WindowTelemetry - Primary *WindowTelemetry - Secondary *WindowTelemetry -} - -// Clone returns an independent bucket snapshot. -func (l LimitTelemetry) Clone() LimitTelemetry { - clone := l - if l.Windows != nil { - clone.Windows = make([]WindowTelemetry, len(l.Windows)) - for i, window := range l.Windows { - clone.Windows[i] = window.Clone() - } - } - if l.Primary != nil { - window := l.Primary.Clone() - clone.Primary = &window - } - if l.Secondary != nil { - window := l.Secondary.Clone() - clone.Secondary = &window - } - return clone -} - -// WindowList returns a copy of the canonical flat window list. It accepts -// hand-built values that populate only Primary/Secondary as well as values -// produced by NormalizeSnapshot. -func (l LimitTelemetry) WindowList() []WindowTelemetry { - if len(l.Windows) > 0 { - windows := make([]WindowTelemetry, len(l.Windows)) - for i, window := range l.Windows { - windows[i] = window.Clone() - } - return windows - } - var windows []WindowTelemetry - if l.Primary != nil { - window := l.Primary.Clone() - if window.Kind == "" { - window.Kind = PrimaryWindow - } - windows = append(windows, window) - } - if l.Secondary != nil { - window := l.Secondary.Clone() - if window.Kind == "" { - window.Kind = SecondaryWindow - } - windows = append(windows, window) - } - return windows -} - -// RefreshStaleness applies the reset-boundary rule independently to every -// window, including convenience views. -func (l *LimitTelemetry) RefreshStaleness(now time.Time) { - if l == nil { - return - } - for i := range l.Windows { - l.Windows[i].RefreshStaleness(now) - } - if l.Primary != nil { - l.Primary.RefreshStaleness(now) - } - if l.Secondary != nil { - l.Secondary.RefreshStaleness(now) - } -} - -// AccountTelemetry is an immutable-by-convention normalized account snapshot. -// StateStore and SnapshotCache enforce the convention by cloning on ingress -// and egress. -type AccountTelemetry struct { - AccountID string - Limits map[string]LimitTelemetry - Aggregate *LimitTelemetry - ObservedAt time.Time - IsUsable bool - Source string -} - -// Clone returns a fully independent account snapshot. -func (a AccountTelemetry) Clone() AccountTelemetry { - clone := a - if a.Limits != nil { - clone.Limits = make(map[string]LimitTelemetry, len(a.Limits)) - for key, limit := range a.Limits { - clone.Limits[key] = limit.Clone() - } - } - if a.Aggregate != nil { - aggregate := a.Aggregate.Clone() - clone.Aggregate = &aggregate - } - return clone -} - -// RefreshStaleness refreshes each window without changing any other account -// metadata. -func (a *AccountTelemetry) RefreshStaleness(now time.Time) { - if a == nil { - return - } - for key, limit := range a.Limits { - limit.RefreshStaleness(now) - a.Limits[key] = limit - } - if a.Aggregate != nil { - a.Aggregate.RefreshStaleness(now) - } -} - -// MarkStale is the account-level alias for RefreshStaleness. -func (a *AccountTelemetry) MarkStale(now time.Time) { a.RefreshStaleness(now) } - -// LimitList returns every distinct metered bucket in deterministic order. A -// separately stored aggregate view is included only when it is not already -// represented by a map entry. -func (a AccountTelemetry) LimitList() []LimitTelemetry { - if len(a.Limits) == 0 { - if a.Aggregate == nil { - return nil - } - return []LimitTelemetry{a.Aggregate.Clone()} - } - - keys := make([]string, 0, len(a.Limits)) - for key := range a.Limits { - keys = append(keys, key) - } - sort.Strings(keys) - limits := make([]LimitTelemetry, 0, len(keys)+1) - seenIDs := make(map[string]struct{}, len(keys)) - for _, key := range keys { - limit := a.Limits[key].Clone() - limits = append(limits, limit) - if limit.LimitID != "" { - seenIDs[limit.LimitID] = struct{}{} - } - } - if a.Aggregate != nil { - if a.Aggregate.LimitID == "" { - // A nameless aggregate is useful when the map has no aggregate key; - // avoid adding it twice when callers explicitly stored the sentinel - // or when the multi-bucket view has exactly one bucket (the common - // backward-compatible representation). - if _, ok := a.Limits[AggregateLimitKey]; !ok && len(bucketKeys(a)) != 1 { - limits = append(limits, a.Aggregate.Clone()) - } - } else if _, ok := seenIDs[a.Aggregate.LimitID]; !ok { - limits = append(limits, a.Aggregate.Clone()) - } - } - return limits -} - -// WindowList returns a flattened copy of all distinct account windows. -func (a AccountTelemetry) WindowList() []WindowTelemetry { - limits := a.LimitList() - var windows []WindowTelemetry - for _, limit := range limits { - windows = append(windows, limit.WindowList()...) - } - return windows -} - -// HasFreshCapacity reports whether every observed window is fresh and below -// the exhaustion threshold. It deliberately returns false for an account -// with no windows or unusable telemetry. -func (a AccountTelemetry) HasFreshCapacity(now time.Time) bool { - eligible, _ := a.IsCompleteFreshCapacity(now, 0) - return eligible -} - -// IsCompleteFreshCapacity is the strict eligibility predicate used by the -// policy engine. It rejects incomplete multi-bucket snapshots, missing -// per-window observations, future timestamps, reset-stale windows, and -// exhausted windows. A false result is accompanied by a stable diagnostic -// reason suitable for operator output and tests; callers must not turn that -// reason into a credential or account selector. -func (a AccountTelemetry) IsCompleteFreshCapacity(now time.Time, maxAge time.Duration) (bool, string) { - if !a.IsUsable { - return false, "account telemetry is unusable" - } - if a.AccountID == "" { - return false, "account telemetry has no account id" - } - if len(a.Limits) == 0 && a.Aggregate == nil { - return false, "account telemetry has no rate-limit buckets" - } - limits := a.LimitList() - if len(limits) == 0 { - return false, "account telemetry has no rate-limit buckets" - } - if maxAge > 0 { - if a.ObservedAt.IsZero() { - return false, "account telemetry has no observation timestamp" - } - if a.ObservedAt.After(now) { - return false, "account telemetry timestamp is in the future" - } - if now.Sub(a.ObservedAt) >= maxAge { - return false, "account telemetry is older than the freshness bound" - } - } - seenLimitIDs := make(map[string]struct{}, len(limits)) - for _, limit := range limits { - if limit.LimitID != "" { - if _, duplicate := seenLimitIDs[limit.LimitID]; duplicate { - return false, fmt.Sprintf("duplicate rate-limit bucket %q", limit.LimitID) - } - seenLimitIDs[limit.LimitID] = struct{}{} - } - windows := limit.WindowList() - if len(windows) == 0 { - return false, "rate-limit bucket has no complete windows" - } - seenKinds := make(map[WindowKind]struct{}, len(windows)) - for _, window := range windows { - if window.Kind != PrimaryWindow && window.Kind != SecondaryWindow { - return false, "rate-limit bucket contains an unknown window kind" - } - if _, duplicate := seenKinds[window.Kind]; duplicate { - return false, "rate-limit bucket contains duplicate windows" - } - seenKinds[window.Kind] = struct{}{} - if !window.IsFreshAt(now, maxAge) { - return false, "rate-limit window is stale or outside the freshness bound" - } - if window.IsExhausted() { - return false, "rate-limit window is exhausted" - } - } - } - return true, "" -} - -// HasFreshCapacityWithin is the age-aware counterpart to -// HasFreshCapacity. The age bound is important for inactive-profile cache -// reads, where reset timestamps alone cannot prove the server state is still -// current. -func (a AccountTelemetry) HasFreshCapacityWithin(now time.Time, maxAge time.Duration) bool { - eligible, _ := a.IsCompleteFreshCapacity(now, maxAge) - return eligible -} - -// MaxUsedPercent returns the greatest usage across all normalized buckets and -// windows. It is a stable, multi-bucket pressure metric for candidate -// ranking; a missing window is not converted into zero because callers should -// first require IsCompleteFreshCapacity. -func (a AccountTelemetry) MaxUsedPercent() float64 { - var max float64 - for _, window := range a.WindowList() { - if window.UsedPercent > max { - max = window.UsedPercent - } - } - return max -} - -// UsedPercentByKind returns the greatest observed usage for one conventional -// window kind. The boolean distinguishes an absent kind from a real 0%% -// observation, which matters when ranking accounts with different bucket -// shapes. -func (a AccountTelemetry) UsedPercentByKind(kind WindowKind) (float64, bool) { - var max float64 - found := false - for _, window := range a.WindowList() { - if window.Kind != kind { - continue - } - if !found || window.UsedPercent > max { - max = window.UsedPercent - } - found = true - } - return max, found -} diff --git a/controller/internal/telemetry/normalize.go b/controller/internal/telemetry/normalize.go deleted file mode 100644 index 7e566ffe..00000000 --- a/controller/internal/telemetry/normalize.go +++ /dev/null @@ -1,114 +0,0 @@ -package telemetry - -import "time" - -// NormalizeWindow converts an upstream wire window into a fresh normalized -// window. Freshness is evaluated again by StateStore/Policy at decision time -// so an elapsed reset can never be mistaken for capacity. -func NormalizeWindow(wire RateLimitWindowWire, kind WindowKind, observedAt time.Time) WindowTelemetry { - window := WindowTelemetry{ - Kind: kind, - UsedPercent: wire.UsedPercent, - WindowDurationMins: cloneInt64(wire.WindowDurationMins), - ObservedAt: observedAt, - Freshness: WindowFresh, - } - if wire.ResetsAt != nil { - reset := time.Unix(*wire.ResetsAt, 0).UTC() - window.ResetsAt = &reset - } - window.RefreshStaleness(observedAt) - return window -} - -// NormalizeLimitSnapshot converts one wire bucket into a domain bucket. -func NormalizeLimitSnapshot(wire RateLimitSnapshotWire, observedAt time.Time) LimitTelemetry { - limit := LimitTelemetry{ - LimitID: derefString(wire.LimitID), - LimitName: derefString(wire.LimitName), - PlanType: derefString(wire.PlanType), - RateLimitReachedType: derefString(wire.RateLimitReachedType), - } - if wire.Primary != nil { - window := NormalizeWindow(*wire.Primary, PrimaryWindow, observedAt) - limit.Primary = &window - limit.Windows = append(limit.Windows, window.Clone()) - } - if wire.Secondary != nil { - window := NormalizeWindow(*wire.Secondary, SecondaryWindow, observedAt) - limit.Secondary = &window - limit.Windows = append(limit.Windows, window.Clone()) - } - return limit -} - -// NormalizeSnapshot converts the aggregate and optional multi-bucket views -// into a controller account snapshot. The aggregate view is always retained -// separately so sparse updates can apply it coherently with a matching bucket. -// When the server provides no multi-bucket map, the aggregate bucket is also -// indexed by its ID (or AggregateLimitKey when it has none). -func NormalizeSnapshot(accountID string, aggregate RateLimitSnapshotWire, byID map[string]RateLimitSnapshotWire, observedAt time.Time) AccountTelemetry { - telemetry := AccountTelemetry{ - AccountID: accountID, - Limits: make(map[string]LimitTelemetry, len(byID)), - ObservedAt: observedAt, - IsUsable: true, - Source: "runtime", - } - - aggregateLimit := NormalizeLimitSnapshot(aggregate, observedAt) - telemetry.Aggregate = &aggregateLimit - - if len(byID) == 0 { - key := aggregateLimit.LimitID - if key == "" { - key = AggregateLimitKey - } - telemetry.Limits[key] = aggregateLimit.Clone() - return telemetry - } - - for key, snapshot := range byID { - limit := NormalizeLimitSnapshot(snapshot, observedAt) - if key == "" { - key = limit.LimitID - if key == "" { - key = AggregateLimitKey - } - } - if limit.LimitID == "" && key != AggregateLimitKey { - // The map key is the authoritative metered ID when a backend - // omits the duplicated limitId field in a bucket value. - limit.LimitID = key - } - telemetry.Limits[key] = limit - } - return telemetry -} - -// NormalizeRateLimits is the response-shaped convenience form used by -// runtime adapters. -func NormalizeRateLimits(accountID string, response GetAccountRateLimitsResponseWire, observedAt time.Time) AccountTelemetry { - return NormalizeSnapshot(accountID, response.RateLimits, response.RateLimitsByID, observedAt) -} - -// NormalizeResponse is an alias retained for callers that use the protocol -// response terminology. -func NormalizeResponse(accountID string, response GetAccountRateLimitsResponseWire, observedAt time.Time) AccountTelemetry { - return NormalizeRateLimits(accountID, response, observedAt) -} - -func derefString(value *string) string { - if value == nil { - return "" - } - return *value -} - -func cloneInt64(value *int64) *int64 { - if value == nil { - return nil - } - clone := *value - return &clone -} diff --git a/controller/internal/telemetry/provider.go b/controller/internal/telemetry/provider.go deleted file mode 100644 index 44870787..00000000 --- a/controller/internal/telemetry/provider.go +++ /dev/null @@ -1,352 +0,0 @@ -package telemetry - -import ( - "context" - "errors" - "sort" - "sync" - "time" -) - -// SnapshotResult carries a provider observation and its usability separately. -// A provider may return a structurally valid but unusable observation (for -// example, a stale cached response), so callers must not infer usability from -// a nil error alone. -type SnapshotResult struct { - Telemetry AccountTelemetry - // Snapshot is retained as a compatibility alias for callers that use the - // protocol vocabulary. New code should use Telemetry. - Snapshot AccountTelemetry - IsUsable bool - ObservedAt time.Time - Err error -} - -// UsableSnapshot constructs a successful provider result. -func UsableSnapshot(snapshot AccountTelemetry) SnapshotResult { - clone := snapshot.Clone() - if !clone.IsUsable { - clone.IsUsable = true - } - return SnapshotResult{ - Telemetry: clone.Clone(), - Snapshot: clone.Clone(), - IsUsable: true, - ObservedAt: clone.ObservedAt, - } -} - -// UnusableSnapshot constructs a non-usable result without treating it as an -// account with zero usage. -func UnusableSnapshot(err error) SnapshotResult { - return SnapshotResult{IsUsable: false, Err: err} -} - -// UsageProvider supplies a complete account snapshot. The account ID is -// passed separately because the runtime connection is authoritative for the -// identity of sparse notifications. -type UsageProvider interface { - Snapshot(context.Context, string) (SnapshotResult, error) -} - -// ProviderFunc adapts a function to UsageProvider. -type ProviderFunc func(context.Context, string) (SnapshotResult, error) - -func (f ProviderFunc) Snapshot(ctx context.Context, accountID string) (SnapshotResult, error) { - return f(ctx, accountID) -} - -// AccountObservation is the result of observing one configured account. It -// intentionally carries an unusable snapshot alongside Err so a batch can -// continue checking other accounts when one inactive profile is expired, -// revoked, or temporarily unreachable. An error is never converted into a -// zero-usage observation. -type AccountObservation struct { - AccountID string - Telemetry AccountTelemetry - Err error - FromCache bool - Refreshed bool -} - -// ObserveOptions controls a bounded observation pass. MaxAge is evaluated by -// the caller/policy as well as by this helper; Force bypasses the cache on the -// first request. A stale cached result is refreshed once when a provider is -// registered, never retried in an unbounded loop. -type ObserveOptions struct { - Force bool - MaxAge time.Duration - Now time.Time -} - -// Observe reads account snapshots in deterministic account-ID order. It is a -// batch boundary for active runtime state and inactive stored-profile -// providers: both use the same cache/state-store semantics, while an -// individual provider failure remains attached to that account. -func (r *MultiAccountUsageRouter) Observe(ctx context.Context, accountIDs []string, options ObserveOptions) ([]AccountObservation, error) { - if r == nil { - return nil, errors.New("usage router is nil") - } - if ctx == nil { - ctx = context.Background() - } - ids := uniqueSortedIDs(accountIDs) - now := options.Now - if now.IsZero() { - now = time.Now().UTC() - } - observations := make([]AccountObservation, 0, len(ids)) - for _, accountID := range ids { - if err := ctx.Err(); err != nil { - return observations, err - } - observation := AccountObservation{AccountID: accountID} - cachedBefore := false - if !options.Force && r.cache != nil { - _, cachedBefore = r.cache.Get(accountID) - } - result, err := r.snapshot(ctx, accountID, options.Force) - if err == nil && result.IsUsable { - telemetry := result.Telemetry - if telemetry.AccountID == "" { - telemetry = result.Snapshot - } - observation.Telemetry = telemetry.Clone() - observation.FromCache = cachedBefore - // A cache hit can be structurally valid but too old or reset-stale. - // Refresh once, provided the account has a provider. This keeps - // threshold decisions from authorizing a switch from stale data. - if !telemetry.HasFreshCapacityWithin(now, options.MaxAge) && r.hasProvider(accountID) { - fresh, refreshErr := r.snapshot(ctx, accountID, true) - observation.Refreshed = true - observation.FromCache = false - if refreshErr != nil || !fresh.IsUsable { - observation.Err = refreshErr - if observation.Err == nil { - observation.Err = fresh.Err - } - observation.Telemetry = AccountTelemetry{AccountID: accountID, IsUsable: false, Source: "refresh_failed"} - } else { - observation.Telemetry = fresh.Telemetry.Clone() - if observation.Telemetry.AccountID == "" { - observation.Telemetry = fresh.Snapshot.Clone() - } - } - } - } else { - observation.Err = err - if observation.Err == nil { - observation.Err = result.Err - } - observation.Telemetry = AccountTelemetry{AccountID: accountID, IsUsable: false, Source: "unavailable"} - } - if observation.Telemetry.AccountID == "" { - observation.Telemetry.AccountID = accountID - } - observations = append(observations, observation) - } - return observations, nil -} - -// ObserveAccounts is an alias with a more explicit call-site spelling. -func (r *MultiAccountUsageRouter) ObserveAccounts(ctx context.Context, accountIDs []string, options ObserveOptions) ([]AccountObservation, error) { - return r.Observe(ctx, accountIDs, options) -} - -// hasProvider reports whether a refresh can be attempted without exposing -// provider implementation details. -func (r *MultiAccountUsageRouter) hasProvider(accountID string) bool { - if r == nil { - return false - } - r.mu.RLock() - provider := r.providers[accountID] - r.mu.RUnlock() - return provider != nil -} - -func uniqueSortedIDs(accountIDs []string) []string { - seen := make(map[string]struct{}, len(accountIDs)) - for _, accountID := range accountIDs { - if accountID != "" { - seen[accountID] = struct{}{} - } - } - ids := make([]string, 0, len(seen)) - for accountID := range seen { - ids = append(ids, accountID) - } - sort.Strings(ids) - return ids -} - -// MultiAccountUsageRouter chooses the provider for an account and centralizes -// cache/state-store ingestion. Active runtime telemetry and inactive-profile -// providers can therefore share one controller-facing abstraction without -// duplicate polling logic. -type MultiAccountUsageRouter struct { - mu sync.RWMutex - providers map[string]UsageProvider - cache *SnapshotCache - store *StateStore -} - -// UsageRouter is a concise name for the multi-account implementation. -type UsageRouter = MultiAccountUsageRouter - -// NewMultiAccountUsageRouter creates a router. Cache and store may be nil if -// a caller only wants provider dispatch; a non-nil cache is recommended for -// normal controller operation. -func NewMultiAccountUsageRouter(cache *SnapshotCache, store *StateStore) *MultiAccountUsageRouter { - return &MultiAccountUsageRouter{ - providers: make(map[string]UsageProvider), - cache: cache, - store: store, - } -} - -// NewUsageRouter is an alias for NewMultiAccountUsageRouter. -func NewUsageRouter(cache *SnapshotCache, store *StateStore) *MultiAccountUsageRouter { - return NewMultiAccountUsageRouter(cache, store) -} - -// Register associates one account with a provider. -func (r *MultiAccountUsageRouter) Register(accountID string, provider UsageProvider) error { - if r == nil { - return errors.New("usage router is nil") - } - if accountID == "" { - return errors.New("account ID is required") - } - if provider == nil { - return errors.New("usage provider is nil") - } - r.mu.Lock() - if r.providers == nil { - r.providers = make(map[string]UsageProvider) - } - r.providers[accountID] = provider - r.mu.Unlock() - return nil -} - -// SetProvider is an alias for Register. -func (r *MultiAccountUsageRouter) SetProvider(accountID string, provider UsageProvider) error { - return r.Register(accountID, provider) -} - -// Unregister removes an account provider. -func (r *MultiAccountUsageRouter) Unregister(accountID string) { - if r == nil { - return - } - r.mu.Lock() - delete(r.providers, accountID) - r.mu.Unlock() -} - -// Accounts returns registered provider IDs in deterministic order. -func (r *MultiAccountUsageRouter) Accounts() []string { - if r == nil { - return nil - } - r.mu.RLock() - accounts := make([]string, 0, len(r.providers)) - for accountID := range r.providers { - accounts = append(accounts, accountID) - } - r.mu.RUnlock() - sort.Strings(accounts) - return accounts -} - -// Snapshot returns a cached usable observation when available, otherwise it -// asks the registered account provider and stores a successful result. -func (r *MultiAccountUsageRouter) Snapshot(ctx context.Context, accountID string) (SnapshotResult, error) { - return r.snapshot(ctx, accountID, false) -} - -// Refresh bypasses the cache and obtains fresh telemetry from the provider. -func (r *MultiAccountUsageRouter) Refresh(ctx context.Context, accountID string) (SnapshotResult, error) { - return r.snapshot(ctx, accountID, true) -} - -func (r *MultiAccountUsageRouter) snapshot(ctx context.Context, accountID string, force bool) (SnapshotResult, error) { - if r == nil { - return UnusableSnapshot(errors.New("usage router is nil")), errors.New("usage router is nil") - } - if !force && r.cache != nil { - if cached, ok := r.cache.Get(accountID); ok && cached.IsUsable { - return UsableSnapshot(cached), nil - } - } - r.mu.RLock() - provider := r.providers[accountID] - r.mu.RUnlock() - if provider == nil { - err := errors.New("no usage provider registered for account") - return UnusableSnapshot(err), err - } - result, err := provider.Snapshot(ctx, accountID) - if err != nil { - result.IsUsable = false - result.Err = err - return result, err - } - telemetry := result.Telemetry - if telemetry.AccountID == "" { - telemetry = result.Snapshot - } - if telemetry.AccountID == "" { - telemetry.AccountID = accountID - } - telemetry.IsUsable = result.IsUsable - if result.ObservedAt.IsZero() { - result.ObservedAt = telemetry.ObservedAt - } - result.Telemetry = telemetry.Clone() - result.Snapshot = telemetry.Clone() - if result.IsUsable { - if r.cache != nil { - r.cache.Set(accountID, telemetry) - } - if r.store != nil { - r.store.Set(accountID, telemetry) - } - } - return result, nil -} - -// IngestFull stores a runtime's normalized full read in both state layers. -func (r *MultiAccountUsageRouter) IngestFull(accountID string, snapshot AccountTelemetry) { - if r == nil { - return - } - if r.cache != nil { - r.cache.Set(accountID, snapshot) - } - if r.store != nil { - r.store.Set(accountID, snapshot) - } -} - -// IngestSparse applies a runtime rolling update to the state store and -// invalidates the cache after a successful merge. A refetch-required result -// is returned unchanged to the caller. -func (r *MultiAccountUsageRouter) IngestSparse(accountID string, update AccountRateLimitsUpdatedWire) SparseUpdateResult { - if r == nil || r.store == nil { - return SparseUpdateResult{ - Outcome: SparseUpdateNoState, - AccountID: accountID, - Reason: "state store is unavailable", - RefetchRequired: true, - } - } - result := r.store.ApplySparseUpdate(accountID, update) - if result.Applied() && r.cache != nil { - // The store has merged a new observation; replace the cache with the - // returned clone rather than leaving an older aggregate snapshot behind. - r.cache.Set(accountID, result.Snapshot) - } - return result -} diff --git a/controller/internal/telemetry/provider_test.go b/controller/internal/telemetry/provider_test.go deleted file mode 100644 index b0c590de..00000000 --- a/controller/internal/telemetry/provider_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package telemetry - -import ( - "context" - "errors" - "testing" - "time" -) - -func TestObserveRefreshesStaleCachedAccountOnce(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - store := NewStateStore(func() time.Time { return now }) - cache := NewSnapshotCache(24*time.Hour, func() time.Time { return now }) - router := NewMultiAccountUsageRouter(cache, store) - refreshes := 0 - if err := router.Register("account-a", ProviderFunc(func(context.Context, string) (SnapshotResult, error) { - refreshes++ - return UsableSnapshot(observedAccount("account-a", now, 10)), nil - })); err != nil { - t.Fatal(err) - } - cache.Set("account-a", observedAccount("account-a", now.Add(-2*time.Hour), 90)) - store.Set("account-a", observedAccount("account-a", now.Add(-2*time.Hour), 90)) - observations, err := router.Observe(context.Background(), []string{"account-a"}, ObserveOptions{MaxAge: time.Hour, Now: now}) - if err != nil { - t.Fatal(err) - } - if len(observations) != 1 || observations[0].Err != nil || !observations[0].Refreshed { - t.Fatalf("observations = %#v", observations) - } - if refreshes != 1 || observations[0].Telemetry.Limits["codex"].Windows[0].UsedPercent != 10 { - t.Fatalf("refreshes=%d telemetry=%#v", refreshes, observations[0].Telemetry) - } -} - -func TestObserveKeepsProviderFailureUnusableAndContinuesBatch(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - router := NewMultiAccountUsageRouter(nil, NewStateStore(func() time.Time { return now })) - if err := router.Register("account-a", ProviderFunc(func(context.Context, string) (SnapshotResult, error) { - return UnusableSnapshot(errors.New("provider unavailable")), errors.New("provider unavailable") - })); err != nil { - t.Fatal(err) - } - if err := router.Register("account-b", ProviderFunc(func(context.Context, string) (SnapshotResult, error) { - return UsableSnapshot(observedAccount("account-b", now, 5)), nil - })); err != nil { - t.Fatal(err) - } - observations, err := router.Observe(context.Background(), []string{"account-b", "account-a"}, ObserveOptions{MaxAge: time.Hour, Now: now}) - if err != nil { - t.Fatal(err) - } - if len(observations) != 2 || observations[0].AccountID != "account-a" || observations[1].AccountID != "account-b" { - t.Fatalf("order = %#v", observations) - } - if observations[0].Err == nil || observations[0].Telemetry.IsUsable { - t.Fatalf("failed provider became usable: %#v", observations[0]) - } - if observations[1].Err != nil || !observations[1].Telemetry.IsUsable { - t.Fatalf("healthy provider lost: %#v", observations[1]) - } -} - -func TestObserveDoesNotRefreshWithoutRegisteredProvider(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - store := NewStateStore(func() time.Time { return now }) - cache := NewSnapshotCache(time.Hour, func() time.Time { return now }) - router := NewMultiAccountUsageRouter(cache, store) - store.Set("account-a", observedAccount("account-a", now.Add(-2*time.Hour), 1)) - observations, err := router.Observe(context.Background(), []string{"account-a"}, ObserveOptions{MaxAge: time.Hour, Now: now}) - if err != nil { - t.Fatal(err) - } - if len(observations) != 1 || observations[0].Telemetry.HasFreshCapacityWithin(now, time.Hour) { - t.Fatalf("stale unregistered account was eligible: %#v", observations) - } -} - -func observedAccount(accountID string, observedAt time.Time, usage float64) AccountTelemetry { - return AccountTelemetry{ - AccountID: accountID, - ObservedAt: observedAt, - IsUsable: true, - Limits: map[string]LimitTelemetry{"codex": {LimitID: "codex", Windows: []WindowTelemetry{{Kind: PrimaryWindow, UsedPercent: usage, ObservedAt: observedAt, Freshness: WindowFresh}}}}, - } -} diff --git a/controller/internal/telemetry/telemetry_test.go b/controller/internal/telemetry/telemetry_test.go deleted file mode 100644 index c4cd43ec..00000000 --- a/controller/internal/telemetry/telemetry_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package telemetry - -import ( - "encoding/json" - "testing" - "time" -) - -func TestSparseUpdateAbsentLimitIDIsAmbiguousWithoutMutation(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - store := NewStateStore(func() time.Time { return now }) - store.Set("account-a", AccountTelemetry{ - AccountID: "account-a", - IsUsable: true, - Limits: map[string]LimitTelemetry{ - "codex": testLimit("codex", 10, now.Add(time.Hour)), - "other": testLimit("other", 20, now.Add(2*time.Hour)), - }, - Aggregate: ptrLimit(testLimit("codex", 10, now.Add(time.Hour))), - }) - - result := store.ApplySparseUpdate("account-a", AccountRateLimitsUpdatedWire{ - RateLimits: RateLimitSnapshotWire{ - Primary: &RateLimitWindowWire{UsedPercent: 99}, - }, - }) - if result.Outcome != SparseUpdateRefetchRequired || !result.RefetchRequired { - t.Fatalf("expected ambiguous update to request refetch, got %#v", result) - } - - snapshot, ok := store.Get("account-a") - if !ok { - t.Fatal("snapshot disappeared after ambiguous update") - } - if got := snapshot.Limits["codex"].Windows[0].UsedPercent; got != 10 { - t.Fatalf("ambiguous update mutated codex bucket: got %v", got) - } - if got := snapshot.Limits["other"].Windows[0].UsedPercent; got != 20 { - t.Fatalf("ambiguous update mutated other bucket: got %v", got) - } -} - -func TestSparseUpdateByIDDoesNotOverwriteDifferentAggregate(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - store := NewStateStore(func() time.Time { return now }) - store.Set("account-a", AccountTelemetry{ - AccountID: "account-a", - IsUsable: true, - Limits: map[string]LimitTelemetry{ - "codex": testLimit("codex", 10, now.Add(time.Hour)), - "team": testLimit("team", 20, now.Add(2*time.Hour)), - }, - Aggregate: ptrLimit(testLimit("codex", 10, now.Add(time.Hour))), - }) - limitID := "team" - result := store.ApplySparseUpdate("account-a", AccountRateLimitsUpdatedWire{ - RateLimits: RateLimitSnapshotWire{ - LimitID: &limitID, - Primary: &RateLimitWindowWire{UsedPercent: 42}, - }, - }) - if !result.Applied() { - t.Fatalf("expected targeted update to apply, got %#v", result) - } - snapshot, _ := store.Get("account-a") - if got := snapshot.Limits["team"].Windows[0].UsedPercent; got != 42 { - t.Fatalf("targeted team update missing: got %v", got) - } - if got := snapshot.Aggregate.Windows[0].UsedPercent; got != 10 { - t.Fatalf("targeted team update overwrote aggregate: got %v", got) - } -} - -func TestPerWindowStalenessIsIndependent(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - primaryReset := now.Add(-time.Minute) - secondaryReset := now.Add(time.Hour) - limit := LimitTelemetry{ - LimitID: "codex", - Windows: []WindowTelemetry{ - {Kind: PrimaryWindow, UsedPercent: 100, ResetsAt: &primaryReset, Freshness: WindowFresh}, - {Kind: SecondaryWindow, UsedPercent: 30, ResetsAt: &secondaryReset, Freshness: WindowFresh}, - }, - } - account := AccountTelemetry{IsUsable: true, Limits: map[string]LimitTelemetry{"codex": limit}} - account.RefreshStaleness(now) - windows := account.Limits["codex"].Windows - if windows[0].Freshness != WindowStale { - t.Fatalf("primary window should be stale: %#v", windows[0]) - } - if windows[1].Freshness != WindowFresh { - t.Fatalf("secondary window should remain fresh: %#v", windows[1]) - } -} - -func TestSnapshotCacheDeepCopiesIngressAndEgress(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - cache := NewSnapshotCache(time.Hour, func() time.Time { return now }) - resetAt := now.Add(time.Hour) - source := AccountTelemetry{ - AccountID: "account-a", - IsUsable: true, - Limits: map[string]LimitTelemetry{ - "codex": { - LimitID: "codex", - Windows: []WindowTelemetry{{Kind: PrimaryWindow, UsedPercent: 10, ResetsAt: &resetAt}}, - }, - }, - } - cache.Set("account-a", source) - source.Limits["codex"].Windows[0].UsedPercent = 77 - first, ok := cache.Get("account-a") - if !ok { - t.Fatal("cache miss after Set") - } - first.Limits["codex"].Windows[0].UsedPercent = 88 - if first.Limits["codex"].Windows[0].ResetsAt == nil { - t.Fatal("deep copy lost reset timestamp") - } - *first.Limits["codex"].Windows[0].ResetsAt = now.Add(4 * time.Hour) - second, ok := cache.Get("account-a") - if !ok { - t.Fatal("cache miss after caller mutation") - } - if got := second.Limits["codex"].Windows[0].UsedPercent; got != 10 { - t.Fatalf("caller mutation changed cache usage: got %v", got) - } - if got := second.Limits["codex"].Windows[0].ResetsAt; !got.Equal(resetAt) { - t.Fatalf("caller mutation changed cache reset: got %v", got) - } -} - -func TestWireWindowExplicitZeroIsPresent(t *testing.T) { - var wire RateLimitWindowWire - if err := json.Unmarshal([]byte(`{"usedPercent":0,"windowDurationMins":null,"resetsAt":null}`), &wire); err != nil { - t.Fatal(err) - } - if !wire.HasUsedPercent() { - t.Fatal("explicit zero usage was treated as absent") - } -} - -func TestSparseUpdateMissingUsageRetainsPriorWindowValue(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - store := NewStateStore(func() time.Time { return now }) - store.Set("account-a", AccountTelemetry{ - AccountID: "account-a", - IsUsable: true, - Limits: map[string]LimitTelemetry{ - "codex": testLimit("codex", 65, now.Add(time.Hour)), - }, - }) - var update AccountRateLimitsUpdatedWire - if err := json.Unmarshal([]byte(`{"rateLimits":{"limitId":"codex","primary":{"resetsAt":1725454800}}}`), &update); err != nil { - t.Fatal(err) - } - result := store.ApplySparseUpdate("account-a", update) - if !result.Applied() { - t.Fatalf("expected sparse metadata update to apply, got %#v", result) - } - snapshot, _ := store.Get("account-a") - if got := snapshot.Limits["codex"].Windows[0].UsedPercent; got != 65 { - t.Fatalf("missing usage field reset prior usage to %v", got) - } -} - -func TestSnapshotCacheHonorsTTLWithInjectedClock(t *testing.T) { - now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) - cache := NewSnapshotCache(time.Minute, func() time.Time { return now }) - cache.Set("account-a", AccountTelemetry{AccountID: "account-a", IsUsable: true}) - if _, ok := cache.Get("account-a"); !ok { - t.Fatal("cache unexpectedly missed before TTL") - } - now = now.Add(time.Minute) - if _, ok := cache.Get("account-a"); ok { - t.Fatal("cache returned an entry at its TTL boundary") - } -} - -func testLimit(id string, usage float64, reset time.Time) LimitTelemetry { - window := WindowTelemetry{Kind: PrimaryWindow, UsedPercent: usage, ResetsAt: &reset, Freshness: WindowFresh} - return LimitTelemetry{LimitID: id, Windows: []WindowTelemetry{window}} -} - -func ptrLimit(limit LimitTelemetry) *LimitTelemetry { return &limit } diff --git a/controller/internal/telemetry/updates.go b/controller/internal/telemetry/updates.go deleted file mode 100644 index d09ebd47..00000000 --- a/controller/internal/telemetry/updates.go +++ /dev/null @@ -1,414 +0,0 @@ -package telemetry - -import ( - "sort" - "strings" - "sync" - "time" -) - -// SparseUpdateOutcome describes whether a rolling notification could be -// attributed without guessing. -type SparseUpdateOutcome uint8 - -const ( - SparseUpdateApplied SparseUpdateOutcome = iota - SparseUpdateRefetchRequired - SparseUpdateNoState -) - -func (o SparseUpdateOutcome) String() string { - switch o { - case SparseUpdateApplied: - return "applied" - case SparseUpdateRefetchRequired: - return "refetch_required" - case SparseUpdateNoState: - return "no_state" - default: - return "unknown" - } -} - -// SparseUpdateResult gives the runtime adapter enough information to issue a -// complete account/rateLimits/read when a sparse update is ambiguous. -type SparseUpdateResult struct { - Outcome SparseUpdateOutcome - AccountID string - TargetLimitID string - Reason string - RefetchRequired bool - Snapshot AccountTelemetry -} - -func (r SparseUpdateResult) Applied() bool { return r.Outcome == SparseUpdateApplied } - -// StateStore retains the most recent normalized snapshot per account. It is -// also the authoritative merge point for sparse rolling updates. -type StateStore struct { - mu sync.RWMutex - snapshots map[string]AccountTelemetry - now func() time.Time -} - -// NewStateStore creates a store. An optional clock makes reset-boundary tests -// deterministic; if omitted, UTC wall time is used. -func NewStateStore(clock ...func() time.Time) *StateStore { - return &StateStore{ - snapshots: make(map[string]AccountTelemetry), - now: selectClock(clock...), - } -} - -// Set stores a full snapshot after cloning it. The caller may safely reuse or -// mutate its input after this method returns. -func (s *StateStore) Set(accountID string, snapshot AccountTelemetry) { - if s == nil { - return - } - clone := snapshot.Clone() - if clone.AccountID == "" { - clone.AccountID = accountID - } - if accountID == "" { - accountID = clone.AccountID - } - s.mu.Lock() - if s.snapshots == nil { - s.snapshots = make(map[string]AccountTelemetry) - } - s.snapshots[accountID] = clone - s.mu.Unlock() -} - -// Put is an alias for Set. -func (s *StateStore) Put(accountID string, snapshot AccountTelemetry) { s.Set(accountID, snapshot) } - -// ApplyFullSnapshot replaces the account state and is intentionally distinct -// from ApplySparseUpdate in runtime adapters. -func (s *StateStore) ApplyFullSnapshot(accountID string, snapshot AccountTelemetry) { - s.Set(accountID, snapshot) -} - -// Get returns a deep copy and computes reset-boundary freshness on that copy. -func (s *StateStore) Get(accountID string) (AccountTelemetry, bool) { - if s == nil { - return AccountTelemetry{}, false - } - s.mu.RLock() - snapshot, ok := s.snapshots[accountID] - s.mu.RUnlock() - if !ok { - return AccountTelemetry{}, false - } - clone := snapshot.Clone() - clone.RefreshStaleness(s.currentTime()) - return clone, true -} - -// Snapshot is an alias for Get. -func (s *StateStore) Snapshot(accountID string) (AccountTelemetry, bool) { - return s.Get(accountID) -} - -// Delete removes one account's observed state. -func (s *StateStore) Delete(accountID string) { - if s == nil { - return - } - s.mu.Lock() - delete(s.snapshots, accountID) - s.mu.Unlock() -} - -// Accounts returns account IDs in deterministic order. -func (s *StateStore) Accounts() []string { - if s == nil { - return nil - } - s.mu.RLock() - accounts := make([]string, 0, len(s.snapshots)) - for accountID := range s.snapshots { - accounts = append(accounts, accountID) - } - s.mu.RUnlock() - sort.Strings(accounts) - return accounts -} - -// ApplySparseUpdate applies a rolling account/rateLimits/updated payload only -// after resolving its target bucket. When the limit ID is absent and more -// than one bucket is known, no field is mutated and RefetchRequired is true. -func (s *StateStore) ApplySparseUpdate(accountID string, update AccountRateLimitsUpdatedWire) SparseUpdateResult { - result := SparseUpdateResult{ - Outcome: SparseUpdateNoState, - AccountID: accountID, - } - if s == nil { - result.Reason = "state store is nil" - return result - } - - observedAt := s.currentTime() - s.mu.Lock() - existing, ok := s.snapshots[accountID] - if !ok { - s.mu.Unlock() - result.Outcome = SparseUpdateNoState - result.Reason = "no full snapshot is available" - result.RefetchRequired = true - return result - } - - targetKey, targetID, updateAggregate, reason := resolveSparseTarget(existing, update.RateLimits) - result.TargetLimitID = targetID - if reason != "" { - s.mu.Unlock() - result.Outcome = SparseUpdateRefetchRequired - result.RefetchRequired = true - result.Reason = reason - result.Snapshot = existing.Clone() - return result - } - - // Resolve first, then mutate. This ordering is important: an ambiguous - // notification must leave both aggregate and by-ID state untouched. - updated := existing.Clone() - limit, present := updated.Limits[targetKey] - if !present { - if updated.Aggregate == nil { - s.mu.Unlock() - result.Outcome = SparseUpdateRefetchRequired - result.RefetchRequired = true - result.Reason = "resolved bucket is not present" - result.Snapshot = existing.Clone() - return result - } - limit = updated.Aggregate.Clone() - } - mergeLimitSparse(&limit, update.RateLimits, observedAt) - updated.Limits[targetKey] = limit.Clone() - - // The aggregate view is coherent only when the target is the aggregate - // bucket. A by-ID update for bucket B must not overwrite aggregate bucket A. - if updateAggregate && updated.Aggregate != nil { - aggregate := updated.Aggregate.Clone() - mergeLimitSparse(&aggregate, update.RateLimits, observedAt) - updated.Aggregate = &aggregate - if aggregateKey := aggregateKeyFor(updated, targetKey); aggregateKey != "" { - updated.Limits[aggregateKey] = aggregate.Clone() - } - } - - updated.ObservedAt = observedAt - updated.AccountID = accountID - s.snapshots[accountID] = updated.Clone() - result.Outcome = SparseUpdateApplied - result.Snapshot = updated.Clone() - s.mu.Unlock() - return result -} - -// ApplySparseWire is a readable alias for protocol adapters. -func (s *StateStore) ApplySparseWire(accountID string, update AccountRateLimitsUpdatedWire) SparseUpdateResult { - return s.ApplySparseUpdate(accountID, update) -} - -func resolveSparseTarget(existing AccountTelemetry, incoming RateLimitSnapshotWire) (key, id string, updateAggregate bool, reason string) { - if incoming.LimitID != nil && strings.TrimSpace(*incoming.LimitID) != "" { - id = *incoming.LimitID - if _, ok := existing.Limits[id]; ok { - return id, id, sameAggregateID(existing, id), "" - } - if existing.Aggregate != nil && existing.Aggregate.LimitID == id { - aggregateKey := aggregateKeyFor(existing, "") - if aggregateKey == "" { - aggregateKey = AggregateLimitKey - } - return aggregateKey, id, true, "" - } - return "", id, false, "incoming limitId does not match a known bucket" - } - - keys := bucketKeys(existing) - switch len(keys) { - case 0: - if existing.Aggregate != nil { - key := aggregateKeyFor(existing, "") - if key == "" { - key = AggregateLimitKey - } - return key, existing.Aggregate.LimitID, true, "" - } - if _, ok := existing.Limits[AggregateLimitKey]; ok { - return AggregateLimitKey, "", true, "" - } - return "", "", false, "no bucket can receive an aggregate sparse update" - case 1: - key = keys[0] - limit := existing.Limits[key] - if existing.Aggregate != nil && existing.Aggregate.LimitID != "" { - id = existing.Aggregate.LimitID - } else { - id = limit.LimitID - } - return key, id, sameAggregateKey(existing, key), "" - default: - return "", "", false, "limitId is absent and bucket attribution is ambiguous" - } -} - -func bucketKeys(snapshot AccountTelemetry) []string { - keys := make([]string, 0, len(snapshot.Limits)) - for key := range snapshot.Limits { - if key == AggregateLimitKey { - continue - } - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -func sameAggregateID(snapshot AccountTelemetry, id string) bool { - if snapshot.Aggregate == nil { - return false - } - if snapshot.Aggregate.LimitID == id { - return true - } - return snapshot.Aggregate.LimitID == "" && len(bucketKeys(snapshot)) == 1 -} - -func sameAggregateKey(snapshot AccountTelemetry, key string) bool { - if snapshot.Aggregate == nil { - return false - } - if key == AggregateLimitKey { - return true - } - if snapshot.Aggregate.LimitID == "" && len(bucketKeys(snapshot)) == 1 { - return true - } - return snapshot.Aggregate.LimitID != "" && snapshot.Aggregate.LimitID == key -} - -func aggregateKeyFor(snapshot AccountTelemetry, preferred string) string { - if preferred != "" { - if _, ok := snapshot.Limits[preferred]; ok { - return preferred - } - } - if snapshot.Aggregate != nil && snapshot.Aggregate.LimitID != "" { - if _, ok := snapshot.Limits[snapshot.Aggregate.LimitID]; ok { - return snapshot.Aggregate.LimitID - } - } - if _, ok := snapshot.Limits[AggregateLimitKey]; ok { - return AggregateLimitKey - } - if len(snapshot.Limits) == 1 { - for key := range snapshot.Limits { - return key - } - } - return "" -} - -func mergeLimitSparse(target *LimitTelemetry, incoming RateLimitSnapshotWire, observedAt time.Time) { - if target == nil { - return - } - if incoming.LimitID != nil { - target.LimitID = *incoming.LimitID - } - if incoming.LimitName != nil { - target.LimitName = *incoming.LimitName - } - if incoming.PlanType != nil { - target.PlanType = *incoming.PlanType - } - if incoming.RateLimitReachedType != nil { - target.RateLimitReachedType = *incoming.RateLimitReachedType - } - - windows := target.WindowList() - mergeWindow := func(kind WindowKind, wire *RateLimitWindowWire) { - if wire == nil { - return - } - index := -1 - for i := range windows { - if windows[i].Kind == kind { - index = i - break - } - } - if index < 0 { - // A future sparse payload may omit usedPercent even though the - // current schema requires it. Do not create a new window with an - // invented zero usage value; the next full read must establish that - // window's usage authoritatively. - if !wire.HasUsedPercent() { - return - } - windows = append(windows, NormalizeWindow(*wire, kind, observedAt)) - return - } - window := &windows[index] - // A non-nil nested wire object carries the usage value, including an - // explicit zero after a real server reset. Nullable metadata is merged - // only when supplied so sparse updates never erase known values. If a - // future payload omits usedPercent, retain the prior authoritative usage - // instead of interpreting the Go zero value as a reset. - if wire.HasUsedPercent() { - window.UsedPercent = wire.UsedPercent - } - if wire.WindowDurationMins != nil { - window.WindowDurationMins = cloneInt64(wire.WindowDurationMins) - } - if wire.ResetsAt != nil { - reset := time.Unix(*wire.ResetsAt, 0).UTC() - window.ResetsAt = &reset - } - window.ObservedAt = observedAt - window.Freshness = WindowFresh - window.RefreshStaleness(observedAt) - } - mergeWindow(PrimaryWindow, incoming.Primary) - mergeWindow(SecondaryWindow, incoming.Secondary) - target.Windows = windows - syncConvenienceWindows(target) - target.RefreshStaleness(observedAt) -} - -func syncConvenienceWindows(limit *LimitTelemetry) { - if limit == nil { - return - } - limit.Primary = nil - limit.Secondary = nil - for _, window := range limit.Windows { - clone := window.Clone() - switch clone.Kind { - case PrimaryWindow: - limit.Primary = &clone - case SecondaryWindow: - limit.Secondary = &clone - } - } -} - -func selectClock(clocks ...func() time.Time) func() time.Time { - if len(clocks) > 0 && clocks[0] != nil { - return clocks[0] - } - return func() time.Time { return time.Now().UTC() } -} - -func (s *StateStore) currentTime() time.Time { - if s == nil || s.now == nil { - return time.Now().UTC() - } - return s.now() -} diff --git a/controller/internal/telemetry/wire.go b/controller/internal/telemetry/wire.go deleted file mode 100644 index b619a93a..00000000 --- a/controller/internal/telemetry/wire.go +++ /dev/null @@ -1,123 +0,0 @@ -// Package telemetry contains the controller-side representation of the -// account rate-limit data exposed by the Codext app-server. -package telemetry - -import ( - "bytes" - "encoding/json" - "errors" - "io" -) - -// RateLimitWindowWire is the deliberately small subset of the Codext wire -// schema consumed by the controller. A duration and reset timestamp are -// nullable in the upstream protocol; a nil value means that the server did -// not provide that piece of metadata. -// -// UsedPercent is kept as a number (rather than an integer) because the core -// protocol can originate fractional values even though the current app-server -// response rounds them for some clients. -type RateLimitWindowWire struct { - UsedPercent float64 `json:"usedPercent"` - WindowDurationMins *int64 `json:"windowDurationMins"` - ResetsAt *int64 `json:"resetsAt"` - - // usedPercentSet lets the decoder distinguish an explicit zero from an - // omitted field when a sparse rolling update is decoded. The field is - // intentionally private so callers can continue to construct the canonical - // wire type with a normal struct literal. - usedPercentSet bool -} - -// UnmarshalJSON preserves presence information for UsedPercent. The -// upstream sparse notification normally contains a complete window object, -// but clients must also tolerate a future response that omits individual -// fields. -func (w *RateLimitWindowWire) UnmarshalJSON(data []byte) error { - var decoded struct { - UsedPercent *float64 `json:"usedPercent"` - WindowDurationMins *int64 `json:"windowDurationMins"` - ResetsAt *int64 `json:"resetsAt"` - } - if err := json.Unmarshal(data, &decoded); err != nil { - return err - } - - // Reset all fields first: this method may be called more than once on a - // reused value. - *w = RateLimitWindowWire{ - WindowDurationMins: decoded.WindowDurationMins, - ResetsAt: decoded.ResetsAt, - usedPercentSet: decoded.UsedPercent != nil, - } - if decoded.UsedPercent != nil { - w.UsedPercent = *decoded.UsedPercent - } - return nil -} - -// HasUsedPercent reports whether a decoded wire window explicitly carried a -// usage value. A non-zero struct literal is also treated as present, which -// keeps hand-written update tests and adapters ergonomic. -func (w RateLimitWindowWire) HasUsedPercent() bool { - return w.usedPercentSet || w.UsedPercent != 0 -} - -// RateLimitSnapshotWire is the controller's MVP view of one metered bucket. -// Unknown upstream fields are intentionally ignored by encoding/json. -type RateLimitSnapshotWire struct { - LimitID *string `json:"limitId"` - LimitName *string `json:"limitName"` - PlanType *string `json:"planType"` - RateLimitReachedType *string `json:"rateLimitReachedType"` - Primary *RateLimitWindowWire `json:"primary"` - Secondary *RateLimitWindowWire `json:"secondary"` -} - -// GetAccountRateLimitsResponseWire is the response from -// account/rateLimits/read. RateLimits is the historical aggregate view; -// RateLimitsByID is the multi-bucket representation keyed by metered limit -// ID. A nil map means the server did not send the multi-bucket view. -type GetAccountRateLimitsResponseWire struct { - RateLimits RateLimitSnapshotWire `json:"rateLimits"` - RateLimitsByID map[string]RateLimitSnapshotWire `json:"rateLimitsByLimitId"` -} - -// AccountRateLimitsUpdatedWire is the sparse rolling notification emitted by -// the runtime. The account identity is supplied by the runtime connection, -// not by this notification. -type AccountRateLimitsUpdatedWire struct { - RateLimits RateLimitSnapshotWire `json:"rateLimits"` -} - -// Short aliases make the protocol vocabulary convenient at package -// boundaries while retaining the explicit Wire names for adapters. -type RateLimitWindow = RateLimitWindowWire -type RateLimitSnapshot = RateLimitSnapshotWire -type GetAccountRateLimitsResponse = GetAccountRateLimitsResponseWire -type AccountRateLimitsUpdated = AccountRateLimitsUpdatedWire - -// DecodeRateLimitsResponse is a small convenience used by IPC adapters. It -// rejects trailing JSON values while leaving unknown object fields ignored, -// matching normal Go JSON decoding semantics. -func DecodeRateLimitsResponse(data []byte) (GetAccountRateLimitsResponseWire, error) { - var response GetAccountRateLimitsResponseWire - decoder := json.NewDecoder(bytes.NewReader(data)) - if err := decoder.Decode(&response); err != nil { - return GetAccountRateLimitsResponseWire{}, err - } - var extra any - if err := decoder.Decode(&extra); err == nil { - return GetAccountRateLimitsResponseWire{}, &TrailingJSONError{} - } else if !errors.Is(err, io.EOF) { - return GetAccountRateLimitsResponseWire{}, err - } - return response, nil -} - -// TrailingJSONError indicates that a wire payload contained more than one -// JSON value. It is kept as a concrete type so callers can classify malformed -// IPC frames without matching an implementation-specific error string. -type TrailingJSONError struct{} - -func (*TrailingJSONError) Error() string { return "trailing JSON data" } diff --git a/controller/internal/transitions/coordinator.go b/controller/internal/transitions/coordinator.go deleted file mode 100644 index e67c84ec..00000000 --- a/controller/internal/transitions/coordinator.go +++ /dev/null @@ -1,704 +0,0 @@ -package transitions - -import ( - "context" - "errors" - "fmt" - "time" - - "codexmarathon/controller/internal/credentials" - "codexmarathon/controller/internal/journal" - "codexmarathon/controller/internal/runtime" -) - -// RequestTransition creates one controller-owned transition and drives it to -// a terminal result. The transition ID and expected generation are allocated -// here; callers cannot supply either value and therefore cannot accidentally -// reuse an old command correlation. -// -// If the runtime reports an active turn, the method waits for the matching -// safe-boundary event (or a runtime state read showing zero active turns) while -// retaining the prepared transition. A caller can also deliver the event via -// HandleEvent. Context cancellation leaves the transition unresolved so a -// later Reconcile call can determine whether the runtime accepted the intent. -func (c *Coordinator) RequestTransition(ctx context.Context, targetAccountID string) (*TransitionResult, error) { - if err := c.validateDependencies(); err != nil { - return nil, err - } - if targetAccountID == "" { - return nil, errors.New("target account id is required") - } - if ctx == nil { - ctx = context.Background() - } - - initial, err := c.runtime.GetRuntimeState(ctx) - if err != nil { - return nil, fmt.Errorf("read runtime state before transition: %w", err) - } - if err := validateRuntimeIdentity(initial.Identity); err != nil { - return nil, err - } - if initial.Identity.AuthGeneration == ^uint64(0) { - return nil, fmt.Errorf("%w: generation overflow", ErrGenerationMismatch) - } - - c.mu.Lock() - if pending := c.activeLocked(); pending != nil { - c.mu.Unlock() - return nil, fmt.Errorf("%w: %s", ErrTransitionInProgress, pending.state.TransitionID) - } - // A request for the identity already held by the runtime is a verified - // no-op. It does not consume a new generation or touch auth.json. - if initial.Identity.AccountID != nil && *initial.Identity.AccountID == targetAccountID && initial.PendingTransition == nil { - result := TransitionResult{ - TargetAccountID: targetAccountID, - AdoptedAccountID: targetAccountID, - ExpectedGeneration: initial.Identity.AuthGeneration, - FinalGeneration: initial.Identity.AuthGeneration, - Outcome: TransitionCommitted, - CompletedAt: c.nowUTC(), - RuntimeID: initial.Identity.RuntimeID, - DiskAccountID: targetAccountID, - Reason: "target identity is already active", - } - c.mu.Unlock() - return &result, nil - } - if initial.PendingTransition != nil { - pendingID := initial.PendingTransition.TransitionID - c.mu.Unlock() - return nil, fmt.Errorf("%w: runtime has pending transition %s", ErrTransitionInProgress, pendingID) - } - transitionID := c.nextTransitionID() - params := runtime.AuthTransitionParams{ - TransitionID: transitionID, - TargetAccountID: targetAccountID, - ExpectedGeneration: initial.Identity.AuthGeneration + 1, - } - now := c.nowUTC() - record := &transitionRecord{ - state: State{ - TransitionID: transitionID, - RuntimeID: initial.Identity.RuntimeID, - CurrentAccountID: accountID(initial.Identity.AccountID), - TargetAccountID: targetAccountID, - ExpectedGeneration: params.ExpectedGeneration, - Phase: PhasePrepared, - CreatedAt: now, - UpdatedAt: now, - ActiveTurnCount: initial.ActiveTurnCount, - }, - oldIdentity: initial.Identity, - params: params, - } - record.state.DiskAccountID = record.state.CurrentAccountID - c.transitions[transitionID] = record - c.current = cloneRecordState(record) - c.mu.Unlock() - - if err := c.append(journal.Event{ - Type: journal.TransitionCreated, - TransitionID: transitionID, - AuthGeneration: initial.Identity.AuthGeneration, - ExpectedGeneration: params.ExpectedGeneration, - RuntimeID: initial.Identity.RuntimeID, - FromAccountID: record.state.CurrentAccountID, - TargetAccountID: targetAccountID, - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal transition creation: %w", err)) - } - - prepared, err := c.runtime.PrepareAuthTransition(ctx, params) - if err != nil { - return c.markUncertain(transitionID, fmt.Errorf("prepare transition: %w", err)) - } - if prepared.Outcome == runtime.TransitionRejected { - return c.finishRejected(transitionID, prepared, errors.New("runtime rejected transition preparation")) - } - if prepared.Outcome == runtime.TransitionUncertain { - return c.markUncertain(transitionID, errors.New("runtime returned uncertain preparation outcome")) - } - if err := c.validateTransitionAck(prepared, record, false); err != nil { - // A typed rejected result is terminal only when the runtime gave us a - // correlated response. Correlation or generation failures leave the - // state uncertain because the command may have been accepted. - if prepared.Outcome == runtime.TransitionRejected { - return c.finishRejected(transitionID, prepared, err) - } - return c.markUncertain(transitionID, err) - } - c.mu.Lock() - record = c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - record.state.PrepareSent = true - record.state.UpdatedAt = c.nowUTC() - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - c.mu.Unlock() - if err := c.append(journal.Event{ - Type: journal.TransitionPrepared, - TransitionID: transitionID, - AuthGeneration: initial.Identity.AuthGeneration, - RuntimeID: initial.Identity.RuntimeID, - FromAccountID: record.state.CurrentAccountID, - TargetAccountID: targetAccountID, - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal transition preparation: %w", err)) - } - - if err := c.waitForSafeBoundary(ctx, transitionID, initial.ActiveTurnCount); err != nil { - return c.markUncertain(transitionID, err) - } - return c.deployAndCommit(ctx, transitionID) -} - -// Request is a concise alias for RequestTransition. -func (c *Coordinator) Request(ctx context.Context, targetAccountID string) (*TransitionResult, error) { - return c.RequestTransition(ctx, targetAccountID) -} - -func (c *Coordinator) nextTransitionID() string { - // idGenerator is configured at construction and is expected to be - // side-effect free. A local fallback protects a zero-value Coordinator - // from panicking while still producing unique IDs. - if c.idGenerator != nil { - return c.idGenerator() - } - return fmt.Sprintf("tx-%d", c.sequence.Add(1)) -} - -func (c *Coordinator) activeLocked() *transitionRecord { - for _, record := range c.transitions { - switch record.state.Phase { - case PhasePrepared, PhaseWaitingForBoundary, PhaseDeploying, PhaseCommitSent, PhaseUncertain: - return record - } - } - return nil -} - -func cloneRecordState(record *transitionRecord) *State { - if record == nil { - return nil - } - state := record.state - return &state -} - -func accountID(value *string) string { - if value == nil { - return "" - } - return *value -} - -func validateRuntimeIdentity(identity runtime.Identity) error { - if identity.RuntimeID == "" { - return errors.New("runtime id is required") - } - return nil -} - -func (c *Coordinator) validateTransitionAck(ack runtime.TransitionResult, record *transitionRecord, requireCommitted bool) error { - if record == nil { - return ErrTransitionNotFound - } - if ack.TransitionID != record.params.TransitionID { - return fmt.Errorf("%w: expected %q, got %q", ErrTransitionIDMismatch, record.params.TransitionID, ack.TransitionID) - } - if ack.RuntimeID != "" && ack.RuntimeID != record.state.RuntimeID { - return fmt.Errorf("%w: expected %q, got %q", ErrRuntimeIDMismatch, record.state.RuntimeID, ack.RuntimeID) - } - if ack.ExpectedGeneration != 0 && ack.ExpectedGeneration != record.params.ExpectedGeneration { - return fmt.Errorf("%w: expected transition generation %d, got %d", ErrGenerationMismatch, record.params.ExpectedGeneration, ack.ExpectedGeneration) - } - if ack.Outcome == runtime.TransitionRejected { - if requireCommitted { - return fmt.Errorf("runtime rejected transition") - } - return nil - } - if requireCommitted && ack.Outcome != runtime.TransitionCommitted { - return fmt.Errorf("runtime returned %q, want committed", ack.Outcome) - } - return nil -} - -func (c *Coordinator) waitForSafeBoundary(ctx context.Context, transitionID string, initialActive int) error { - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return ErrTransitionNotFound - } - if initialActive <= 0 { - record.state.ActiveTurnCount = 0 - record.state.Phase = PhaseDeploying - record.state.UpdatedAt = c.nowUTC() - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - c.mu.Unlock() - return nil - } - record.state.Phase = PhaseWaitingForBoundary - record.state.UpdatedAt = c.nowUTC() - boundary := c.boundary[transitionID] - if boundary == nil { - boundary = make(chan struct{}, 1) - c.boundary[transitionID] = boundary - } - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - c.mu.Unlock() - - var events <-chan runtime.Event - if source, ok := c.runtime.(RuntimeEvents); ok { - events = source.Events() - } - var timer *time.Timer - if events == nil { - timer = time.NewTimer(c.boundaryPollInterval) - defer timer.Stop() - } - for { - select { - case <-ctx.Done(): - return fmt.Errorf("%w: %v", ErrSafeBoundaryPending, ctx.Err()) - case <-boundary: - c.markBoundaryReached(transitionID) - return nil - case event, ok := <-events: - if !ok { - return fmt.Errorf("%w: runtime event stream closed", ErrSafeBoundaryPending) - } - matched, err := c.consumeBoundaryEvent(event, transitionID) - if err != nil { - return err - } - if matched { - c.markBoundaryReached(transitionID) - return nil - } - case <-timerC(timer): - state, err := c.runtime.GetRuntimeState(ctx) - if err != nil { - return fmt.Errorf("read runtime state while waiting for safe boundary: %w", err) - } - if state.Identity.RuntimeID != c.runtimeIDFor(transitionID) { - return fmt.Errorf("%w: runtime changed while waiting", ErrRuntimeIDMismatch) - } - if state.Identity.AuthGeneration != c.oldGenerationFor(transitionID) { - return fmt.Errorf("%w: runtime generation changed while waiting", ErrGenerationMismatch) - } - if state.ActiveTurnCount <= 0 { - c.markBoundaryReached(transitionID) - return nil - } - c.mu.Lock() - if record := c.transitions[transitionID]; record != nil { - record.state.ActiveTurnCount = state.ActiveTurnCount - record.state.UpdatedAt = c.nowUTC() - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - } - c.mu.Unlock() - resetTimer(timer, c.boundaryPollInterval) - } - } -} - -func timerC(timer *time.Timer) <-chan time.Time { - if timer == nil { - return nil - } - return timer.C -} - -func resetTimer(timer *time.Timer, duration time.Duration) { - if timer == nil { - return - } - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - timer.Reset(duration) -} - -func (c *Coordinator) runtimeIDFor(transitionID string) string { - c.mu.Lock() - defer c.mu.Unlock() - if record := c.transitions[transitionID]; record != nil { - return record.state.RuntimeID - } - return "" -} - -func (c *Coordinator) oldGenerationFor(transitionID string) uint64 { - c.mu.Lock() - defer c.mu.Unlock() - if record := c.transitions[transitionID]; record != nil { - return record.oldIdentity.AuthGeneration - } - return 0 -} - -func (c *Coordinator) markBoundaryReached(transitionID string) { - c.mu.Lock() - defer c.mu.Unlock() - if record := c.transitions[transitionID]; record != nil { - if record.state.Phase == PhaseWaitingForBoundary || record.state.Phase == PhasePrepared { - record.state.ActiveTurnCount = 0 - record.state.Phase = PhaseDeploying - record.state.UpdatedAt = c.nowUTC() - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - } - } -} - -func (c *Coordinator) deployAndCommit(ctx context.Context, transitionID string) (*TransitionResult, error) { - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - params := record.params - currentAccountID := record.state.CurrentAccountID - c.mu.Unlock() - - // AuthManager may have refreshed Account A while the turn was running. - // Synchronize that native snapshot into the same protected vault used by - // the deployer before reading/deploying B. The opaque bytes never enter - // transition state or the journal, and a mismatched native identity leaves - // the transition uncertain for three-way reconciliation. - if err := c.syncActiveSnapshot(ctx, currentAccountID); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("synchronize active auth snapshot: %w", err)) - } - - deployed, err := c.deployer.Deploy(params.TargetAccountID) - if err != nil { - // Nothing after a failed deploy is assumed to have happened. A - // deployer may report an uncertain directory sync, however, so keep - // the transition unresolved and let reconciliation inspect disk. - return c.markUncertain(transitionID, fmt.Errorf("deploy target credentials: %w", err)) - } - c.mu.Lock() - record = c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - record.state.DiskAccountID = deployed.AccountID - if record.state.DiskAccountID == "" { - record.state.DiskAccountID = params.TargetAccountID - } - record.state.UpdatedAt = c.nowUTC() - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - diskAccountID := record.state.DiskAccountID - runtimeID := record.state.RuntimeID - oldGeneration := record.oldIdentity.AuthGeneration - c.mu.Unlock() - if err := c.append(journal.Event{ - Type: journal.AuthDeployed, - TransitionID: transitionID, - AuthGeneration: params.ExpectedGeneration, - RuntimeID: runtimeID, - AccountID: diskAccountID, - TargetAccountID: params.TargetAccountID, - Reason: "atomic credential deployment completed", - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal credential deployment: %w", err)) - } - - c.mu.Lock() - record = c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - record.state.CommitSent = true - record.state.CommitAttempts++ - record.state.Phase = PhaseCommitSent - record.state.UpdatedAt = c.nowUTC() - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - attempt := record.state.CommitAttempts - c.mu.Unlock() - if err := c.append(journal.Event{ - Type: journal.CommitSent, - TransitionID: transitionID, - AuthGeneration: params.ExpectedGeneration, - RuntimeID: runtimeID, - TargetAccountID: params.TargetAccountID, - Reason: fmt.Sprintf("commit attempt %d", attempt), - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal commit request: %w", err)) - } - - ack, err := c.runtime.CommitAuthTransition(ctx, params) - if err != nil { - return c.markUncertain(transitionID, fmt.Errorf("commit transition: %w", err)) - } - if err := c.validateTransitionAck(ack, record, true); err != nil { - return c.markUncertain(transitionID, err) - } - result, err := c.verifyCommitted(ctx, transitionID, ack, oldGeneration) - if err != nil { - return c.markUncertain(transitionID, err) - } - return result, nil -} - -func (c *Coordinator) syncActiveSnapshot(ctx context.Context, currentAccountID string) error { - if currentAccountID == "" { - return nil - } - reader, ok := c.runtime.(RuntimeAuthSnapshotReader) - if !ok { - // Keep the coordinator compatible with legacy/test runtimes. The - // embedded Codex runtime implements this seam, so production switching - // always gets the synchronization barrier. - return nil - } - if c.snapshotWriter == nil { - return errors.New("active auth snapshot writer is unavailable") - } - snapshot, err := reader.ReadAuthSnapshot(ctx) - if err != nil { - return err - } - if snapshot.AccountID != currentAccountID { - return fmt.Errorf("%w: native snapshot account %q, expected %q", ErrIdentityMismatch, snapshot.AccountID, currentAccountID) - } - if err := c.snapshotWriter.Save(currentAccountID, snapshot.AuthJSON); err != nil { - return err - } - return nil -} - -func (c *Coordinator) verifyCommitted(ctx context.Context, transitionID string, ack runtime.TransitionResult, oldGeneration uint64) (*TransitionResult, error) { - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - target := record.state.TargetAccountID - expected := record.state.ExpectedGeneration - runtimeID := record.state.RuntimeID - disk := record.state.DiskAccountID - c.mu.Unlock() - identity, err := c.runtime.GetIdentity(ctx) - if err != nil { - return nil, fmt.Errorf("verify runtime identity after commit: %w", err) - } - if identity.RuntimeID != runtimeID { - return nil, fmt.Errorf("%w: expected %q, got %q", ErrRuntimeIDMismatch, runtimeID, identity.RuntimeID) - } - if identity.AuthGeneration != expected { - return nil, fmt.Errorf("%w: committed generation %d, got %d", ErrGenerationMismatch, expected, identity.AuthGeneration) - } - if accountID(identity.AccountID) != target { - return nil, fmt.Errorf("%w: committed account %q, got %q", ErrIdentityMismatch, target, accountID(identity.AccountID)) - } - if disk != "" && disk != target { - return nil, fmt.Errorf("%w: deployed account %q, expected %q", ErrIdentityMismatch, disk, target) - } - if ack.AuthGeneration != 0 && ack.AuthGeneration != expected { - return nil, fmt.Errorf("%w: ack generation %d, expected %d", ErrGenerationMismatch, ack.AuthGeneration, expected) - } - if ack.AccountID != nil && *ack.AccountID != target { - return nil, fmt.Errorf("%w: ack account %q, expected %q", ErrIdentityMismatch, *ack.AccountID, target) - } - result := TransitionResult{ - TransitionID: transitionID, - TargetAccountID: target, - AdoptedAccountID: accountID(identity.AccountID), - ExpectedGeneration: expected, - FinalGeneration: identity.AuthGeneration, - Outcome: TransitionCommitted, - CompletedAt: c.nowUTC(), - RuntimeID: identity.RuntimeID, - DiskAccountID: target, - Reason: "runtime identity confirmed after commit", - } - c.mu.Lock() - if record = c.transitions[transitionID]; record != nil { - record.state.AdoptedAccountID = result.AdoptedAccountID - record.state.FinalGeneration = result.FinalGeneration - record.state.DiskAccountID = target - c.setStateLocked(record, PhaseCommitted, result.Reason) - } - c.mu.Unlock() - if err := c.append(journal.Event{ - Type: journal.IdentityConfirmed, - TransitionID: transitionID, - AuthGeneration: result.FinalGeneration, - RuntimeID: result.RuntimeID, - AccountID: result.AdoptedAccountID, - TargetAccountID: target, - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal identity confirmation: %w", err)) - } - if err := c.append(journal.Event{ - Type: journal.TransitionCommitted, - TransitionID: transitionID, - AuthGeneration: result.FinalGeneration, - RuntimeID: result.RuntimeID, - AccountID: result.AdoptedAccountID, - TargetAccountID: target, - Outcome: string(result.Outcome), - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal transition commit: %w", err)) - } - _ = oldGeneration // retained in the signature for future monotonic checks. - return &result, nil -} - -func (c *Coordinator) finishRejected(transitionID string, ack runtime.TransitionResult, reason error) (*TransitionResult, error) { - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - target := record.state.TargetAccountID - runtimeID := record.state.RuntimeID - expected := record.state.ExpectedGeneration - result := TransitionResult{ - TransitionID: transitionID, - TargetAccountID: target, - AdoptedAccountID: accountID(ack.AccountID), - ExpectedGeneration: expected, - FinalGeneration: ack.AuthGeneration, - Outcome: TransitionRejected, - CompletedAt: c.nowUTC(), - RuntimeID: runtimeID, - DiskAccountID: record.state.DiskAccountID, - Reason: reason.Error(), - } - c.setStateLocked(record, PhaseRejected, result.Reason) - c.mu.Unlock() - return &result, nil -} - -// markUncertain records an unresolved transition and returns the result plus -// the triggering error. Keeping both values lets a caller surface transport -// failure while retaining the reconciliation handle. -func (c *Coordinator) markUncertain(transitionID string, reason error) (*TransitionResult, error) { - if reason == nil { - reason = ErrReconciliationNeeded - } - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, fmt.Errorf("%w: %s", ErrTransitionNotFound, transitionID) - } - state := record.state - c.setStateLocked(record, PhaseUncertain, reason.Error()) - result := TransitionResult{ - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AdoptedAccountID: state.AdoptedAccountID, - ExpectedGeneration: state.ExpectedGeneration, - FinalGeneration: state.FinalGeneration, - Outcome: TransitionUncertain, - CompletedAt: c.nowUTC(), - RuntimeID: state.RuntimeID, - DiskAccountID: state.DiskAccountID, - Reason: reason.Error(), - } - c.mu.Unlock() - _ = c.append(journal.Event{ - Type: journal.TransitionUncertain, - TransitionID: transitionID, - AuthGeneration: state.ExpectedGeneration, - RuntimeID: state.RuntimeID, - AccountID: state.DiskAccountID, - TargetAccountID: state.TargetAccountID, - Outcome: string(TransitionUncertain), - Reason: reason.Error(), - }) - return &result, reason -} - -func (c *Coordinator) append(event journal.Event) error { - if c == nil || c.journal == nil { - return nil - } - return c.journal.Append(event) -} - -// CancelTransition explicitly abandons a prepared transition. It is useful -// when an operator chooses not to reconcile a context-cancelled request. A -// lost cancellation acknowledgement is represented as uncertain as with any -// other state-changing command. -func (c *Coordinator) CancelTransition(ctx context.Context, transitionID, reason string) (*TransitionResult, error) { - if err := c.validateDependencies(); err != nil { - return nil, err - } - if ctx == nil { - ctx = context.Background() - } - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, fmt.Errorf("%w: %s", ErrTransitionNotFound, transitionID) - } - params := runtime.CancelAuthTransitionParams{TransitionID: transitionID, ExpectedGeneration: record.params.ExpectedGeneration, Reason: reason} - c.mu.Unlock() - ack, err := c.runtime.CancelAuthTransition(ctx, params) - if err != nil { - return c.markUncertain(transitionID, fmt.Errorf("cancel transition: %w", err)) - } - if ack.TransitionID != transitionID { - return c.markUncertain(transitionID, fmt.Errorf("%w: cancel response %q", ErrTransitionIDMismatch, ack.TransitionID)) - } - if ack.Outcome == runtime.TransitionRejected || ack.Outcome == runtime.TransitionUncertain { - return c.markUncertain(transitionID, errors.New("runtime did not confirm cancellation")) - } - c.mu.Lock() - record = c.transitions[transitionID] - target := "" - if record != nil { - target = record.state.TargetAccountID - c.setStateLocked(record, PhaseRejected, reason) - } - c.mu.Unlock() - result := &TransitionResult{TransitionID: transitionID, TargetAccountID: target, Outcome: TransitionRejected, CompletedAt: c.nowUTC(), Reason: reason} - return result, nil -} - -// Cancel is an alias for CancelTransition. -func (c *Coordinator) Cancel(ctx context.Context, transitionID, reason string) (*TransitionResult, error) { - return c.CancelTransition(ctx, transitionID, reason) -} - -// ensure imported package remains part of the public seam even when callers -// provide a custom CredentialDeployer implementation. -var _ CredentialDeployer = (*credentials.AtomicDeployer)(nil) diff --git a/controller/internal/transitions/reconcile.go b/controller/internal/transitions/reconcile.go deleted file mode 100644 index c62d4e89..00000000 --- a/controller/internal/transitions/reconcile.go +++ /dev/null @@ -1,358 +0,0 @@ -package transitions - -import ( - "context" - "errors" - "fmt" - - "codexmarathon/controller/internal/journal" - "codexmarathon/controller/internal/runtime" -) - -// ReconciliationDecision classifies the independently observed disk and -// runtime state. The values are deliberately descriptive because operators -// should be able to distinguish a safe commit from a split-brain condition. -type ReconciliationDecision string - -const ( - ReconcileCommitted ReconciliationDecision = "committed" - ReconcileRuntimeReload ReconciliationDecision = "runtime_reload_required" - ReconcileRejected ReconciliationDecision = "rejected" - ReconcileSplitBrain ReconciliationDecision = "split_brain" - ReconcileGenerationMismatch ReconciliationDecision = "generation_mismatch" - ReconcileUnknown ReconciliationDecision = "unknown" -) - -// IdentityObservation is the complete non-secret comparison input. Empty -// account IDs mean unauthenticated/unknown, never an inferred account. -type IdentityObservation struct { - DesiredAccountID string - PreviousAccountID string - DiskAccountID string - RuntimeAccountID string - ExpectedRuntimeID string - RuntimeID string - ControllerGeneration uint64 - RuntimeGeneration uint64 - ExpectedGeneration uint64 -} - -// ReconciliationObservation is a compatibility alias. -type ReconciliationObservation = IdentityObservation - -// CompareIdentity compares the disk, runtime, and generation authorities. It -// performs no I/O and never treats a matching account with a mismatching -// generation as committed. -func CompareIdentity(observation IdentityObservation) ReconciliationDecision { - if observation.DesiredAccountID == "" { - return ReconcileUnknown - } - if observation.ExpectedRuntimeID != "" && observation.RuntimeID != observation.ExpectedRuntimeID { - return ReconcileSplitBrain - } - expected := observation.ExpectedGeneration - if expected == 0 && observation.ControllerGeneration != ^uint64(0) { - expected = observation.ControllerGeneration + 1 - } - old := observation.ControllerGeneration - if old == 0 && expected > 0 { - old = expected - 1 - } - runtimeAtTarget := observation.RuntimeAccountID == observation.DesiredAccountID - diskAtTarget := observation.DiskAccountID == observation.DesiredAccountID - runtimeAtPrevious := observation.RuntimeAccountID == observation.PreviousAccountID || (observation.PreviousAccountID == "" && observation.RuntimeAccountID == "") - diskAtPrevious := observation.DiskAccountID == observation.PreviousAccountID || (observation.PreviousAccountID == "" && observation.DiskAccountID == "") - - controllerMatchesCommit := observation.ControllerGeneration == 0 || observation.ControllerGeneration == expected - // A lost acknowledgement can leave the controller's last durable value at - // the source generation while disk and runtime have already advanced. That - // one-generation lag is observable and safe to adopt after the runtime - // identity/generation checks below. - if expected > 0 && observation.ControllerGeneration+1 == expected { - controllerMatchesCommit = true - } - if runtimeAtTarget && diskAtTarget && expected > 0 && observation.RuntimeGeneration == expected && controllerMatchesCommit { - return ReconcileCommitted - } - // The target is durably on disk but the runtime still reports the prior - // generation. Retrying the same correlated commit is safe and is not a - // second transition. - if diskAtTarget && runtimeAtPrevious && expected > 0 && observation.RuntimeGeneration == old { - return ReconcileRuntimeReload - } - // A failed/never-applied deploy leaves both authorities at the source. It - // is safe to close the transition as rejected because no target state is - // observable. The caller may issue a fresh transition later. - if diskAtPrevious && runtimeAtPrevious && (expected == 0 || observation.RuntimeGeneration == old) { - return ReconcileRejected - } - // The runtime adopted the target but the disk is still on the source, or - // both sides claim the target with a stale generation. These are explicit - // split-brain/ordering failures and must not be guessed into committed. - if runtimeAtTarget != diskAtTarget { - return ReconcileSplitBrain - } - if runtimeAtTarget && diskAtTarget { - return ReconcileGenerationMismatch - } - return ReconcileUnknown -} - -// Compare is a short alias for CompareIdentity. -func Compare(observation IdentityObservation) ReconciliationDecision { - return CompareIdentity(observation) -} - -// Reconcile compares the stored transition intent with fresh runtime and disk -// observations. A resolved transition is returned idempotently; an -// unresolved transition remains the coordinator's active state and blocks new -// requests. -func (c *Coordinator) Reconcile(ctx context.Context, transitionID string) (*TransitionResult, error) { - if err := c.validateDependencies(); err != nil { - return nil, err - } - if ctx == nil { - ctx = context.Background() - } - c.mu.Lock() - if transitionID == "" { - if active := c.activeLocked(); active != nil { - transitionID = active.state.TransitionID - } - } - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, fmt.Errorf("%w: %s", ErrTransitionNotFound, transitionID) - } - if record.state.Phase == PhaseCommitted || record.state.Phase == PhaseRejected { - result := resultFromState(record.state) - c.mu.Unlock() - return &result, nil - } - c.mu.Unlock() - - state, err := c.runtime.GetRuntimeState(ctx) - if err != nil { - return c.markUncertain(transitionID, fmt.Errorf("reconcile runtime state: %w", err)) - } - if err := validateRuntimeIdentity(state.Identity); err != nil { - return c.markUncertain(transitionID, err) - } - c.mu.Lock() - record = c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - // A runtime restart can preserve an identity but not the connection's - // runtime ID. Treat that as unresolved rather than applying a command to a - // different process. - if state.Identity.RuntimeID != record.state.RuntimeID { - c.mu.Unlock() - return c.markUncertain(transitionID, fmt.Errorf("%w: expected %q, got %q", ErrRuntimeIDMismatch, record.state.RuntimeID, state.Identity.RuntimeID)) - } - previous := record.oldIdentity - desired := record.state.TargetAccountID - expected := record.state.ExpectedGeneration - storedDisk := record.state.DiskAccountID - c.mu.Unlock() - - disk := storedDisk - if c.disk != nil { - disk, err = c.disk.ReadIdentity() - if err != nil { - return c.markUncertain(transitionID, fmt.Errorf("read deployed auth identity: %w", err)) - } - c.mu.Lock() - if record = c.transitions[transitionID]; record != nil { - record.state.DiskAccountID = disk - record.state.UpdatedAt = c.nowUTC() - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - } - c.mu.Unlock() - } - - decision := CompareIdentity(IdentityObservation{ - DesiredAccountID: desired, - PreviousAccountID: accountID(previous.AccountID), - DiskAccountID: disk, - RuntimeAccountID: accountID(state.Identity.AccountID), - ExpectedRuntimeID: recordRuntimeID(c, transitionID), - RuntimeID: state.Identity.RuntimeID, - ControllerGeneration: previous.AuthGeneration, - RuntimeGeneration: state.Identity.AuthGeneration, - ExpectedGeneration: expected, - }) - - switch decision { - case ReconcileCommitted: - return c.finishReconciled(transitionID, TransitionCommitted, state.Identity, disk, "runtime and disk already agree at expected generation") - case ReconcileRejected: - return c.finishReconciled(transitionID, TransitionRejected, state.Identity, disk, "runtime and disk remain on prior identity") - case ReconcileRuntimeReload: - return c.retryCommitForReconciliation(ctx, transitionID, state.Identity, disk) - case ReconcileSplitBrain: - return c.markUncertain(transitionID, ErrSplitBrain) - case ReconcileGenerationMismatch: - return c.markUncertain(transitionID, ErrGenerationMismatch) - default: - return c.markUncertain(transitionID, ErrReconciliationNeeded) - } -} - -// ReconcileTransition is an alias for Reconcile. -func (c *Coordinator) ReconcileTransition(ctx context.Context, transitionID string) (*TransitionResult, error) { - return c.Reconcile(ctx, transitionID) -} - -func recordRuntimeID(c *Coordinator, transitionID string) string { - if c == nil { - return "" - } - c.mu.Lock() - defer c.mu.Unlock() - if record := c.transitions[transitionID]; record != nil { - return record.state.RuntimeID - } - return "" -} - -func resultFromState(state State) TransitionResult { - outcome := TransitionUncertain - switch state.Phase { - case PhaseCommitted: - outcome = TransitionCommitted - case PhaseRejected: - outcome = TransitionRejected - } - return TransitionResult{ - TransitionID: state.TransitionID, - TargetAccountID: state.TargetAccountID, - AdoptedAccountID: state.AdoptedAccountID, - ExpectedGeneration: state.ExpectedGeneration, - FinalGeneration: state.FinalGeneration, - Outcome: outcome, - CompletedAt: state.CompletedAt, - RuntimeID: state.RuntimeID, - DiskAccountID: state.DiskAccountID, - Reason: state.Reason, - } -} - -func (c *Coordinator) finishReconciled(transitionID string, outcome TransitionOutcome, identity runtime.Identity, disk, reason string) (*TransitionResult, error) { - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - result := TransitionResult{ - TransitionID: transitionID, - TargetAccountID: record.state.TargetAccountID, - ExpectedGeneration: record.state.ExpectedGeneration, - FinalGeneration: identity.AuthGeneration, - Outcome: outcome, - CompletedAt: c.nowUTC(), - RuntimeID: identity.RuntimeID, - DiskAccountID: disk, - Reason: reason, - } - if identity.AccountID != nil { - result.AdoptedAccountID = *identity.AccountID - } - record.state.AdoptedAccountID = result.AdoptedAccountID - record.state.FinalGeneration = result.FinalGeneration - record.state.DiskAccountID = disk - phase := PhaseRejected - if outcome == TransitionCommitted { - phase = PhaseCommitted - } - c.setStateLocked(record, phase, reason) - c.mu.Unlock() - - if err := c.append(journal.Event{ - Type: journal.TransitionReconciled, - TransitionID: transitionID, - AuthGeneration: result.FinalGeneration, - RuntimeID: result.RuntimeID, - AccountID: result.AdoptedAccountID, - TargetAccountID: result.TargetAccountID, - Outcome: string(outcome), - Reason: reason, - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal reconciliation: %w", err)) - } - return &result, nil -} - -func (c *Coordinator) retryCommitForReconciliation(ctx context.Context, transitionID string, identity runtime.Identity, disk string) (*TransitionResult, error) { - c.mu.Lock() - record := c.transitions[transitionID] - if record == nil { - c.mu.Unlock() - return nil, ErrTransitionNotFound - } - params := record.params - record.state.CommitSent = true - record.state.CommitAttempts++ - record.state.Phase = PhaseCommitSent - record.state.UpdatedAt = c.nowUTC() - attempt := record.state.CommitAttempts - runtimeID := record.state.RuntimeID - target := record.state.TargetAccountID - expected := record.state.ExpectedGeneration - if c.current != nil && c.current.TransitionID == transitionID { - copy := record.state - c.current = © - } - c.mu.Unlock() - - if err := c.append(journal.Event{ - Type: journal.CommitSent, - TransitionID: transitionID, - AuthGeneration: expected, - RuntimeID: runtimeID, - TargetAccountID: target, - Reason: fmt.Sprintf("reconciliation commit attempt %d", attempt), - }); err != nil { - return c.markUncertain(transitionID, fmt.Errorf("journal reconciliation commit: %w", err)) - } - ack, err := c.runtime.CommitAuthTransition(ctx, params) - if err != nil { - return c.markUncertain(transitionID, fmt.Errorf("reconciliation commit: %w", err)) - } - if ack.Outcome == runtime.TransitionRejected { - return c.markUncertain(transitionID, errors.New("runtime rejected reconciliation commit")) - } - if ack.Outcome == runtime.TransitionUncertain { - return c.markUncertain(transitionID, errors.New("runtime returned uncertain reconciliation commit")) - } - if err := c.validateTransitionAck(ack, record, true); err != nil { - return c.markUncertain(transitionID, err) - } - result, err := c.verifyCommitted(ctx, transitionID, ack, identity.AuthGeneration) - if err != nil { - return c.markUncertain(transitionID, err) - } - result.DiskAccountID = disk - // verifyCommitted already establishes a terminal state. Add a distinct - // reconciliation record so recovery tooling can tell this was recovered - // after an uncertain command. - if journalErr := c.append(journal.Event{ - Type: journal.TransitionReconciled, - TransitionID: transitionID, - AuthGeneration: result.FinalGeneration, - RuntimeID: result.RuntimeID, - AccountID: result.AdoptedAccountID, - TargetAccountID: result.TargetAccountID, - Outcome: string(result.Outcome), - Reason: "runtime reload completed during reconciliation", - }); journalErr != nil { - return result, journalErr - } - return result, nil -} diff --git a/controller/internal/transitions/replay.go b/controller/internal/transitions/replay.go deleted file mode 100644 index dcb300e7..00000000 --- a/controller/internal/transitions/replay.go +++ /dev/null @@ -1,238 +0,0 @@ -package transitions - -import ( - "fmt" - "strconv" - "strings" - - "codexmarathon/controller/internal/journal" - "codexmarathon/controller/internal/runtime" -) - -// Restore rehydrates transition intent from the append-only journal. -// -// A Coordinator is intentionally in-memory while it is attached to a -// runtime, but the transition intent it owns is durable. Replaying the -// journal before a reconnect is what lets Reconcile inspect a transition that -// was interrupted by a controller restart. This method restores only -// secret-free metadata; it never reads or reconstructs credential bytes. -// -// The journal is an ordered log, so the last lifecycle record for a -// transition wins. Incomplete histories are rejected rather than guessed: -// silently inventing a source identity or generation would make a later -// commit unsafe. -func (c *Coordinator) Restore(events []journal.Event) error { - if c == nil { - return ErrNilCoordinator - } - c.mu.Lock() - defer c.mu.Unlock() - - if c.transitions == nil { - c.transitions = make(map[string]*transitionRecord) - } - if c.boundary == nil { - c.boundary = make(map[string]chan struct{}) - } - var latest *transitionRecord - for _, event := range events { - if !isTransitionJournalEvent(event.Type) { - continue - } - if event.TransitionID == "" { - return fmt.Errorf("transition journal event %s has no transition_id", event.Type) - } - if event.RuntimeID == "" { - return fmt.Errorf("transition journal event %s/%s has no runtime_id", event.Type, event.TransitionID) - } - - record := c.transitions[event.TransitionID] - if event.Type == journal.TransitionCreated { - if record == nil { - expected, err := replayExpectedGeneration(event) - if err != nil { - return fmt.Errorf("restore transition %s: %w", event.TransitionID, err) - } - if event.TargetAccountID == "" { - return fmt.Errorf("restore transition %s: target account is empty", event.TransitionID) - } - var source *string - if event.FromAccountID != "" { - value := event.FromAccountID - source = &value - } - record = &transitionRecord{ - state: State{ - TransitionID: event.TransitionID, - RuntimeID: event.RuntimeID, - CurrentAccountID: event.FromAccountID, - TargetAccountID: event.TargetAccountID, - DiskAccountID: event.FromAccountID, - ExpectedGeneration: expected, - Phase: PhasePrepared, - CreatedAt: event.At, - UpdatedAt: event.At, - }, - oldIdentity: runtime.Identity{ - RuntimeID: event.RuntimeID, - AccountID: source, - AuthGeneration: event.AuthGeneration, - }, - params: runtime.AuthTransitionParams{ - TransitionID: event.TransitionID, - TargetAccountID: event.TargetAccountID, - ExpectedGeneration: expected, - }, - } - c.transitions[event.TransitionID] = record - } else if err := validateReplayCreated(record, event); err != nil { - return err - } - latest = record - continue - } - if record == nil { - return fmt.Errorf("restore transition %s: lifecycle event %s precedes TransitionCreated", event.TransitionID, event.Type) - } - if err := validateReplayCorrelation(record, event); err != nil { - return err - } - applyReplayEvent(record, event) - latest = record - } - - var unresolved *transitionRecord - for _, record := range c.transitions { - if record == nil { - continue - } - if record.state.Phase == PhaseCommitted || record.state.Phase == PhaseRejected || record.state.Phase == PhaseIdle { - continue - } - if unresolved != nil { - return fmt.Errorf("restore transition journal: multiple unresolved transitions (%s and %s)", unresolved.state.TransitionID, record.state.TransitionID) - } - unresolved = record - } - if unresolved != nil { - c.current = cloneRecordState(unresolved) - } else { - c.current = cloneRecordState(latest) - } - return nil -} - -func isTransitionJournalEvent(eventType journal.EventType) bool { - switch eventType { - case journal.TransitionCreated, journal.TransitionPrepared, journal.AuthDeployed, - journal.CommitSent, journal.IdentityConfirmed, journal.TransitionCommitted, - journal.TransitionUncertain, journal.TransitionReconciled: - return true - default: - return false - } -} - -func replayExpectedGeneration(event journal.Event) (uint64, error) { - if event.ExpectedGeneration != 0 { - if event.ExpectedGeneration <= event.AuthGeneration { - return 0, fmt.Errorf("expected generation %d is not greater than source generation %d", event.ExpectedGeneration, event.AuthGeneration) - } - return event.ExpectedGeneration, nil - } - if event.AuthGeneration == ^uint64(0) { - return 0, fmt.Errorf("source generation overflows target generation") - } - return event.AuthGeneration + 1, nil -} - -func validateReplayCreated(record *transitionRecord, event journal.Event) error { - if record.state.RuntimeID != event.RuntimeID || record.state.TargetAccountID != event.TargetAccountID || record.state.CurrentAccountID != event.FromAccountID { - return fmt.Errorf("restore transition %s: duplicate TransitionCreated conflicts with existing intent", event.TransitionID) - } - return nil -} - -func validateReplayCorrelation(record *transitionRecord, event journal.Event) error { - if record.state.RuntimeID != event.RuntimeID { - return fmt.Errorf("restore transition %s: event %s runtime_id %q conflicts with %q", event.TransitionID, event.Type, event.RuntimeID, record.state.RuntimeID) - } - if event.TargetAccountID != "" && event.TargetAccountID != record.state.TargetAccountID { - return fmt.Errorf("restore transition %s: event %s target account %q conflicts with %q", event.TransitionID, event.Type, event.TargetAccountID, record.state.TargetAccountID) - } - return nil -} - -func applyReplayEvent(record *transitionRecord, event journal.Event) { - state := &record.state - if event.At.After(state.UpdatedAt) { - state.UpdatedAt = event.At - } - switch event.Type { - case journal.TransitionPrepared: - state.PrepareSent = true - if state.Phase == PhaseIdle || state.Phase == PhasePrepared { - state.Phase = PhasePrepared - } - case journal.AuthDeployed: - state.DiskAccountID = firstNonEmpty(event.AccountID, event.TargetAccountID) - if state.Phase != PhaseCommitted && state.Phase != PhaseRejected { - state.Phase = PhaseDeploying - } - case journal.CommitSent: - state.CommitSent = true - state.CommitAttempts++ - state.Phase = PhaseCommitSent - if attempt, err := parseCommitAttempt(event.Reason); err == nil && attempt > state.CommitAttempts { - state.CommitAttempts = attempt - } - case journal.IdentityConfirmed: - state.AdoptedAccountID = firstNonEmpty(event.AccountID, event.TargetAccountID) - state.FinalGeneration = event.AuthGeneration - // Identity confirmation is durable evidence that the runtime had - // already verified the commit, but TransitionCommitted is a separate - // append. Keep the record unresolved until that terminal marker exists. - if state.Phase != PhaseCommitted && state.Phase != PhaseRejected { - state.Phase = PhaseCommitSent - } - case journal.TransitionCommitted: - state.AdoptedAccountID = firstNonEmpty(event.AccountID, event.TargetAccountID) - state.DiskAccountID = firstNonEmpty(state.DiskAccountID, event.TargetAccountID) - state.FinalGeneration = event.AuthGeneration - state.Phase = PhaseCommitted - state.CompletedAt = event.At - case journal.TransitionUncertain: - state.Phase = PhaseUncertain - state.Reason = event.Reason - case journal.TransitionReconciled: - state.FinalGeneration = event.AuthGeneration - state.AdoptedAccountID = firstNonEmpty(event.AccountID, event.TargetAccountID) - if strings.EqualFold(event.Outcome, string(TransitionCommitted)) || event.Outcome == "committed" { - state.Phase = PhaseCommitted - } else if strings.EqualFold(event.Outcome, string(TransitionRejected)) || event.Outcome == "rejected" { - state.Phase = PhaseRejected - } - if state.Phase == PhaseCommitted || state.Phase == PhaseRejected { - state.CompletedAt = event.At - } - state.Reason = event.Reason - } -} - -func parseCommitAttempt(reason string) (uint32, error) { - const prefix = "commit attempt " - if !strings.HasPrefix(strings.ToLower(reason), prefix) { - return 0, fmt.Errorf("commit attempt is not encoded") - } - n, err := strconv.ParseUint(strings.TrimSpace(reason[len(prefix):]), 10, 32) - return uint32(n), err -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} diff --git a/controller/internal/transitions/replay_test.go b/controller/internal/transitions/replay_test.go deleted file mode 100644 index 28a0188a..00000000 --- a/controller/internal/transitions/replay_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package transitions - -import ( - "testing" - "time" - - "codexmarathon/controller/internal/journal" -) - -func TestRestoreRehydratesUncertainTransitionIntent(t *testing.T) { - now := time.Unix(100, 0).UTC() - coordinator := NewCoordinator(Config{}) - events := []journal.Event{ - { - At: now, Type: journal.TransitionCreated, TransitionID: "tx-1", - AuthGeneration: 7, ExpectedGeneration: 8, RuntimeID: "runtime-1", - FromAccountID: "account-a", TargetAccountID: "account-b", - }, - { - At: now.Add(time.Second), Type: journal.TransitionPrepared, TransitionID: "tx-1", - AuthGeneration: 7, RuntimeID: "runtime-1", TargetAccountID: "account-b", - }, - { - At: now.Add(2 * time.Second), Type: journal.AuthDeployed, TransitionID: "tx-1", - AuthGeneration: 8, RuntimeID: "runtime-1", AccountID: "account-b", - TargetAccountID: "account-b", - }, - { - At: now.Add(3 * time.Second), Type: journal.CommitSent, TransitionID: "tx-1", - AuthGeneration: 8, RuntimeID: "runtime-1", TargetAccountID: "account-b", - Reason: "commit attempt 1", - }, - { - At: now.Add(4 * time.Second), Type: journal.TransitionUncertain, TransitionID: "tx-1", - AuthGeneration: 8, RuntimeID: "runtime-1", TargetAccountID: "account-b", - Reason: "controller disconnected after commit", - }, - } - if err := coordinator.Restore(events); err != nil { - t.Fatalf("Restore() error = %v", err) - } - state, ok := coordinator.Pending() - if !ok { - t.Fatal("Pending() = false, want restored unresolved transition") - } - if state.TransitionID != "tx-1" || state.TargetAccountID != "account-b" { - t.Fatalf("restored target = %#v", state) - } - if state.ExpectedGeneration != 8 || state.CommitAttempts != 1 || state.Phase != PhaseUncertain { - t.Fatalf("restored correlation = %#v", state) - } -} - -func TestRestoreIgnoresNonTransitionEventsAndReplaysTerminalHistory(t *testing.T) { - now := time.Unix(200, 0).UTC() - coordinator := NewCoordinator(Config{}) - events := []journal.Event{ - {At: now, Type: journal.RuntimeConnected, RuntimeID: "runtime-1"}, - {At: now.Add(time.Second), Type: journal.TransitionCreated, TransitionID: "tx-1", AuthGeneration: 0, RuntimeID: "runtime-1", TargetAccountID: "account-a"}, - {At: now.Add(2 * time.Second), Type: journal.TransitionCommitted, TransitionID: "tx-1", AuthGeneration: 1, RuntimeID: "runtime-1", AccountID: "account-a", TargetAccountID: "account-a", Outcome: "committed"}, - } - if err := coordinator.Restore(events); err != nil { - t.Fatalf("Restore() error = %v", err) - } - if _, ok := coordinator.Pending(); ok { - t.Fatal("Pending() = true, want terminal committed history") - } - state, ok := coordinator.State() - if !ok || state.Phase != PhaseCommitted || state.FinalGeneration != 1 { - t.Fatalf("terminal state = %#v, %v", state, ok) - } -} diff --git a/controller/internal/transitions/snapshot_test.go b/controller/internal/transitions/snapshot_test.go deleted file mode 100644 index 9cdd9b4c..00000000 --- a/controller/internal/transitions/snapshot_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package transitions - -import ( - "context" - "encoding/json" - "testing" - - "codexmarathon/controller/internal/credentials" - "codexmarathon/controller/internal/runtime" -) - -type snapshotRuntimeFixture struct { - identity runtime.Identity - snapshot runtime.NativeAuthSnapshotResult -} - -func (f *snapshotRuntimeFixture) GetRuntimeState(context.Context) (runtime.RuntimeState, error) { - return runtime.RuntimeState{ - Identity: f.identity, - ActiveTurnCount: 0, - }, nil -} - -func (f *snapshotRuntimeFixture) PrepareAuthTransition(_ context.Context, params runtime.AuthTransitionParams) (runtime.TransitionResult, error) { - return runtime.TransitionResult{ - TransitionID: params.TransitionID, - RuntimeID: f.identity.RuntimeID, - ExpectedGeneration: params.ExpectedGeneration, - AuthGeneration: f.identity.AuthGeneration, - Outcome: runtime.TransitionCommitted, - AccountID: f.identity.AccountID, - }, nil -} - -func (f *snapshotRuntimeFixture) CommitAuthTransition(_ context.Context, params runtime.AuthTransitionParams) (runtime.TransitionResult, error) { - f.identity.AccountID = stringPointer(params.TargetAccountID) - f.identity.AuthGeneration = params.ExpectedGeneration - return runtime.TransitionResult{ - TransitionID: params.TransitionID, - RuntimeID: f.identity.RuntimeID, - ExpectedGeneration: params.ExpectedGeneration, - AuthGeneration: f.identity.AuthGeneration, - Outcome: runtime.TransitionCommitted, - AccountID: f.identity.AccountID, - }, nil -} - -func (f *snapshotRuntimeFixture) CancelAuthTransition(_ context.Context, params runtime.CancelAuthTransitionParams) (runtime.TransitionResult, error) { - return runtime.TransitionResult{ - TransitionID: params.TransitionID, - RuntimeID: f.identity.RuntimeID, - ExpectedGeneration: params.ExpectedGeneration, - AuthGeneration: f.identity.AuthGeneration, - Outcome: runtime.TransitionCommitted, - AccountID: f.identity.AccountID, - }, nil -} - -func (f *snapshotRuntimeFixture) GetIdentity(context.Context) (runtime.Identity, error) { - return f.identity, nil -} - -func (f *snapshotRuntimeFixture) GetAuthGeneration(context.Context) (runtime.AuthGenerationResult, error) { - return runtime.AuthGenerationResult{AuthGeneration: f.identity.AuthGeneration}, nil -} - -func (f *snapshotRuntimeFixture) ReadAuthSnapshot(context.Context) (runtime.NativeAuthSnapshotResult, error) { - return f.snapshot, nil -} - -type snapshotWriterFixture struct { - accountID string - raw []byte -} - -func (w *snapshotWriterFixture) Save(accountID string, raw []byte) error { - w.accountID = accountID - w.raw = append([]byte(nil), raw...) - return nil -} - -type snapshotDeployerFixture struct { - accountID string -} - -func (d *snapshotDeployerFixture) Deploy(accountID string) (credentials.DeploymentResult, error) { - d.accountID = accountID - return credentials.DeploymentResult{AccountID: accountID}, nil -} - -func stringPointer(value string) *string { return &value } - -func TestCoordinatorSynchronizesNativeSnapshotBeforeDeployment(t *testing.T) { - raw, err := json.Marshal(map[string]any{ - "auth_mode": "chatgpt", - "tokens": map[string]string{"account_id": "account-a", "access_token": "refreshed"}, - }) - if err != nil { - t.Fatal(err) - } - rt := &snapshotRuntimeFixture{ - identity: runtime.Identity{ - RuntimeID: "runtime-test", - AccountID: stringPointer("account-a"), - AuthGeneration: 1, - }, - snapshot: runtime.NativeAuthSnapshotResult{ - AccountID: "account-a", - AuthJSON: raw, - }, - } - writer := &snapshotWriterFixture{} - deployer := &snapshotDeployerFixture{} - coordinator := NewCoordinator(Config{ - Runtime: rt, - Deployer: deployer, - SnapshotWriter: writer, - }) - - result, err := coordinator.RequestTransition(context.Background(), "account-b") - if err != nil { - t.Fatalf("RequestTransition() error = %v", err) - } - if result.Outcome != TransitionCommitted { - t.Fatalf("RequestTransition() outcome = %q, want committed", result.Outcome) - } - if writer.accountID != "account-a" || string(writer.raw) != string(raw) { - t.Fatalf("snapshot writer = (%q, %q), want Account A opaque snapshot", writer.accountID, writer.raw) - } - if deployer.accountID != "account-b" { - t.Fatalf("deployer account = %q, want account-b", deployer.accountID) - } -} diff --git a/controller/internal/transitions/state.go b/controller/internal/transitions/state.go deleted file mode 100644 index 3f4658ec..00000000 --- a/controller/internal/transitions/state.go +++ /dev/null @@ -1,505 +0,0 @@ -// Package transitions coordinates controller-owned credential deployment with -// the runtime's safe-boundary and identity APIs. -// -// The package deliberately keeps the runtime and disk as separate authorities: -// a successful filesystem write is not a successful runtime transition, and a -// runtime acknowledgement that cannot be correlated is not silently treated -// as a failure. The resulting explicit uncertain state is what lets callers -// reconcile after an IPC or process failure without starting a second, -// competing transition. -package transitions - -import ( - "context" - "errors" - "fmt" - "os" - "sync" - "sync/atomic" - "time" - - "codexmarathon/controller/internal/credentials" - "codexmarathon/controller/internal/journal" - "codexmarathon/controller/internal/runtime" -) - -// TransitionOutcome is intentionally a string so it can be logged and -// compared with runtime.TransitionOutcome without an ordinal conversion. -// Uncertain is a first-class outcome: it means the controller cannot prove -// whether a state-changing runtime command was applied. -type TransitionOutcome string - -const ( - TransitionCommitted TransitionOutcome = "committed" - TransitionRejected TransitionOutcome = "rejected" - TransitionUncertain TransitionOutcome = "uncertain" -) - -// Outcome is a descriptive alias for integrations that use the shorter name. -type Outcome = TransitionOutcome - -// Result is the coordinator's durable, controller-facing result. RuntimeID, -// DiskAccountID, and Reason are diagnostic evidence; no credential material is -// representable here. -type TransitionResult struct { - TransitionID string `json:"transition_id"` - TargetAccountID string `json:"target_account_id"` - AdoptedAccountID string `json:"adopted_account_id,omitempty"` - ExpectedGeneration uint64 `json:"expected_generation"` - FinalGeneration uint64 `json:"final_generation"` - Outcome TransitionOutcome `json:"outcome"` - CompletedAt time.Time `json:"completed_at,omitempty"` - RuntimeID string `json:"runtime_id,omitempty"` - DiskAccountID string `json:"disk_account_id,omitempty"` - Reason string `json:"reason,omitempty"` -} - -// Result is retained as a convenient compatibility spelling. -type Result = TransitionResult - -// TransitionPhase is the controller's local lifecycle state. It is not a -// substitute for runtime state; it records what the controller has sent or -// durably observed so reconciliation can resume after a restart. -type TransitionPhase string - -const ( - PhaseIdle TransitionPhase = "idle" - PhasePrepared TransitionPhase = "prepared" - PhaseWaitingForBoundary TransitionPhase = "waiting_for_boundary" - PhaseDeploying TransitionPhase = "deploying" - PhaseCommitSent TransitionPhase = "commit_sent" - PhaseUncertain TransitionPhase = "uncertain" - PhaseCommitted TransitionPhase = "committed" - PhaseRejected TransitionPhase = "rejected" -) - -// State is a snapshot of one controller-owned transition. Copies returned by -// the coordinator are independent and can be retained by callers. -type State struct { - TransitionID string `json:"transition_id"` - RuntimeID string `json:"runtime_id"` - CurrentAccountID string `json:"current_account_id,omitempty"` - TargetAccountID string `json:"target_account_id"` - DiskAccountID string `json:"disk_account_id,omitempty"` - AdoptedAccountID string `json:"adopted_account_id,omitempty"` - ExpectedGeneration uint64 `json:"expected_generation"` - FinalGeneration uint64 `json:"final_generation,omitempty"` - Phase TransitionPhase `json:"phase"` - PrepareSent bool `json:"prepare_sent"` - CommitSent bool `json:"commit_sent"` - CommitAttempts uint32 `json:"commit_attempts"` - ActiveTurnCount int `json:"active_turn_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CompletedAt time.Time `json:"completed_at,omitempty"` - Reason string `json:"reason,omitempty"` -} - -// TransitionState is a descriptive alias for State. -type TransitionState = State - -// Runtime is the small runtime surface used by the coordinator. Events are -// optional (see RuntimeEvents); keeping them out of this required interface -// allows deterministic fakes and reconnecting transports to implement only the -// request side. -type Runtime interface { - GetRuntimeState(context.Context) (runtime.RuntimeState, error) - PrepareAuthTransition(context.Context, runtime.AuthTransitionParams) (runtime.TransitionResult, error) - CommitAuthTransition(context.Context, runtime.AuthTransitionParams) (runtime.TransitionResult, error) - CancelAuthTransition(context.Context, runtime.CancelAuthTransitionParams) (runtime.TransitionResult, error) - GetIdentity(context.Context) (runtime.Identity, error) - GetAuthGeneration(context.Context) (runtime.AuthGenerationResult, error) -} - -// RuntimeEvents is implemented by runtime.Client and the fake runtime. The -// coordinator consumes this channel only while it is waiting for a matching -// safe-boundary event; callers may instead pass events to HandleEvent. -type RuntimeEvents interface { - Events() <-chan runtime.Event -} - -// TransitionCoordinator is the public orchestration seam consumed by policy -// code. IDs and generations are intentionally absent from RequestTransition: -// they are coordinator-owned values. -type TransitionCoordinator interface { - RequestTransition(context.Context, string) (*TransitionResult, error) - Reconcile(context.Context, string) (*TransitionResult, error) -} - -// DiskIdentityReader supplies the identity observed in the deployed auth file. -// It must return an empty string when the file exists but contains no account -// identity; that is evidence of an unauthenticated file, not an inferred -// target account. -type DiskIdentityReader interface { - ReadIdentity() (string, error) -} - -// CredentialDeployer is repeated here as a descriptive seam so callers do -// not need to import credentials merely to construct a coordinator. -type CredentialDeployer interface { - Deploy(accountID string) (credentials.DeploymentResult, error) -} - -// RuntimeAuthSnapshotReader exposes the native runtime's current opaque auth -// snapshot. It is optional for compatibility with small runtimes and fakes, -// but production transitions should implement it so refreshed Account A -// credentials are synchronized before Account B is deployed. -type RuntimeAuthSnapshotReader interface { - ReadAuthSnapshot(context.Context) (runtime.NativeAuthSnapshotResult, error) -} - -// Clock is injectable for deterministic transition and journal tests. -type Clock func() time.Time - -// Config constructs a Coordinator. Runtime and Deployer are required; -// Journal, Disk, and the remaining fields are optional. -type Config struct { - Runtime Runtime - Deployer CredentialDeployer - // SnapshotWriter receives the active account's opaque native snapshot - // before target deployment. It must be the same protected vault used by - // Deployer; no token material enters transition state or the journal. - SnapshotWriter credentials.SnapshotWriter - - // Disk is the preferred spelling. DiskIdentity is accepted as a - // compatibility alias when wiring older callers. - Disk DiskIdentityReader - DiskIdentity DiskIdentityReader - Journal journal.Journal - - Now Clock - BoundaryPollInterval time.Duration - TransitionID func() string -} - -// Coordinator is safe for a policy loop and an event/reconciliation loop to -// call concurrently. Network, runtime, deployer, and journal calls happen -// outside mu; local state remains serialized by mu. -type Coordinator struct { - runtime Runtime - deployer CredentialDeployer - snapshotWriter credentials.SnapshotWriter - disk DiskIdentityReader - journal journal.Journal - now Clock - boundaryPollInterval time.Duration - idGenerator func() string - - mu sync.Mutex - current *State - transitions map[string]*transitionRecord - boundary map[string]chan struct{} - sequence atomic.Uint64 -} - -type transitionRecord struct { - state State - oldIdentity runtime.Identity - params runtime.AuthTransitionParams -} - -var ( - ErrNilCoordinator = errors.New("nil transition coordinator") - ErrNilRuntime = errors.New("transition runtime is nil") - ErrNilDeployer = errors.New("transition deployer is nil") - ErrTransitionInProgress = errors.New("transition already in progress") - ErrTransitionNotFound = errors.New("transition not found") - ErrTransitionIDMismatch = errors.New("transition id mismatch") - ErrRuntimeIDMismatch = errors.New("runtime id mismatch") - ErrGenerationMismatch = errors.New("auth generation mismatch") - ErrIdentityMismatch = errors.New("runtime identity mismatch") - ErrSafeBoundaryPending = errors.New("safe boundary is pending") - ErrSplitBrain = errors.New("disk and runtime identities disagree") - ErrReconciliationNeeded = errors.New("transition reconciliation is required") -) - -// NewCoordinator constructs a coordinator from a Config. For compatibility -// with small callers it also accepts the equivalent positional form -// NewCoordinator(runtime, deployer, journal...). It returns a usable object -// even when required dependencies are nil; operations then return a stable -// error rather than panicking. -func NewCoordinator(value any, args ...any) *Coordinator { - config := Config{} - switch typed := value.(type) { - case Config: - config = typed - case Runtime: - config.Runtime = typed - for _, arg := range args { - if arg == nil { - continue - } - if config.Deployer == nil { - if deployer, ok := arg.(CredentialDeployer); ok { - config.Deployer = deployer - continue - } - } - if config.Journal == nil { - if record, ok := arg.(journal.Journal); ok { - config.Journal = record - continue - } - } - if config.Disk == nil { - if diskReader, ok := arg.(DiskIdentityReader); ok { - config.Disk = diskReader - } - } - } - default: - // Leave dependencies nil. This preserves a deterministic validation - // error for a malformed constructor call rather than panicking. - } - disk := config.Disk - if disk == nil { - disk = config.DiskIdentity - } - now := config.Now - if now == nil { - now = func() time.Time { return time.Now().UTC() } - } - interval := config.BoundaryPollInterval - if interval <= 0 { - interval = 100 * time.Millisecond - } - c := &Coordinator{ - runtime: config.Runtime, - deployer: config.Deployer, - snapshotWriter: config.SnapshotWriter, - disk: disk, - journal: config.Journal, - now: now, - boundaryPollInterval: interval, - transitions: make(map[string]*transitionRecord), - boundary: make(map[string]chan struct{}), - } - if config.TransitionID != nil { - c.idGenerator = config.TransitionID - } else { - c.idGenerator = func() string { - return fmt.Sprintf("tx-%d", c.sequence.Add(1)) - } - } - return c -} - -// NewTransitionCoordinator is the explicit constructor spelling. -func NewTransitionCoordinator(config Config) *Coordinator { return NewCoordinator(config) } - -// NewCoordinatorWithDeps is a compact constructor for simple wiring. The -// optional journal keeps this source-compatible with callers that do not need -// durable records in unit tests. -func NewCoordinatorWithDeps(rt Runtime, deployer CredentialDeployer, journals ...journal.Journal) *Coordinator { - var record journal.Journal - if len(journals) > 0 { - record = journals[0] - } - return NewCoordinator(Config{Runtime: rt, Deployer: deployer, Journal: record}) -} - -// NewTransitionCoordinatorWithDeps is an alias for NewCoordinatorWithDeps. -func NewTransitionCoordinatorWithDeps(rt Runtime, deployer CredentialDeployer, journals ...journal.Journal) *Coordinator { - return NewCoordinatorWithDeps(rt, deployer, journals...) -} - -// State returns the active transition snapshot. The bool is false when there -// is no active or unresolved transition. -func (c *Coordinator) State() (State, bool) { - if c == nil { - return State{}, false - } - c.mu.Lock() - defer c.mu.Unlock() - if c.current == nil { - return State{}, false - } - return *c.current, true -} - -// CurrentState is a descriptive alias for State. -func (c *Coordinator) CurrentState() (State, bool) { return c.State() } - -// Pending reports whether a transition blocks a new request. Terminal -// committed/rejected records do not block subsequent requests. -func (c *Coordinator) Pending() (State, bool) { - state, ok := c.State() - if !ok || state.Phase == PhaseCommitted || state.Phase == PhaseRejected || state.Phase == PhaseIdle { - return State{}, false - } - return state, true -} - -// HasUnresolved reports whether reconciliation is required before a new -// transition may begin. -func (c *Coordinator) HasUnresolved() bool { - state, ok := c.Pending() - return ok && state.Phase == PhaseUncertain -} - -func (c *Coordinator) validateDependencies() error { - if c == nil { - return ErrNilCoordinator - } - if c.runtime == nil { - return ErrNilRuntime - } - if c.deployer == nil { - return ErrNilDeployer - } - return nil -} - -func (c *Coordinator) nowUTC() time.Time { - now := c.now - if now == nil { - now = func() time.Time { return time.Now().UTC() } - } - return now().UTC() -} - -func cloneState(state State) State { return state } - -func cloneResult(result TransitionResult) *TransitionResult { - return &result -} - -func runtimeOutcome(outcome runtime.TransitionOutcome) TransitionOutcome { - switch outcome { - case runtime.TransitionCommitted: - return TransitionCommitted - case runtime.TransitionRejected: - return TransitionRejected - case runtime.TransitionUncertain: - return TransitionUncertain - default: - return TransitionUncertain - } -} - -func (c *Coordinator) setStateLocked(record *transitionRecord, phase TransitionPhase, reason string) { - now := c.nowUTC() - record.state.Phase = phase - record.state.Reason = reason - record.state.UpdatedAt = now - if phase == PhaseCommitted || phase == PhaseRejected { - record.state.CompletedAt = now - } - if c.current == nil || c.current.TransitionID == record.state.TransitionID { - copy := record.state - c.current = © - } -} - -func (c *Coordinator) signalBoundaryLocked(transitionID string) { - ch := c.boundary[transitionID] - if ch == nil { - return - } - select { - case ch <- struct{}{}: - default: - } -} - -// HandleEvent delivers a runtime event to the coordinator. It is safe to -// call this from a runtime event loop. Events unrelated to an active -// transition are ignored, while malformed/mismatched safe-boundary events are -// rejected so a stale runtime cannot advance a transition. -func (c *Coordinator) HandleEvent(event runtime.Event) error { - if c == nil { - return ErrNilCoordinator - } - if event.EventType != runtime.EventSafeBoundaryReached { - return nil - } - if event.TransitionID == "" { - return ErrTransitionIDMismatch - } - c.mu.Lock() - defer c.mu.Unlock() - record := c.transitions[event.TransitionID] - if record == nil { - return fmt.Errorf("%w: %s", ErrTransitionNotFound, event.TransitionID) - } - if record.state.RuntimeID != event.RuntimeID { - return fmt.Errorf("%w: expected %q, got %q", ErrRuntimeIDMismatch, record.state.RuntimeID, event.RuntimeID) - } - if event.AuthGeneration != record.oldIdentity.AuthGeneration && event.AuthGeneration != record.state.ExpectedGeneration { - return fmt.Errorf("%w: safe boundary generation %d, expected %d or %d", ErrGenerationMismatch, event.AuthGeneration, record.oldIdentity.AuthGeneration, record.state.ExpectedGeneration) - } - if record.state.Phase == PhaseWaitingForBoundary || record.state.Phase == PhasePrepared { - c.signalBoundaryLocked(event.TransitionID) - } - return nil -} - -// OnEvent is a compatibility alias for HandleEvent. -func (c *Coordinator) OnEvent(event runtime.Event) error { return c.HandleEvent(event) } - -// consumeBoundaryEvent validates and handles one event while waiting. The -// returned bool indicates whether the event was the matching safe boundary. -func (c *Coordinator) consumeBoundaryEvent(event runtime.Event, transitionID string) (bool, error) { - if event.EventType != runtime.EventSafeBoundaryReached { - return false, nil - } - if event.TransitionID != transitionID { - // A different transition's boundary must not wake this request, but it - // is otherwise a valid event for a caller-owned event loop. - return false, nil - } - if err := c.HandleEvent(event); err != nil { - return false, err - } - return true, nil -} - -// FileIdentityReader reads only the account identity from the deployed -// auth.json. It never returns or stores the raw document. The runtime remains -// authoritative for parsing semantics; this reader is strictly a controller -// reconciliation observation of the account_id field supported by the vault -// contract. -type FileIdentityReader struct { - Path string -} - -// NewFileIdentityReader constructs a disk identity reader for path. -func NewFileIdentityReader(path string) *FileIdentityReader { - return &FileIdentityReader{Path: path} -} - -// ReadIdentity reads the current deployed identity. An auth file without an -// account_id returns an empty identity, not a guessed value. -func (r *FileIdentityReader) ReadIdentity() (string, error) { - if r == nil { - return "", errors.New("nil disk identity reader") - } - if r.Path == "" { - return "", errors.New("disk identity path is empty") - } - info, err := os.Lstat(r.Path) - if err != nil { - return "", err - } - if info.Mode()&os.ModeSymlink != 0 { - return "", errors.New("disk identity path is a symbolic link") - } - if info.IsDir() { - return "", errors.New("disk identity path is a directory") - } - raw, err := os.ReadFile(r.Path) - if err != nil { - return "", err - } - return credentials.ExtractAccountID(raw) -} - -// DiskIdentityFunc adapts a function to DiskIdentityReader. -type DiskIdentityFunc func() (string, error) - -func (f DiskIdentityFunc) ReadIdentity() (string, error) { - if f == nil { - return "", errors.New("nil disk identity function") - } - return f() -} diff --git a/docs/account-manager.md b/docs/account-manager.md index e3b6b594..5b44f007 100644 --- a/docs/account-manager.md +++ b/docs/account-manager.md @@ -1,63 +1,48 @@ # Native account manager -CodexMarathon owns the account/profile lifecycle in the controller. A user -does not install or invoke `codex-switch`, `codext`, or a second `codex` -executable to manage accounts. - -## Reused donor behavior - -The implementation is adapted from the pinned read-only checkout at -`donor/codex-switch` (source commit `ca5c7dd454780272ed429f6fb1dceebda44397ae`): - -| Donor path | CodexMarathon adaptation | -| --- | --- | -| `internal/profile/profile.go` | `controller/internal/credentials` opaque snapshots plus `accounts.Manager` profile lifecycle | -| `internal/auth/refresh.go` | `accounts.AuthService.Refresh`; the installed Codex login/AuthManager performs provider refresh when its supported local interface is available | -| `internal/switcher/switcher.go` | `credentials.AtomicDeployer` and `accounts.Manager.Activate` with rollback | -| `internal/cli/auth.go` | `accounts login/add` commands through the native runtime service; no shell-out login | -| `internal/cli/list.go` and `profile_rows.go` | secret-free account list/status views backed by registry and vault metadata | -| `internal/cli/remove.go` | active-profile protection and explicit force removal | -| `internal/cli/use.go` | `accounts activate/use` commands | - -The account manager also consumes the installed Codex CLI as the native -authentication authority when it exposes the supported local control -interface. Its login service is a direct boundary rather than a Go -reimplementation of OAuth or a shell command. The imported Codex-derived -runtime under `runtime/codex-rs` implements the same seam for optional -development and diagnostics. - -For a stock Codex installation that is already authenticated, `accounts -import` reads the configured `auth.json`, validates only its JSON object and -optional account identity, and stores the document as an opaque snapshot. It -does not require a runtime endpoint or expose token fields. - -## Storage and mutation rules - -* `accounts.json` stores only stable IDs, aliases, telemetry markers, and a - credential reference. Token fields are rejected from metadata. -* The credential vault stores one opaque validated auth snapshot per stable - account ID with owner-only permissions. -* Login saves the snapshot before selecting it; the first account is selected - automatically, later accounts require `--activate` or `accounts activate`. -* Import uses the same protected save and activation path as login, but takes - the existing authenticated snapshot instead of invoking a native login. -* Activation deploys `auth.json` atomically, then updates the active marker. A - failed or cancelled activation restores the prior file and marker. -* Refresh returns token fields only from the native service, merges non-empty - fields while retaining unknown auth fields, and atomically redeploys the - active snapshot. -* Removing the active profile is rejected unless `--force` is explicit. Force - clears controller metadata but leaves the deployed `auth.json` untouched so - deleting a local profile does not unexpectedly log Codex out. -* Native provider errors are intentionally reduced to generic operation errors - at the manager boundary; free-form provider text is not printed because it - could contain bearer material. - -## Runtime boundary - -`controller/app.RuntimeAuthService` maps the manager to the local-control -methods `account/login` and `account/refresh`. The response carries an opaque -snapshot only in memory; it must travel over the authenticated user-scoped -boundary and must never be logged or journalled. If the installed Codex does -not expose the live interface, the controlled restart path must stop the same -Codex executable before deploying the snapshot and resuming its conversation. +The native Marathon account service stores account aliases, quota metadata, +and opaque ChatGPT credential snapshots inside Codex's configured home. It is +part of the app-server process embedded in the Codex executable. + +## Account lifecycle + +```text +native login or active-account import + | + v +validate identity -> write opaque snapshot atomically -> record alias + | + v +status/accounts -> safe-boundary switch -> native AuthManager reload +``` + +Use the public command surface: + +```bash +codex marathon import personal +codex marathon login work --device-code +codex marathon accounts +codex marathon switch work +``` + +The same operations are available from `/marathon` in the interactive TUI. +Login can use a local browser link or device/auth code. Device-code login is +the supported path when the Codex process runs on a headless server. + +## Storage rules + +- Account metadata contains IDs, aliases, quota observations, and transition + markers; it never contains token fields. +- Credential snapshots are opaque to the display and logging layers and are + protected with owner-only file permissions. +- Snapshot writes use a same-directory temporary file, flush, and atomic + replacement. A failed replacement leaves the previous snapshot intact. +- A switch rechecks the source account and waits for all account-bound work to + become idle before persisting the target. +- Codex's AuthManager remains the authority for in-memory credentials and + native provider refresh. Marathon only stores the opaque result needed for a + later managed switch. + +Unsupported authentication modes such as API keys and external bearer +credentials are rejected for managed account switching. Status output, +transition records, and diagnostics contain account IDs and aliases only. diff --git a/docs/architecture.md b/docs/architecture.md index 887da0d9..f894c6db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,67 +1,64 @@ -# Companion architecture +# Native CodexMarathon architecture -## Outcome - -CodexMarathon is a small companion to a Codex CLI that is already installed -on the machine. It coordinates account selection and safe process handoff; it -does not ship a second Codex runtime in the normal release. +CodexMarathon is a native feature of the custom Codex Rust CLI. The Marathon +command, interactive `/marathon` controls, status line, account service, and +Codex authentication manager all run in the same executable. ```text -operator - | - v -codexmarathon (Go companion) - |-- account registry and protected credential vault - |-- quota policy, reset scheduler, journal, reconciliation - |-- installed Codex discovery and compatibility checks - | - +--> installed Codex local control interface (preferred) - | safe boundary -> auth reload -> transport invalidation -> verify - | - `--> installed Codex process fallback - graceful stop -> atomic auth deployment -> relaunch -> resume -> verify +codex executable + | + +-- `codex marathon` commands + +-- `/marathon` interactive controls + +-- status-line Marathon items + | + `-- app-server Marathon service + |-- account registry and opaque credential vault + |-- quota policy and mock-only reset scheduler + |-- safe-boundary transition coordinator + `-- native AuthManager and recovery authorities ``` -The imported Rust runtime under `runtime/codex-rs/` remains in the repository -for protocol development, adapter tests, provenance, and an explicitly -optional self-contained archive. It is not a default runtime dependency and -the companion never silently substitutes it for the user's `codex` command. +The CLI starts a typed in-process app-server client. Requests use the same +app-server protocol as the rest of Codex, so account state and transitions do +not depend on a second process, an environment-variable socket, or a separate +authentication implementation. ## Ownership -| Concern | CodexMarathon companion | Installed Codex | +| Concern | Native Marathon service | Codex core | | --- | --- | --- | | Account aliases and profile metadata | owns | observes target | -| Opaque credential snapshots | owns in protected vault | reads active auth | -| Active account choice and transition intent | owns | reports result | +| Opaque credential snapshots | owns protected storage | provides active snapshot | +| Account policy and transition intent | owns | receives request | | Running-turn count and safe boundary | consumes | authoritative | -| Auth reload and transport invalidation | requests through supported interface | owns | -| Conversation files and thread identity | records resume identity | owns | -| Recovery queue and exactly-once continuation | observes/correlates | owns | -| Quota telemetry and reset policy | owns policy and ranking | supplies active observations | -| Transition journal and uncertain reconciliation | owns | returns evidence | +| Auth reload and transport invalidation | requests | performs | +| Conversation and thread state | observes identity | owns | +| Recovery continuation | coordinates release condition | owns parked turn | +| Quota observations and reset policy | ranks and schedules | supplies native observations | + +Marathon never changes credentials during an active turn. It validates the +source identity immediately before a transition, atomically persists the +target snapshot, asks Codex to reload its native authentication, invalidates +account-bound transports, and verifies the target identity before committing. -The companion must never write `auth.json` while Codex has an active turn. A -transition first attempts the supported local control interface. If it is -unavailable or incompatible, the companion controls a graceful process stop, -performs the atomic deployment, relaunches the same installed executable, and -passes the captured conversation/thread resume identity. +## Runtime crates -## Integration boundary +`runtime/codex-rs/codexmarathon-runtime` contains the domain model, durable +account state, opaque snapshot vault, transition journal, quota policy, and +native authority bridge. The adjacent `runtime/codexmarathon-adapter` crate +contains reusable typed runtime and recovery bridge types used by the native +app-server composition. It does not own credentials or start another Codex +process. -The protocol schemas in [`protocol/`](../protocol/) describe the local -control messages for a compatible Codex build. A compatible implementation -must expose readiness, safe-boundary, auth reload, transport invalidation, and -identity-generation evidence. The companion verifies the target identity -after the reload before releasing a parked recovery continuation. +The app-server protocol defines the typed `marathon/status`, +`marathon/enabled/set`, `marathon/autoReset/set`, `marathon/import`, and +`marathon/switch` requests. Login continues through Codex's native login +service, including the browser-link and device-code flows. -The fallback path has the same commit rule. A clean process exit and a disk -write are insufficient; the resumed Codex process must report the target -account and the expected generation/thread identity. If the process exits or -the acknowledgement is lost after a state-changing step, the journal keeps -the transition unresolved and reconciliation retries the same transition -identity instead of starting a second one. +## Recovery and reset rules -No donor checkout is read at runtime. The companion's installed-Codex path is -resolved from `--codex`, configuration, or `PATH`, and its version/capability -check fails with an actionable error when the selected binary is unsuitable. +Codex owns the parked usage-limit continuation. Marathon releases it only after +the account transition has verified the target identity. If every managed +account has zero weekly quota, the scheduler may consider an account with a +provider reset capability. The capability is disabled by default, and tests +use a fake executor so no real provider reset is called. diff --git a/docs/build-test.md b/docs/build-test.md index 2e67f921..3cc2130b 100644 --- a/docs/build-test.md +++ b/docs/build-test.md @@ -1,71 +1,49 @@ # Build and verification -The normal CodexMarathon companion release requires Go and Python. It does -not require Cargo because the user's Codex CLI is installed separately. +The supported product is the custom Codex Rust CLI with Marathon compiled into +the same binary. A Rust toolchain and the native build dependencies are +required; no Go toolchain or external Marathon process is part of the build. -Run the focused checks from the repository root: +From the repository root, run the focused checks: ```bash -(cd controller && go test ./...) -(cd controller && go vet ./...) -(cd integration && go test ./...) -python scripts/test_package_release.py -python scripts/verify_provenance.py --root . -python scripts/verify_package.py --root . -python scripts/verify_protocol.py --root . +cd runtime/codex-rs +cargo fmt --all -- --check +cargo check -p codexmarathon-runtime +cargo check -p codex-login +cargo check -p codex-app-server +cargo check -p codex-tui +cargo check -p codex-cli +cargo test --locked -p codexmarathon-runtime +cargo build --locked --release -p codex-cli ``` -Build the normal Linux companion artifact with: +The release executable is written to: -```bash -bash scripts/build-release.sh +```text +runtime/codex-rs/target/release/codex ``` -The script compiles the Go controller, chooses `linux-x86_64` or -`linux-aarch64` from the host, and packages only the companion executable and -allow-listed metadata. Verify the exact archive and its clean-machine -boundary: +Run the command surface smoke check against that binary: ```bash -artifact=$(find dist/release -maxdepth 1 -type f -name '*.tar.gz' -print -quit) -python scripts/verify_package.py --artifact "$artifact" -python scripts/clean_machine_check.py --artifact "$artifact" +runtime/codex-rs/target/release/codex --version +runtime/codex-rs/target/release/codex marathon --help ``` -`clean_machine_check.py` runs the packaged controller's initialization, -status, and empty account-list commands with an empty `PATH` and isolated -Codex home. This proves that the companion does not need a repository, -toolchain, donor checkout, or bundled runtime for local state management. A -separate installed `codex` executable is required for live login and process -handoff tests. - -The imported Rust adapter/runtime remains available for optional protocol -development and the explicit embedded-runtime variant: - -```bash -(cd runtime/codexmarathon-adapter && cargo test) -(cd runtime/codex-rs && cargo test --locked -p codexmarathon-runtime) -CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME=1 bash scripts/build-release.sh -python scripts/live_smoke.py \ - --runtime-binary runtime/codex-rs/target/release/codex-app-server -``` - -The optional smoke is evidence about that explicitly bundled runtime only. It -does not replace testing against the installed Codex executable, OAuth, quota, -reload, controlled restart, or conversation resume. - -On Windows, use `scripts/build-release.ps1` for the Go companion archive. The -same packaging contract applies; the named-pipe or process-restart acceptance -tests must run on a Windows host when Windows support is enabled. +For a server without a local browser, use `codex marathon login ALIAS +--device-code`; the command prints a verification URL and one-time code. Use a +disposable `CODEX_HOME` for account and transition tests. ## Evidence labels -- `static`: manifests, schemas, provenance, and forbidden-content checks. -- `focused`: deterministic unit or package tests. -- `integration`: controller and protocol fixtures together. -- `live`: an installed Codex process was exercised in the selected mode. -- `blocked`: a required toolchain, binary, platform, or credential was absent. -- `pending`: the behavior still needs implementation or an acceptance run. +- `static`: formatting, source review, and whitespace checks. +- `focused`: deterministic unit tests for the native Marathon runtime. +- `build`: a release `codex-cli` executable was produced by Cargo. +- `live`: the resulting Codex executable was exercised with a real account and + safe disposable Codex home. +- `blocked`: a required Rust toolchain, linker dependency, platform, or test + credential was unavailable. -Static, focused, or optional embedded-runtime evidence must not be described -as proof of live installed-Codex switching. +A successful runtime unit test or compilation proves the native code builds; +it does not by itself prove a live account switch or provider login. diff --git a/docs/companion-cli.md b/docs/companion-cli.md deleted file mode 100644 index 64e28cd2..00000000 --- a/docs/companion-cli.md +++ /dev/null @@ -1,36 +0,0 @@ -# Installed Codex companion mode - -CodexMarathon can run as a companion to a user's installed `codex` command. -In this mode it does not start or ship `codex-app-server`; it owns only the -Codex process it launched and leaves the user's normal Codex home, sessions, -configuration, and CLI version in place. - -When an installed Codex version exposes the Marathon reload protocol, the -controller can switch credentials at the runtime's safe boundary and keep the -current process alive. Older or unmodified versions use the controlled -restart fallback: - -```text -running codex process - | - | SIGINT and wait for process exit - | SIGTERM/SIGKILL only after the deadline - v -atomic target auth.json deployment - | - v -codex resume -``` - -The target may use `codex resume --last` when the caller does not have a -specific thread ID. The resume target and optional CLI arguments are passed as -separate argv values; shell interpolation is never used. The process manager -does not inspect or log credentials. - -The deployment callback runs only after the previous process has exited. If -deployment fails, the process remains stopped so its in-memory credentials -cannot race the failed transition. If the process does not honor the graceful -signal, the manager escalates in bounded phases before returning. A companion -supervisor never scans for or signals an unrelated Codex process; installations -that were already running before the companion started must be relaunched -through the companion to establish ownership. diff --git a/docs/features-and-usage.md b/docs/features-and-usage.md index 9f0fa0c0..15ea985d 100644 --- a/docs/features-and-usage.md +++ b/docs/features-and-usage.md @@ -1,372 +1,101 @@ # CodexMarathon features and usage -This guide explains what CodexMarathon is useful for, how to install it, and -how to use it beside an existing Codex CLI. +CodexMarathon is built into the custom Codex Rust CLI. The installed result is +one `codex` executable; there is no companion process, control socket, or +second command to supervise. -## The problem it solves +## Native commands -Codex accounts are often tied to different jobs. A developer may have a work -account, a personal account, and an account for an open-source project. When -one account reaches a limit, manually changing credentials can interrupt the -conversation and make it easy to resume the wrong session. - -CodexMarathon gives those accounts names and a controlled handoff. It watches -the usage information available from Codex, chooses an eligible profile, and -keeps the conversation associated with the same Codex thread. - -The important design choice is that CodexMarathon is a companion. The Codex -CLI is installed separately and remains the program that talks to the model. -The companion stays small and works beside that installation. - -## Features - -### Multiple account profiles - -Store several account profiles with stable IDs and friendly aliases such as -`work`, `personal`, or `opensource`. Profile metadata is separate from the -opaque credential snapshot used for deployment. - -### Usage-aware account selection - -The controller can combine active Codex rate-limit observations with stored -account observations. It ranks fresh, usable profiles and avoids selecting an -account that is known to be exhausted or cooling down. - -### Safe live switching - -When the installed Codex build exposes the supported local control interface, -CodexMarathon asks Codex to perform the change at a safe boundary. Codex owns -the active-turn guard, authentication reload, account-bound transport -invalidation, and identity report. The companion verifies the reported account -before it considers the change complete. - -### Controlled restart and resume - -When live reload is unavailable, CodexMarathon can use the process it launched: - -```text -safe boundary -> graceful stop -> atomic credential deploy - -> start the same codex executable -> resume the same thread - -> verify the target identity -``` - -The process manager escalates from interrupt to termination only after a -bounded wait. It never scans for or kills an unrelated Codex process. This -ownership rule prevents an ordinary `codex` session from being interrupted by -an independently started companion. - -### Continuation without duplicate prompts - -The transition and resume target are journaled as one operation. CodexMarathon -does not create a second synthetic prompt when Codex already owns the recovery -turn. If a process or acknowledgement disappears, the journal provides the -information needed for reconciliation. - -### Reset and recovery policy - -If every known account is exhausted, the policy can wait for the earliest -trustworthy reset time and revalidate usage before selecting an account. Stale -observations are not treated as proof of available quota. - -### Small, safe distribution - -The archive contains the companion and its documentation. It never contains -your `auth.json`, credential snapshots, journals, or controller state. - -## Use the native Codex integration - -The embedded Codex build also exposes Marathon directly. These commands run -inside the single `codex` executable and use its native app-server service: +Use Marathon from a shell: ```bash codex marathon status +codex marathon accounts +codex marathon login personal --device-code +codex marathon login work --device-code codex marathon on -codex marathon off -codex marathon import personal -codex marathon login work codex marathon switch work ``` -Inside an interactive Codex session, the same operations are available as -`/marathon status`, `/marathon on`, `/marathon off`, `/marathon import ALIAS`, -`/marathon login ALIAS`, and `/marathon switch ALIAS_OR_ID`. - -`login` starts Codex's normal browser login flow, prints the URL, waits for -Codex to report that login completed, and then stores the resulting native -credential under the alias. It never asks you to paste a token. If the -browser does not open automatically, open the printed URL yourself. `import` -captures the currently authenticated Codex identity after a normal `codex login`; -it does not accept credential text. - -## Install CodexMarathon - -Install and authenticate Codex using its normal distribution first: - -```bash -codex --version -codex login -``` - -Download the matching CodexMarathon archive from the -[GitHub releases page](https://github.com/Lem0nTree/codexmarathon/releases). -Linux archives use `linux-x86_64` for amd64 and `linux-aarch64` for ARM64. - -Install the companion in a user-owned directory: - -```bash -tar -xzf codexmarathon--linux-.tar.gz -cd codexmarathon--linux- -install -m 0755 codexmarathon "$HOME/.local/bin/codexmarathon" -``` - -Check that both commands are available: - -```bash -codex --version -codexmarathon --help -``` - -CodexMarathon does not overwrite the `codex` executable. It does not copy -credentials into the installation directory. - -## Initialize and inspect the companion - -Create the controller-owned state directories and inspect their status: - -```bash -codexmarathon init -codexmarathon status -codexmarathon status --json -codexmarathon accounts list -``` - -For automation, use `--json` and provide explicit paths: - -```bash -codexmarathon status \ - --state-dir "$HOME/.config/CodexMarathon" \ - --auth "$HOME/.codex/auth.json" \ - --json -``` - -Status output contains account IDs, health, policy, and lifecycle information. -It does not print access or refresh tokens. - -## Start Codex through the companion - -Starting a conversation through CodexMarathon gives the companion ownership of -that process. Ownership is required for the restart/resume fallback: - -```bash -codexmarathon run --codex codex -``` - -Pass a working directory and normal Codex options explicitly: - -```bash -codexmarathon run \ - --codex "$HOME/.local/bin/codex" \ - --codex-home "$HOME/.codex" \ - --codex-dir "$HOME/projects/my-app" \ - --codex-arg=--no-alt-screen \ - --codex-arg=--model \ - --codex-arg=gpt-5 -``` - -Arguments supplied with `--codex-arg` are passed as separate argument values; -they are not interpreted by a shell. Positional values after the options are -also forwarded to Codex when the command-line parser accepts them. - -If Codex is already running with a compatible local control channel, attach to it: - -```bash -codexmarathon run --attach-only -``` - -Without `--attach-only`, the command starts the installed Codex executable if -no compatible local control endpoint is available. - -## Manage profiles - -List profiles and inspect one profile: - -```bash -codexmarathon accounts list -codexmarathon accounts status work -``` - -Rename a local alias or select a profile manually: - -```bash -codexmarathon accounts rename --name work account-id -codexmarathon accounts activate account-id -codexmarathon accounts use account-id -``` - -Remove a profile when it is no longer needed: - -```bash -codexmarathon accounts remove account-id -``` - -The account ID in these examples is the ID printed by `accounts list`; the -word `work` is an example alias, not a special built-in account. - -## Add or refresh credentials - -If Codex is already authenticated through a normal stock Codex CLI install, -bootstrap the first profile by importing its active `auth.json` snapshot: +`status` and `accounts` show only aliases, IDs, quota summaries, enabled state, +and transition state. Credential snapshots are never printed. `import` stores +the identity that is already active in Codex: ```bash -codexmarathon accounts import \ - --auth "$HOME/.codex/auth.json" \ - --name personal \ - --activate -``` - -The import reads the existing file, keeps the authentication document opaque, -and stores it in CodexMarathon's owner-only vault. It reports only the account -ID and alias; token values are never printed or written to the registry. Use -`--from-auth ` when the source file differs from the `--auth` deployment -path. If the snapshot does not contain an `account_id`, supply a stable value -with `--id `. - -The account login and refresh commands use the Codex authentication service -through a compatible Marathon runtime endpoint: - -```bash -codexmarathon accounts login \ - --runtime unix:///path/to/marathon-runtime.sock \ - --name work \ - --activate - -codexmarathon accounts refresh \ - --runtime unix:///path/to/marathon-runtime.sock account-id -``` - -The endpoint is a local IPC endpoint, not a public network service. Do not -place token values in `--runtime` or any other command argument. If your -installed Codex release does not expose the compatible login endpoint, use its -normal `codex login` flow, then run `accounts import` as shown above. - -## Switch accounts - -For a live installed Codex session, request a switch by account ID: - -```bash -codexmarathon switch account-id -``` - -You can specify the installed executable, Codex home, or control endpoint: - -```bash -codexmarathon switch \ - --codex "$HOME/.local/bin/codex" \ - --codex-home "$HOME/.codex" \ - --app-server-endpoint unix:///tmp/codex-control.sock \ - account-id -``` - -The preferred path asks Codex to reload at a safe boundary. If the endpoint -does not support auth reload, the switch can use restart/resume only when the -Codex process was started by `codexmarathon run` in the same supervisor. - -## A normal workday - -Here is a complete example for a developer with work and personal accounts: - -```bash -# One-time setup -codexmarathon init -codexmarathon accounts list - -# Start a project using the installed Codex CLI -codexmarathon run --codex codex --codex-dir "$HOME/projects/api" - -# Check the active account from another terminal -codexmarathon status --json - -# Make a deliberate change if needed -codexmarathon switch work-account +codex marathon import personal ``` -For automatic selection, leave the companion session running. It consumes the -installed Codex event stream, feeds fresh observations into the policy loop, -and invokes the live or restart transition at the appropriate boundary. - -## Troubleshooting +Use `codex marathon off` to leave the profiles stored while disabling +automatic Marathon account management. A switch is accepted only at a safe +turn boundary and updates Codex's native in-memory authentication after the +durable transition is committed. -### `installed Codex CLI was not found` - -Check the executable and pass it explicitly: +The automatic reset control is explicit and disabled by default: ```bash -command -v codex -codexmarathon run --codex /full/path/to/codex +codex marathon auto-reset status +codex marathon auto-reset on +codex marathon auto-reset off ``` -You can also set `CODEX_BIN` for scripts. +It is eligible only when every managed account has zero weekly quota and an +account advertises a provider-supported reset capability. Development and test +paths use mocks only; no provider reset action is invoked by this project. -### The companion cannot attach +## Interactive commands -Confirm the Codex home and endpoint: +Inside a running Codex session, `/marathon` opens the status and help panel. +The same actions are available as slash commands: -```bash -echo "$CODEX_HOME" -codexmarathon run --codex-home "$HOME/.codex" --attach-only +```text +/marathon +/marathon status +/marathon accounts +/marathon on +/marathon off +/marathon auto-reset status +/marathon auto-reset on +/marathon auto-reset off +/marathon import personal +/marathon login work +/marathon login work browser +/marathon login work device-code +/marathon switch work ``` -If the installed version has no compatible local control interface, launch it -through `codexmarathon run` so the controlled restart/resume path is available. +The login panel asks whether to use a browser link or a device/auth code. On a +headless server, choose device code, open the displayed verification URL on +another machine, and enter the one-time code. After native login completes, +the identity is saved under the requested alias. -### A restart is refused +## Status line -CodexMarathon refuses to signal a process it did not start. Stop the existing -Codex session normally, then start it through the companion. This is a safety -boundary that protects unrelated sessions. +Add the `marathon` and `marathon-accounts` items to the Codex status line. The +first shows whether Marathon is enabled; the second shows the number of +managed accounts. -### A command asks for `--runtime` +## Account and transition safety -The login and refresh paths require a compatible Marathon authentication -endpoint. The normal installed-Codex process path uses `run` and `switch`. +Only managed ChatGPT profiles can be switched. Codex remains authoritative for +the active turn, authentication reload, account-bound transport invalidation, +and recovery continuation. Marathon coordinates the policy and durable account +metadata, and asks Codex to perform a switch only after all account-bound work +is idle. -## Developer utilities +The source identity is checked again before deployment. Target credentials are +validated and written atomically. A failed or uncertain transition remains +visible for reconciliation and is never reported as committed merely because a +file was changed. -Build the companion and run the deterministic checks: +## Build and verification -```bash -bash scripts/build-release.sh -(cd controller && go test ./...) -(cd controller && go vet ./...) -(cd integration && go test ./...) -python3 scripts/test_package_release.py -python3 scripts/verify_protocol.py --root . -python3 scripts/verify_provenance.py --root . -``` - -Verify a release archive from a clean environment: +The native CLI is built from the tracked Rust workspace: ```bash -python3 scripts/verify_package.py --artifact dist/release/.tar.gz -python3 scripts/clean_machine_check.py --artifact dist/release/.tar.gz +cd runtime/codex-rs +cargo build --release -p codex-cli +cargo test --locked -p codexmarathon-runtime ``` -## Security model - -CodexMarathon treats credential snapshots as opaque data. It uses restrictive -permissions and atomic replacement for controller-owned credential deployment. -It avoids putting secrets in command output, journals, generated manifests, -or archives. - -The companion cannot make a third-party or unrelated process reload a secret by -force. Live reload must be accepted by the installed Codex control interface; -otherwise only a process owned by the companion can be restarted and resumed. - -## Further reading - -- [Main project README](../README.md) -- [Companion process design](companion-cli.md) -- [Architecture and ownership](architecture.md) -- [Build and verification](build-test.md) -- [Release checklist](release.md) -- [Project completion plan](../PLAN.md) +See [Build and verification](build-test.md) for the focused checks and +[Runtime provenance](../runtime/PROVENANCE.md) for the imported Codex source. diff --git a/docs/recovery.md b/docs/recovery.md index 0e6d0ed2..aa1b5ed1 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -1,62 +1,42 @@ -# Conversation recovery with an installed Codex CLI +# Conversation recovery -Codex remains the authority for the conversation, thread state, parked -recovery turn, and exactly-once continuation. CodexMarathon only chooses a -target account and coordinates the identity transition that must happen before -Codex releases its existing recovery work. +Codex owns the conversation, thread state, parked usage-limit continuation, +and exactly-once resume. Native Marathon selects an eligible account and +coordinates the identity transition that must complete before Codex releases +its existing recovery work. -When Codex reports `UsageLimitExceeded`, the sequence is: +When Codex reports `UsageLimitExceeded`, the native flow is: ```text Codex parks its existing recovery turn | v -companion selects a fresh eligible account +Marathon selects a fresh eligible managed account | - +--> supported local control interface - | safe boundary -> reload -> invalidate -> verify identity + v +wait for safe boundary -> persist target -> native auth reload | - `--> controlled process handoff - graceful stop -> atomic auth deploy -> relaunch codex - -> resume same thread -> verify identity + v +invalidate account transports -> verify target identity | v Codex releases its parked recovery turn exactly once ``` -The companion never edits `auth.json` during an active turn, creates another -turn counter, copies the recovery prompt, or submits a second continuation. -If no account has usable fresh telemetry, the recovery stays parked while the -reset scheduler waits and then revalidates quota after the reset timestamp. - -## Supported local control path - -For a compatible installed Codex version, the companion opens its protected -user-scoped local control endpoint. It correlates a transition ID and expected -auth generation, waits for the runtime-owned safe boundary, requests the -native authentication reload and transport invalidation, and reads the -resulting runtime identity. Recovery is released only after the target account, -generation, and deployed credential identity agree. - -Lost replies remain unresolved in the journal. Reconnect/reconciliation retries -the same transition ID and never starts a new recovery turn. - -## Controlled restart path +Marathon never edits the active credential during a running turn, creates a +second turn counter, copies the recovery prompt, or submits a duplicate +continuation. If no account has fresh usable quota, the recovery remains +parked while the policy waits for quota state to change and revalidates it. -When the installed Codex version has no compatible local control interface, -the companion uses a controlled process boundary: +The transition journal records the same transition and recovery IDs across +failures. A missing acknowledgement leaves the result uncertain until the +native service can read the account and generation evidence; it is not guessed +from a file timestamp or process exit. -1. Ask the Codex process to stop cleanly at its normal boundary and persist - the conversation/thread resume identity. -2. Confirm the process has exited before changing the active credential file. -3. Atomically deploy the selected profile snapshot and preserve any refreshed - fields written by the old process. -4. Relaunch the same installed `codex` executable with the captured resume - identity and the original user arguments. -5. Verify that the resumed process reports the target account before allowing - Codex's parked recovery turn to run. +## Reset behavior -If a restart fails after deployment, journal the transition as uncertain and -reconcile the same process/thread identity after the next launch. A timestamp -or file existence check never proves that the new process adopted the target -identity. +Automatic reset is opt-in. It becomes eligible only after every managed +account reports zero weekly quota and at least one account exposes a supported +provider reset capability. The reset executor is disabled in the current +product and all development/tests use a fake executor. No real provider reset +is sent by this repository. diff --git a/docs/release.md b/docs/release.md index a897ae01..fc3b5f47 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,81 +1,48 @@ # Release validation -CodexMarathon's default release is a companion archive containing the Go -controller and allow-listed metadata. A user installs Codex separately. The -release process must not silently bundle `codex-app-server`, replace an -existing `codex` executable, or write user credentials into the archive. +CodexMarathon releases are custom Codex CLI builds with native Marathon +controls compiled into the `codex` executable. The release does not include a +separate controller, Go binary, or companion archive. -## Required companion checks +## Required checks From the repository root: ```bash -python scripts/verify_provenance.py --root . -python scripts/verify_package.py --root . -python scripts/verify_protocol.py --root . -python scripts/test_package_release.py -(cd controller && go test ./...) -(cd integration && go test ./...) -(cd controller && go vet ./...) -bash scripts/build-release.sh -artifact=$(find dist/release -maxdepth 1 -type f -name '*.tar.gz' -print -quit) -python scripts/verify_package.py --artifact "$artifact" -python scripts/clean_machine_check.py --artifact "$artifact" +python3 scripts/verify_provenance.py --root . +cd runtime/codex-rs +cargo fmt --all -- --check +cargo test --locked -p codexmarathon-runtime +cargo build --locked --release -p codex-cli ``` -The package regression test covers both the normal companion archive and the -explicit opt-in embedded-runtime archive. The clean-machine check extracts -the exact release, sets an empty `PATH`, uses an isolated `CODEX_HOME`, and -runs `init`, `status --json`, and an empty account listing. It proves local -state setup without a repository, toolchain, donor checkout, or bundled -Codex. It does not prove OAuth or live account switching. +The resulting executable is `runtime/codex-rs/target/release/codex`. Verify +that its Marathon surface is present: -## Installed-Codex acceptance - -The live acceptance environment must provide a real `codex` executable and a -safe disposable Codex home. Record the exact executable path and version, then -exercise both supported handoff paths: - -1. Start a conversation through CodexMarathon. Exhaust or simulate the active - account at a safe turn boundary. Confirm that the companion requests the - installed Codex local control interface to reload the target credential, - invalidates account-bound transports, and reports the target identity and - generation before continuation is released. -2. Repeat with a Codex version that does not expose the local control - capability. Confirm that the companion asks the process to exit cleanly, - atomically deploys the target credential, relaunches the same installed - executable, resumes the same thread/conversation, and verifies the target - identity before releasing recovery. - -Both runs must show one continuation in the original conversation. Capture -process logs only after redaction; never record access or refresh tokens. - -The full Gate 7 contract in [`PLAN.md`](../PLAN.md) also covers active-turn -protection, stale generations, lost acknowledgements, pool exhaustion and -reset revalidation, refreshed-token write-back, and restart recovery. +```bash +runtime/codex-rs/target/release/codex marathon --help +runtime/codex-rs/target/release/codex marathon status +``` -## Optional embedded-runtime package +## Live acceptance -The imported Rust runtime is retained for protocol development and controlled -offline diagnostics. It is excluded from the default package. Build it only -with explicit opt-in: +Use a disposable `CODEX_HOME` and a real ChatGPT account only in an explicit +acceptance environment. Record the executable version and exercise: -```bash -CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME=1 bash scripts/build-release.sh -python scripts/live_smoke.py \ - --runtime-binary runtime/codex-rs/target/release/codex-app-server -``` +1. `codex marathon login ALIAS --device-code` on a headless host. +2. A second account import or login under another alias. +3. `codex marathon status`, `on`, `off`, and `switch ALIAS`. +4. `/marathon` help and status inside an interactive session. +5. Status-line Marathon state and managed-account count. +6. Recovery at a safe turn boundary with no duplicate continuation. -The generated manifest marks this package -`companion-with-embedded-runtime`. Its smoke result is evidence about the -optional binary and cannot be substituted for installed-Codex acceptance. +Do not invoke a real quota reset while testing automatic reset. The product +uses mocked reset capabilities and executors until a provider integration is +explicitly reviewed and enabled. -## Archive and provenance policy +## Provenance -`scripts/verify_package.py` rejects unsafe paths, duplicate members, missing -hash records, and obvious credential material. It requires one controller -entrypoint and rejects an embedded runtime in an archive marked `companion`. -`runtime/PROVENANCE.md`, `runtime/PATCH_LEDGER.md`, and the Apache-2.0 notices -remain allow-listed metadata for the optional runtime source. Donor checkouts, -Cargo targets, auth files, vault snapshots, journals, and developer caches -are never release inputs. +The embedded Codex source and local Marathon additions are documented in +[`runtime/PROVENANCE.md`](../runtime/PROVENANCE.md) and +[`runtime/PATCH_LEDGER.md`](../runtime/PATCH_LEDGER.md). Do not package local +credentials, Cargo target output, or developer caches in a release. diff --git a/docs/runtime-adapter.md b/docs/runtime-adapter.md index 303107b8..5089f04f 100644 --- a/docs/runtime-adapter.md +++ b/docs/runtime-adapter.md @@ -1,114 +1,27 @@ -# Codext runtime adapter seam - -This document records the deliberately narrow Rust seam for CodexMarathon -protocol v1. The pinned Codex runtime is imported under `runtime/codex-rs/`; -the adapter and bridge reuse its native authorities rather than creating -parallel authentication, turn, transport, or recovery implementations. The -Go controller only coordinates intent and verifies observed state. - -## Boundary and transport - -The adapter speaks the shared schemas in `protocol/` over a bidirectional -stream (user-scoped Unix socket, protected named pipe, or an in-process test -pipe). The production Codex app-server creates this listener itself when the -launcher supplies `CODEXMARATHON_LISTEN`; users do not install or launch a -separate adapter process. Messages are -newline-delimited JSON-RPC 2.0. Controller requests use the namespaced methods -defined in `protocol/commands.json`; runtime notifications use the -`codexmarathon/event` method and event types from `protocol/events.json`. - -The first request on a connection is `protocol/negotiate`. A runtime must -return a version in the controller's advertised set. A `runtime_ready` event -then announces the runtime ID, selected protocol version, current account -identity, and auth generation. Ordinary state/events contain no credentials; -the three explicitly authenticated native account methods carry opaque -snapshots only in memory over the local protocol and never into the event -journal. - -## Observed donor evidence - -The read-only checkout under `donor/codext/` is the refresh reference for the -tracked source under `runtime/codex-rs/`. The following symbols are the -evidence for the adapter mapping: - -| Marathon operation | Existing Codext owner / evidence | Adapter responsibility | -| --- | --- | --- | -| Active rate-limit snapshot | `codex-rs/app-server-protocol/src/protocol/v2/account.rs`: `GetAccountRateLimitsResponse` (lines 314–325); `common.rs` maps `account/rateLimits/read` (lines 1229–1232) | Call the existing account rate-limit read, narrow it to `RateLimitSnapshotWire`, and emit `rate_limits_snapshot`. | -| Sparse rate-limit update | `account.rs`: `AccountRateLimitsUpdatedNotification` (lines 562–569), documented as a sparse rolling update; `common.rs` maps `account/rateLimits/updated` (line 1902) | Attach the connection's observed account ID and forward as `rate_limits_updated`; the controller merges or refetches when attribution is ambiguous. | -| Runtime identity | `login/src/auth/manager.rs`: `auth_cached` (lines 2328–2334), `auth` (lines 2351–2368), and account accessors around lines 2963–2975 | Report only account ID/auth mode metadata needed for identity verification; do not expose token material. | -| Native account login | `login/src/server.rs`: `run_login_server` and `ServerOptions`; `login/src/auth/manager.rs`: `load_auth_dot_json` | `CodexNativeRuntime` stages the browser callback in a temporary `CODEX_HOME`, then returns an opaque `auth_json` snapshot to the protected controller vault. | -| Native account refresh | `login/src/auth/manager.rs`: `AuthManager::shared` and `refresh_token_from_authority` | `CodexNativeRuntime` stages the supplied snapshot in a temporary `CODEX_HOME`, invokes the existing native refresh authority, and returns only changed token fields; it never shells out to `codex login`. | -| Safe turn boundary | `app-server/src/request_processors.rs`: `reload_auth_from_storage_if_idle` (lines 540–579) checks `subscribe_running_turn_count()` and returns while nonzero; `thread_processor.rs` serializes `thread/start` and `thread/resume` with `auth_transition_lock` (lines 1119–1129 and 3593–3604) | Keep the pending transition ID in adapter state, wait for this existing guard to reach zero, then emit one `safe_boundary_reached`. Do not add a second turn counter. | -| Auth reload | `request_processors.rs`: `reload_auth_from_storage_if_idle` calls `AuthManager::reload_with_status` (lines 557–562), while `handle_auth_reload_status` handles changed/failed outcomes (lines 581–625) | On commit, call the existing reload path and map its outcome to `auth_reload_started`, `auth_reload_succeeded`, or `auth_reload_failed`. | -| Transport invalidation | `request_processors.rs`: changed auth calls `thread_manager.invalidate_model_transport_caches()` (lines 593–600); `core/src/codex_thread.rs` delegates to the model client (lines 269–279) | Never recreate or duplicate the transport cache. Emit `identity_changed` only after the existing invalidation/refresh path returns. | -| Account update | `request_processors.rs`: changed auth refreshes cloud config and emits `AccountUpdated` (lines 601–620) | Use the resulting observed identity for the Marathon event; `AccountUpdated` remains a Codext notification and is not replaced. | -| Auth file compatibility path | `tui/src/auth_watch.rs`: watches `auth.json`, polls every 200ms, and waits through a 200ms quiet period before `AuthFileChanged` (lines 12–24 and 50–77) | Leave the watcher unchanged. Explicit Marathon IPC is primary; the existing watcher remains a compatibility/fallback path. | -| Runtime-owned recovery | `login/src/auth/manager.rs`: `UnauthorizedRecovery` documents reload-then-refresh ordering (lines 1850–1862); `core/src/client.rs` emits recovery events around `handle_unauthorized` (lines 2402–2479) | Forward `recovery_parked`, `recovery_started`, and `recovery_completed` observations. The controller must not construct a recovery prompt or queue. | - -The Codex-switch donor supplies the control-plane pattern that motivated this -split: `internal/profile/profile.go` stores independent profile snapshots, -`internal/switcher/switcher.go` performs a temp-write/rename replacement, and -`internal/watcher/watcher.go` confirms an active profile before switching. -Those features are imported into the controller in later account-manager -gates. The runtime adapter must not duplicate them. - -## Protocol-to-Codext mapping - -| Protocol v1 message | Codext action / result | -| --- | --- | -| `GetRuntimeState` | Read current AuthManager identity, the existing running-turn count, and adapter pending-transition state. | -| `PrepareAuthTransition` | Validate `transition_id`, `target_account_id`, and `expected_generation`; record intent. No auth reload occurs. | -| `SafeBoundaryReached` | Adapter observes the existing running-turn guard at zero. This is a notification, not permission to invent a new boundary authority. | -| `CommitAuthTransition` | Require the exact pending ID/generation, then invoke the existing AuthManager reload path after controller atomic deployment. Reload and `ThreadManager::invalidate_model_transport_caches` execute under the shared auth-transition lock; the result is rejected unless AuthManager observes the exact target account. | -| `account/login` / `account/refresh` | Delegate to `CodexNativeRuntime`'s native login/refresh methods; opaque credentials are not interpreted by the adapter and are never journalled. | -| `account/authSnapshot/read` | Read the active opaque snapshot from the shared native `AuthManager`; the controller synchronizes it into the protected Account A vault before deploying Account B. | -| `CancelAuthTransition` | Clear matching adapter intent without touching AuthManager or conversation state. | -| `GetIdentity` / `GetAuthGeneration` | Return the same runtime-observed identity/generation used in reconciliation. | -| `IdentityChanged` | Report the post-reload account and generation after Codext invalidates account-bound transports and refreshes account state. | - -Every transition event carries `transition_id`, `runtime_id`, and -`auth_generation`. A stale command (lower generation or a non-matching pending -ID) is rejected and must not call AuthManager. The adapter should return a -typed `rejected` result; transport loss after a state-changing operation is -reported to the controller as an uncertain outcome to be reconciled, not as a -guessed failure. - -## Patch ledger - -The runtime patch should stay reviewable and reappliable onto fresh upstream -Codex versions: - -| ID | Narrow addition | Explicitly not changed | -| --- | --- | --- | -| M01 | Marathon JSON-RPC server and version negotiation | Existing app-server transport semantics | -| M02 | In-memory auth-generation counter persisted only in adapter/runtime state | AuthManager token refresh and storage format | -| M03 | Transition ID correlation, pending intent, and stale-generation rejection | Existing `auth_transition_lock` and running-turn guard | -| M04 | Runtime identity/status API and rate-limit event forwarding | Existing rate-limit provider and account read implementation | -| M05 | Safe-boundary, reload, identity, and recovery event translation | Existing transport invalidation, recovery queue, and conversation/session manager | - -The MVP does not require controller-owned repository checkpointing, a second -active-account quota poller, a custom `AuthManager`, custom transport teardown, -or a controller recovery prompt. These were explicitly removed by the -architecture freeze because they would create competing authorities or -duplicate Codext behavior. - -## Adapter acceptance checks - -Before the adapter is considered complete, its focused tests should prove: - -1. A commit requested while a turn is running remains pending until Codext's - running-turn count reaches zero. -2. A stale generation or transition ID is rejected without reloading auth. -3. Auth reload failure emits `auth_reload_failed` and leaves the prior identity - authoritative. -4. A changed auth reload uses Codext's existing model-transport invalidation - path before `identity_changed`, and a reload for the wrong account is - rejected with `identity_mismatch`. -5. An active `account/authSnapshot/read` result is synchronized to Account A's - protected vault before the target deployment, and a mismatched snapshot - identity leaves the transition unresolved. -6. A sparse rate-limit notification retains the runtime connection identity - and requests a full read when multiple by-ID buckets make attribution - ambiguous. -7. Runtime recovery events are observed exactly once; no controller-generated - continuation is added. +# Native runtime bridge + +The Rust adapter crate is an internal typed seam used by the embedded +app-server composition. It keeps the native Marathon service independent from +transport details while preserving Codex's ownership of authentication, +turns, transports, and recovery. + +`codexmarathon-runtime` supplies the native backend implementation. The +adapter types carry only account IDs, aliases, generation numbers, quota +windows, transition IDs, and recovery outcomes. Credential snapshots remain +opaque and are never logged or serialized into diagnostics. + +The app-server's normal in-process client is the supported user-facing path: + +```text +codex marathon or /marathon + | + v +typed app-server request + | + v +native Marathon service -> AuthManager / turn manager / recovery owner +``` + +The adapter does not spawn a process, own a second turn counter, or implement +provider authentication. Its tests use fake native authorities and fake reset +executors so a test cannot consume a real account reset. diff --git a/docs/telemetry.md b/docs/telemetry.md index b74bf549..0ea3a102 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,74 +1,27 @@ -# Telemetry and reset decisions +# Telemetry and quota decisions -Installed Codex rate-limit observations are the active-account authority when -the supported local control interface is available. The optional adapter -forwards a complete `rate_limits_snapshot` and sparse `rate_limits_updated` -event; the controller normalizes the upstream camelCase window fields into a -multi-bucket domain model. +The native Codex app-server supplies account rate-limit observations to +Marathon. The runtime keeps complete and sparse window updates separate, +attributes each observation to the account that Codex reports, and preserves +unknown provider fields without using them for policy decisions. ```text -installed Codex event - -> NormalizeSnapshot - -> StateStore (sparse merge authority) - -> SnapshotCache (deep-copy TTL cache) - -> policy.Engine - -> automation.Loop - -> reset.Scheduler when the pool is exhausted +native rate-limit observation + -> account snapshot and sparse merge + -> freshness and quota policy + -> safe-boundary account switch or reset wait ``` -`rateLimits` is the aggregate view. `rateLimitsByLimitId` is the optional map -of metered buckets. The controller never assumes one fixed bucket per account. -Unknown upstream fields remain forward-compatible but have no policy meaning -until a typed model is added. +Freshness is tracked per quota window. An elapsed reset timestamp makes the +observation stale; it does not prove that quota is available. Missing, +ambiguous, or failed observations cannot authorize a switch. -## Sparse updates +When every managed account reports zero weekly quota, the policy can consider +an account that advertises a provider-supported reset capability. Automatic +reset is disabled by default and is exercised with mock capabilities and +executors in development and tests. A real provider reset is never called by +this repository. -An update without `limitId` is applied only when one target bucket can be -identified. If multiple buckets are present, the update is left untouched and -the controller reports `telemetry refetch required`. A runtime connection's -account identity is the attribution source; the sparse payload itself is not -treated as an account selector. - -Nullable metadata does not clear an existing value. An explicit `usedPercent: 0` -is retained as a real server observation, while an omitted usage field does -not create an invented zero-usage window. - -## Freshness - -Freshness is tracked independently per window. `resetsAt <= now` marks only -that window stale. It never proves that usage is zero or that capacity is -available. Provider usability is separate from window freshness, so missing -or failed telemetry cannot become an eligible account. - -## Pool exhaustion - -When no configured account has a complete fresh non-exhausted snapshot, policy -returns `wait_for_reset` when a future server reset is trustworthy, otherwise -`no_telemetry`. The reset scheduler stores the earliest reset as a wake-up -hint. At that boundary the controller must refresh telemetry and reevaluate; -it must not transition merely because the timestamp elapsed. - -The preferred provider is event-driven from the installed Codex process. -Inactive-profile providers are pluggable through `telemetry.UsageProvider`; -`internal/quota.Provider` adapts -the donor `codex-switch/internal/quota` calibration request and retry/header -rules to that interface. `app.NewAutomationLoop` installs that provider for -stored accounts that do not already have an active runtime snapshot provider, -so inactive profiles are checked without starting a second Codex process. The -active installed-Codex provider remains the event/state-store authority and -replaces the fallback when its authoritative snapshot event arrives. - -## Automatic policy - -`internal/automation.Loop` is the feature boundary that turns observations -into an action. It refreshes every configured account once per event, maps -`threshold_reached`, `usage_limit_exceeded`, and reset wakes to explicit -policy triggers, and calls the transition layer only for a deterministic -replacement. A provider error stays attached to that account and cannot be -interpreted as available quota. - -The policy engine carries a one-minute donor-compatible cooldown, remembers -event IDs, and suppresses an in-flight target. When all accounts are -unavailable it returns the earliest future server reset plus the account that -must be revalidated; when no reset is trustworthy it returns a bounded data -retry hint. A wake-up never marks quota restored by elapsed time alone. +After any reset or refreshed observation, Marathon revalidates the complete +quota state before selecting an account. It does not treat elapsed time alone +as proof that a limit was restored. diff --git a/docs/transitions.md b/docs/transitions.md index 38cf96d0..1b613446 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -1,49 +1,35 @@ -# Account transition and reconciliation +# Account transitions -CodexMarathon records one transition ID and one expected target generation for -each account change. The installed Codex process remains authoritative for -active turns, authentication reload, model transport invalidation, and runtime -identity. +The native Marathon service records one transition ID and expected target +generation for each account change. Codex remains authoritative for the +running turn, authentication reload, model transport invalidation, and +conversation recovery. ```text -read installed-Codex identity (A, N) +read native identity (A, N) | -prepare transition to B (tx, N+1) +prepare target B (transition, N + 1) | -supported control: wait safe boundary, reload, invalidate -or fallback: stop Codex, deploy auth, relaunch and resume +wait for safe boundary -> atomically persist B -> reload native auth | -read runtime/process identity and active-auth identity +invalidate account-bound transports -> read identity | -commit only when both agree on B, N+1, and the same conversation +commit only when B, N + 1, and the same thread agree ``` -The companion captures refreshed fields from Account A before deployment, -atomically writes Account B's opaque snapshot, and verifies the target after -reload or after the resumed process starts. It never treats an `auth.json` -mtime, a successful file rename, or an uncorrelated process exit as proof of a -committed switch. +The source identity is rechecked immediately before deployment. Credential +material remains opaque to the service boundary and never enters output, +telemetry, or transition records. ## Outcomes -- `committed`: installed Codex and the deployed credential agree on B at the - expected generation, with the intended conversation identity. -- `rejected`: the installed process rejected the correlated transition before - adoption; the previous account remains active. -- `uncertain`: a process, IPC, journal, or verification failure leaves the - effect unknown. The journal blocks a new transition until the same ID is - reconciled. +- `committed`: Codex and durable Marathon state agree on the target account and + generation. +- `rejected`: Codex rejected the request before adopting the target. +- `uncertain`: a persistence, reload, or identity acknowledgement was lost; + the same transition ID remains available for reconciliation. -For an uncertain transition, reconnect to the same installed Codex process or -resume the recorded conversation, read both authorities, and resolve only -when the account/generation/thread evidence agrees. If the process was lost, -the controlled restart path uses the recorded executable and resume identity; -it does not launch a different Codex installation or submit a duplicate -recovery. - -## Safety requirements - -The local control transport must be user-scoped and authenticated by the -operating system. The fallback must confirm a clean process exit before -writing the active credential. Both paths must preserve Codex's own recovery -queue and release it once, after identity verification. +An uncertain result is never inferred from a file timestamp or a successful +rename. The recovery continuation remains parked until Codex reports the +verified target identity. This preserves exactly-once continuation when the +process or an acknowledgement is interrupted. diff --git a/docs/upstream-sync.md b/docs/upstream-sync.md index c5233708..5f40a3c6 100644 --- a/docs/upstream-sync.md +++ b/docs/upstream-sync.md @@ -1,40 +1,22 @@ -# Upstream and donor synchronization +# Upstream Codex synchronization -`donor/codex-switch` and `donor/codext` are read-only checkouts used to ground -the design and refresh the imported implementation. They are not build or -runtime dependencies and must not be edited as part of an MVP change. The -pinned Codext source is tracked under `runtime/codex-rs`; its source commit and -license are recorded in [`runtime/PROVENANCE.md`](../runtime/PROVENANCE.md). -The [patch ledger](../runtime/PATCH_LEDGER.md) lists each narrow integration -addition and the Codext authority it delegates to. - -The controller account manager's donor mapping is recorded in -[`account-manager.md`](account-manager.md). It adapts the pinned -`humeo/codex-switch` profile store, refresh/write-back, switch, and CLI -semantics into `controller/internal/accounts` and -`controller/internal/credentials`, while `codext` remains the native -AuthManager/login service authority. The manager has no shell-out fallback to -an external Codex installation. +`runtime/codex-rs` is a pinned copy of the upstream Codex Rust workspace. The +source commit, license, and local Marathon additions are recorded in +[`runtime/PROVENANCE.md`](../runtime/PROVENANCE.md) and +[`runtime/PATCH_LEDGER.md`](../runtime/PATCH_LEDGER.md). ## Refresh procedure -1. Fetch the relevant upstream refs in a separate donor checkout. -2. Re-run the source audit for AuthManager reload, running-turn guard, - transport invalidation, rate-limit notifications, and recovery ordering. -3. Re-check protocol payloads against the current runtime schema. -4. Update the optional runtime provenance, adapter patch ledger, and focused - Rust tests. -5. Run `verify.ps1`, Go tests, Rust tests, and the integration module. -6. Review the diff to confirm no donor file changed and no credential value - entered controller logs or journal records. - -CodexMarathon should reapply a small patch onto a fresh upstream runtime rather -than merge unrelated Codext history. If a native symbol moves, update the -mapping and tests first; do not compensate by duplicating the mechanism in Go. - -## Compatibility rules +1. Import the new upstream Rust workspace into a separate review branch. +2. Reapply the native Marathon app-server, CLI, TUI, authentication, and + status-line changes. +3. Recheck AuthManager reload, safe-boundary enforcement, transport + invalidation, quota events, and recovery ordering against the new APIs. +4. Run formatting, focused runtime tests, and a release build of `codex-cli`. +5. Review the diff for credential leaks and verify that no build cache or local + account state entered the commit. -Protocol `VERSION` is the shared compatibility integer. The first request on a -connection is `protocol/negotiate`. Every transition event carries -`transition_id`, `runtime_id`, and `auth_generation`. Unknown event fields are -forward-compatible; unknown semantics are not eligible for policy decisions. +Marathon must continue to use Codex's native authorities. It does not shell +out to another executable, maintain a second turn counter, or duplicate a +recovery prompt. If an upstream symbol moves, update the native integration +and its tests at the same time. diff --git a/integration/fake-runtime/runtime.go b/integration/fake-runtime/runtime.go deleted file mode 100644 index d60e1922..00000000 --- a/integration/fake-runtime/runtime.go +++ /dev/null @@ -1,770 +0,0 @@ -// Package fakeruntime is a deterministic in-process implementation of the -// Marathon runtime boundary. It models only observable runtime behavior; it -// does not parse or retain credential bytes. -package fakeruntime - -import ( - "context" - "encoding/json" - "errors" - "sync" - "time" - - "codexmarathon/controller/internal/runtime" -) - -var ( - // ErrAckLost simulates a state-changing commit whose response was lost on - // the transport. The identity and generation are still updated first. - ErrAckLost = errors.New("fake runtime commit acknowledgement lost") - // ErrRuntimeDisconnected simulates a runtime that cannot service commands. - ErrRuntimeDisconnected = errors.New("fake runtime is disconnected") -) - -// Runtime is safe for concurrent controller calls and test assertions. -type Runtime struct { - mu sync.Mutex - - identity runtime.Identity - activeTurnCount int - pending *runtime.PendingTransition - completed map[string]runtime.TransitionResult - recoveries map[string]runtime.RecoveryState - - events chan runtime.Event - closed bool - - disconnected bool - prepareErr error - commitErr error - reloadErr error - reloadFailures int - dropNextAck bool - reloadCount int - - prepareCalls []runtime.AuthTransitionParams - commitCalls []runtime.AuthTransitionParams - cancelCalls []runtime.CancelAuthTransitionParams - recoveryReleaseCalls []runtime.RecoveryReleaseParams - dropNextRecoveryAck bool -} - -// New creates a fake runtime with a stable runtime ID and the supplied -// observed account/generation. A blank account means unauthenticated. -func New(accountID string, generation uint64) *Runtime { - var account *string - if accountID != "" { - copy := accountID - account = © - } - return &Runtime{ - identity: runtime.Identity{RuntimeID: "fake-runtime", AccountID: account, AuthGeneration: generation}, - events: make(chan runtime.Event, 64), - completed: make(map[string]runtime.TransitionResult), - recoveries: make(map[string]runtime.RecoveryState), - } -} - -// NewFakeRuntime is a descriptive alias for New. -func NewFakeRuntime(accountID string, generation uint64) *Runtime { return New(accountID, generation) } - -// SetRuntimeID changes the runtime identity used for subsequent responses. -// Tests should use this to model reconnect-to-a-different-process behavior. -func (f *Runtime) SetRuntimeID(runtimeID string) { - if f == nil { - return - } - f.mu.Lock() - f.identity.RuntimeID = runtimeID - f.mu.Unlock() -} - -// RuntimeID returns the current fake process ID. -func (f *Runtime) RuntimeID() string { - if f == nil { - return "" - } - f.mu.Lock() - defer f.mu.Unlock() - return f.identity.RuntimeID -} - -// Identity returns a defensive copy of the fake's identity. -func (f *Runtime) Identity() runtime.Identity { - if f == nil { - return runtime.Identity{} - } - f.mu.Lock() - defer f.mu.Unlock() - return cloneIdentity(f.identity) -} - -// SetIdentity overwrites the observed identity for a test fixture. It is -// intentionally explicit so a test can model a process restart or split-brain -// observation without touching credential material. -func (f *Runtime) SetIdentity(accountID string, generation uint64) { - if f == nil { - return - } - f.mu.Lock() - if accountID == "" { - f.identity.AccountID = nil - } else { - copy := accountID - f.identity.AccountID = © - } - f.identity.AuthGeneration = generation - f.mu.Unlock() -} - -// SetAccountID changes only the observed account while preserving generation. -func (f *Runtime) SetAccountID(accountID string) { - if f == nil { - return - } - f.mu.Lock() - if accountID == "" { - f.identity.AccountID = nil - } else { - copy := accountID - f.identity.AccountID = © - } - f.mu.Unlock() -} - -// SetAuthGeneration changes only the observed generation. -func (f *Runtime) SetAuthGeneration(generation uint64) { - if f == nil { - return - } - f.mu.Lock() - f.identity.AuthGeneration = generation - f.mu.Unlock() -} - -// ReloadCount reports how many times the fake actually adopted a new -// identity. Idempotent duplicate commit commands do not increase this count. -func (f *Runtime) ReloadCount() int { - if f == nil { - return 0 - } - f.mu.Lock() - defer f.mu.Unlock() - return f.reloadCount -} - -// Events exposes runtime notifications. The channel is closed by Close. -func (f *Runtime) Events() <-chan runtime.Event { - if f == nil { - return nil - } - return f.events -} - -// Close ends the notification stream. It is idempotent. -func (f *Runtime) Close() { - if f == nil { - return - } - f.mu.Lock() - if !f.closed { - f.closed = true - close(f.events) - } - f.mu.Unlock() -} - -// Disconnect toggles command failures without discarding runtime state. -func (f *Runtime) Disconnect() { - if f == nil { - return - } - f.mu.Lock() - f.disconnected = true - f.mu.Unlock() -} - -// Reconnect restores command servicing while preserving identity and pending -// transition state. -func (f *Runtime) Reconnect() { - if f == nil { - return - } - f.mu.Lock() - f.disconnected = false - f.mu.Unlock() -} - -// SetPrepareError injects an error returned before prepare mutates state. -func (f *Runtime) SetPrepareError(err error) { - if f == nil { - return - } - f.mu.Lock() - f.prepareErr = err - f.mu.Unlock() -} - -// SetCommitError injects an error returned by commit before applying it. -func (f *Runtime) SetCommitError(err error) { - if f == nil { - return - } - f.mu.Lock() - f.commitErr = err - f.mu.Unlock() -} - -// SetReloadFailure injects count reload failures. The error is one-shot per -// call, which lets reconciliation recover after a transient failure. -func (f *Runtime) SetReloadFailure(err error, count ...int) { - if f == nil { - return - } - f.mu.Lock() - if err == nil { - err = errors.New("fake auth reload failed") - } - f.reloadErr = err - f.reloadFailures = 1 - if len(count) > 0 && count[0] > 0 { - f.reloadFailures = count[0] - } - f.mu.Unlock() -} - -// DropNextCommitAck applies one commit and then returns ErrAckLost. It is the -// canonical lost-ACK injection used by integration tests. -func (f *Runtime) DropNextCommitAck() { - if f == nil { - return - } - f.mu.Lock() - f.dropNextAck = true - f.mu.Unlock() -} - -// SetDropCommitAck sets or clears one-shot lost-ACK injection. -func (f *Runtime) SetDropCommitAck(drop bool) { - if f == nil { - return - } - f.mu.Lock() - f.dropNextAck = drop - f.mu.Unlock() -} - -// SetActiveTurnCount sets the runtime's authoritative running-turn count. -// Counts below zero are normalized to zero. -func (f *Runtime) SetActiveTurnCount(count int) { - if f == nil { - return - } - if count < 0 { - count = 0 - } - f.mu.Lock() - f.activeTurnCount = count - identity := cloneIdentity(f.identity) - pending := clonePending(f.pending) - f.mu.Unlock() - if count == 0 && pending != nil { - f.emit(runtime.Event{EventBase: runtime.EventBase{ - EventType: runtime.EventSafeBoundaryReached, - OccurredAt: time.Now().Unix(), - RuntimeID: identity.RuntimeID, - AuthGeneration: identity.AuthGeneration, - TransitionID: pending.TransitionID, - }, Payload: mustJSON(runtime.SafeBoundaryReachedEvent{Reason: "running turn count reached zero"})}) - } -} - -// ActiveTurnCount returns the current running-turn count. -func (f *Runtime) ActiveTurnCount() int { - if f == nil { - return 0 - } - f.mu.Lock() - defer f.mu.Unlock() - return f.activeTurnCount -} - -// BeginTurn increments the authoritative running-turn count and emits a -// correlated turn_started notification. -func (f *Runtime) BeginTurn(turnID string) { - if f == nil { - return - } - f.mu.Lock() - f.activeTurnCount++ - identity := cloneIdentity(f.identity) - f.mu.Unlock() - f.emit(runtime.Event{EventBase: runtime.EventBase{ - EventType: runtime.EventTurnStarted, - OccurredAt: time.Now().Unix(), - RuntimeID: identity.RuntimeID, - AuthGeneration: identity.AuthGeneration, - }, Payload: mustJSON(runtime.TurnStartedEvent{TurnID: turnID})}) -} - -// FinishTurn decrements the running-turn count and emits exactly one safe -// boundary event when a prepared transition reaches zero. -func (f *Runtime) FinishTurn(turnID string) { - if f == nil { - return - } - f.mu.Lock() - if f.activeTurnCount > 0 { - f.activeTurnCount-- - } - identity := cloneIdentity(f.identity) - pending := clonePending(f.pending) - zero := f.activeTurnCount == 0 - f.mu.Unlock() - f.emit(runtime.Event{EventBase: runtime.EventBase{ - EventType: runtime.EventTurnCompleted, - OccurredAt: time.Now().Unix(), - RuntimeID: identity.RuntimeID, - AuthGeneration: identity.AuthGeneration, - }, Payload: mustJSON(runtime.TurnCompletedEvent{TurnID: turnID, Outcome: "completed"})}) - if zero && pending != nil { - f.emit(runtime.Event{EventBase: runtime.EventBase{ - EventType: runtime.EventSafeBoundaryReached, - OccurredAt: time.Now().Unix(), - RuntimeID: identity.RuntimeID, - AuthGeneration: identity.AuthGeneration, - TransitionID: pending.TransitionID, - }, Payload: mustJSON(runtime.SafeBoundaryReachedEvent{Reason: "running turn count reached zero"})}) - } -} - -// ReachSafeBoundary emits the runtime-authoritative safe-boundary event for -// the current pending transition without fabricating a turn completion. -func (f *Runtime) ReachSafeBoundary() { - if f == nil { - return - } - f.mu.Lock() - identity := cloneIdentity(f.identity) - pending := clonePending(f.pending) - f.activeTurnCount = 0 - f.mu.Unlock() - if pending == nil { - return - } - f.emit(runtime.Event{EventBase: runtime.EventBase{ - EventType: runtime.EventSafeBoundaryReached, - OccurredAt: time.Now().Unix(), - RuntimeID: identity.RuntimeID, - AuthGeneration: identity.AuthGeneration, - TransitionID: pending.TransitionID, - }, Payload: mustJSON(runtime.SafeBoundaryReachedEvent{Reason: "explicit fake boundary"})}) -} - -// PendingTransition returns a defensive pending intent, if any. -func (f *Runtime) PendingTransition() (runtime.PendingTransition, bool) { - if f == nil { - return runtime.PendingTransition{}, false - } - f.mu.Lock() - defer f.mu.Unlock() - if f.pending == nil { - return runtime.PendingTransition{}, false - } - return *f.pending, true -} - -// PrepareCalls returns a copy of all prepare requests, useful for asserting -// exactly-once transition correlation. -func (f *Runtime) PrepareCalls() []runtime.AuthTransitionParams { - if f == nil { - return nil - } - f.mu.Lock() - defer f.mu.Unlock() - return append([]runtime.AuthTransitionParams(nil), f.prepareCalls...) -} - -// CommitCalls returns a copy of all commit requests. -func (f *Runtime) CommitCalls() []runtime.AuthTransitionParams { - if f == nil { - return nil - } - f.mu.Lock() - defer f.mu.Unlock() - return append([]runtime.AuthTransitionParams(nil), f.commitCalls...) -} - -// CancelCalls returns a copy of all cancellation requests. -func (f *Runtime) CancelCalls() []runtime.CancelAuthTransitionParams { - if f == nil { - return nil - } - f.mu.Lock() - defer f.mu.Unlock() - return append([]runtime.CancelAuthTransitionParams(nil), f.cancelCalls...) -} - -// SetRecovery installs a runtime-owned parked recovery for an integration -// fixture. The fixture stores only correlation metadata, matching the real -// adapter contract; prompt and credential bytes are never represented. -func (f *Runtime) SetRecovery(recoveryID, threadID, turnID, sourceAccountID string) { - if f == nil || recoveryID == "" { - return - } - f.mu.Lock() - f.recoveries[recoveryID] = runtime.RecoveryState{ - RecoveryID: recoveryID, ThreadID: threadID, TurnID: turnID, - SourceAccountID: sourceAccountID, Phase: runtime.RecoveryParkedPhase, - } - f.mu.Unlock() -} - -// RecoveryReleaseCalls returns the controller-to-runtime release requests in -// order, allowing integration tests to assert exactly-once dispatch. -func (f *Runtime) RecoveryReleaseCalls() []runtime.RecoveryReleaseParams { - if f == nil { - return nil - } - f.mu.Lock() - defer f.mu.Unlock() - return append([]runtime.RecoveryReleaseParams(nil), f.recoveryReleaseCalls...) -} - -// DropNextRecoveryReleaseAck applies one release and drops its acknowledgement. -func (f *Runtime) DropNextRecoveryReleaseAck() { - if f == nil { - return - } - f.mu.Lock() - f.dropNextRecoveryAck = true - f.mu.Unlock() -} - -// GetRuntimeState implements transitions.Runtime. -func (f *Runtime) GetRuntimeState(ctx context.Context) (runtime.RuntimeState, error) { - if err := f.check(ctx); err != nil { - return runtime.RuntimeState{}, err - } - f.mu.Lock() - defer f.mu.Unlock() - return runtime.RuntimeState{ - Identity: cloneIdentity(f.identity), - ActiveTurnCount: f.activeTurnCount, - PendingTransition: clonePending(f.pending), - Recoveries: cloneRecoveries(f.recoveries), - }, nil -} - -// ReleaseRecovery implements recovery.Runtime. It authorizes one already -// parked runtime recovery and never constructs or submits a prompt. -func (f *Runtime) ReleaseRecovery(ctx context.Context, params runtime.RecoveryReleaseParams) (runtime.RecoveryReleaseResult, error) { - if err := f.check(ctx); err != nil { - return runtime.RecoveryReleaseResult{}, err - } - if err := params.Validate(); err != nil { - return runtime.RecoveryReleaseResult{}, err - } - f.mu.Lock() - f.recoveryReleaseCalls = append(f.recoveryReleaseCalls, params) - state, ok := f.recoveries[params.RecoveryID] - if !ok { - f.mu.Unlock() - code, message := "recovery_not_found", "recovery not found" - return runtime.RecoveryReleaseResult{RecoveryID: params.RecoveryID, ThreadID: params.ThreadID, Outcome: runtime.RecoveryReleaseRejected, ErrorCode: &code, ErrorMessage: &message}, nil - } - if params.ExpectedGeneration != f.identity.AuthGeneration { - f.mu.Unlock() - code, message := "stale_generation", "runtime identity generation does not match release" - return runtime.RecoveryReleaseResult{RecoveryID: params.RecoveryID, ThreadID: state.ThreadID, Outcome: runtime.RecoveryReleaseRejected, ErrorCode: &code, ErrorMessage: &message}, nil - } - if state.ThreadID != "" && params.ThreadID != "" && state.ThreadID != params.ThreadID { - f.mu.Unlock() - code, message := "thread_id_mismatch", "recovery belongs to another thread" - return runtime.RecoveryReleaseResult{RecoveryID: params.RecoveryID, ThreadID: state.ThreadID, Outcome: runtime.RecoveryReleaseRejected, ErrorCode: &code, ErrorMessage: &message}, nil - } - if state.Phase == runtime.RecoveryReleasedPhase || state.Phase == runtime.RecoveryStartedPhase || state.Phase == runtime.RecoveryCompletedPhase { - f.mu.Unlock() - return runtime.RecoveryReleaseResult{RecoveryID: params.RecoveryID, ThreadID: state.ThreadID, Outcome: runtime.RecoveryAlreadyReleased}, nil - } - state.Phase = runtime.RecoveryReleasedPhase - state.TransitionID = params.TransitionID - state.ExpectedGeneration = params.ExpectedGeneration - f.recoveries[params.RecoveryID] = state - dropAck := f.dropNextRecoveryAck - f.dropNextRecoveryAck = false - f.mu.Unlock() - if dropAck { - return runtime.RecoveryReleaseResult{}, ErrAckLost - } - return runtime.RecoveryReleaseResult{RecoveryID: params.RecoveryID, ThreadID: state.ThreadID, Outcome: runtime.RecoveryReleased}, nil -} - -// PrepareAuthTransition records intent without changing identity. -func (f *Runtime) PrepareAuthTransition(ctx context.Context, params runtime.AuthTransitionParams) (runtime.TransitionResult, error) { - if err := f.check(ctx); err != nil { - return runtime.TransitionResult{}, err - } - if err := params.Validate(); err != nil { - return runtime.TransitionResult{}, err - } - f.mu.Lock() - defer f.mu.Unlock() - f.prepareCalls = append(f.prepareCalls, params) - if f.prepareErr != nil { - err := f.prepareErr - f.prepareErr = nil - return runtime.TransitionResult{}, err - } - if params.ExpectedGeneration != f.identity.AuthGeneration+1 { - return f.rejectedLocked(params, "stale_generation", "expected generation is not newer than runtime"), nil - } - if f.pending != nil { - if f.pending.TransitionID == params.TransitionID && f.pending.TargetAccountID == params.TargetAccountID && f.pending.ExpectedGeneration == params.ExpectedGeneration { - return f.acceptedLocked(params), nil - } - return f.rejectedLocked(params, "transition_in_progress", "another transition is pending"), nil - } - pending := runtime.PendingTransition{TransitionID: params.TransitionID, TargetAccountID: params.TargetAccountID, ExpectedGeneration: params.ExpectedGeneration} - f.pending = &pending - return f.acceptedLocked(params), nil -} - -// CommitAuthTransition applies the prepared identity at the fake safe -// boundary. Calls with the same transition ID after a successful apply are -// idempotent and return the original committed result. -func (f *Runtime) CommitAuthTransition(ctx context.Context, params runtime.AuthTransitionParams) (runtime.TransitionResult, error) { - if err := f.check(ctx); err != nil { - return runtime.TransitionResult{}, err - } - if err := params.Validate(); err != nil { - return runtime.TransitionResult{}, err - } - f.mu.Lock() - f.commitCalls = append(f.commitCalls, params) - if result, ok := f.completed[params.TransitionID]; ok { - f.mu.Unlock() - return result, nil - } - if f.commitErr != nil { - err := f.commitErr - f.commitErr = nil - f.mu.Unlock() - return runtime.TransitionResult{}, err - } - if f.pending == nil || f.pending.TransitionID != params.TransitionID || f.pending.ExpectedGeneration != params.ExpectedGeneration || f.pending.TargetAccountID != params.TargetAccountID { - result := f.rejectedLocked(params, "stale_transition", "pending transition does not match commit") - f.mu.Unlock() - return result, nil - } - if params.ExpectedGeneration != f.identity.AuthGeneration+1 { - result := f.rejectedLocked(params, "stale_generation", "commit generation is stale") - f.mu.Unlock() - return result, nil - } - if f.activeTurnCount > 0 { - result := f.rejectedLocked(params, "active_turn", "commit requested before safe boundary") - f.mu.Unlock() - return result, nil - } - if f.reloadFailures > 0 { - f.reloadFailures-- - err := f.reloadErr - if err == nil { - err = errors.New("fake auth reload failed") - } - f.mu.Unlock() - return runtime.TransitionResult{}, err - } - previous := cloneIdentity(f.identity) - target := params.TargetAccountID - f.identity.AccountID = &target - f.identity.AuthGeneration = params.ExpectedGeneration - f.reloadCount++ - f.pending = nil - result := runtime.TransitionResult{ - TransitionID: params.TransitionID, - RuntimeID: f.identity.RuntimeID, - ExpectedGeneration: params.ExpectedGeneration, - AuthGeneration: f.identity.AuthGeneration, - Outcome: runtime.TransitionCommitted, - AccountID: cloneString(f.identity.AccountID), - } - f.completed[params.TransitionID] = result - identity := cloneIdentity(f.identity) - dropAck := f.dropNextAck - f.dropNextAck = false - f.mu.Unlock() - - f.emit(reloadStartedEvent(identity, params)) - f.emit(reloadSucceededEvent(identity, previous, params)) - f.emit(identityChangedEvent(identity, previous, params)) - if dropAck { - return runtime.TransitionResult{}, ErrAckLost - } - return result, nil -} - -// CancelAuthTransition removes a matching pending intent without touching -// identity or generation. -func (f *Runtime) CancelAuthTransition(ctx context.Context, params runtime.CancelAuthTransitionParams) (runtime.TransitionResult, error) { - if err := f.check(ctx); err != nil { - return runtime.TransitionResult{}, err - } - if err := params.Validate(); err != nil { - return runtime.TransitionResult{}, err - } - f.mu.Lock() - defer f.mu.Unlock() - f.cancelCalls = append(f.cancelCalls, params) - if f.pending == nil || f.pending.TransitionID != params.TransitionID || f.pending.ExpectedGeneration != params.ExpectedGeneration { - return f.rejectedLocked(runtime.AuthTransitionParams{TransitionID: params.TransitionID, TargetAccountID: "unknown", ExpectedGeneration: params.ExpectedGeneration}, "stale_transition", "pending transition does not match cancellation"), nil - } - f.pending = nil - return runtime.TransitionResult{ - TransitionID: params.TransitionID, - RuntimeID: f.identity.RuntimeID, - ExpectedGeneration: params.ExpectedGeneration, - AuthGeneration: f.identity.AuthGeneration, - Outcome: runtime.TransitionCommitted, - AccountID: cloneString(f.identity.AccountID), - }, nil -} - -// GetIdentity implements transitions.Runtime. -func (f *Runtime) GetIdentity(ctx context.Context) (runtime.Identity, error) { - if err := f.check(ctx); err != nil { - return runtime.Identity{}, err - } - f.mu.Lock() - defer f.mu.Unlock() - return cloneIdentity(f.identity), nil -} - -// GetAuthGeneration implements transitions.Runtime. -func (f *Runtime) GetAuthGeneration(ctx context.Context) (runtime.AuthGenerationResult, error) { - if err := f.check(ctx); err != nil { - return runtime.AuthGenerationResult{}, err - } - f.mu.Lock() - defer f.mu.Unlock() - return runtime.AuthGenerationResult{AuthGeneration: f.identity.AuthGeneration}, nil -} - -// Emit publishes an arbitrary event for tests that need to model stale or -// duplicated notifications. -func (f *Runtime) Emit(event runtime.Event) { f.emit(event) } - -func (f *Runtime) emit(event runtime.Event) { - if f == nil { - return - } - f.mu.Lock() - closed := f.closed - channel := f.events - f.mu.Unlock() - if closed { - return - } - select { - case channel <- event: - default: - // Tests should never deadlock because a notification consumer is slow; - // retain deterministic state and drop only excess diagnostics. - } -} - -func (f *Runtime) check(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - if f == nil { - return errors.New("nil fake runtime") - } - f.mu.Lock() - disconnected := f.disconnected - f.mu.Unlock() - if disconnected { - return ErrRuntimeDisconnected - } - return nil -} - -func (f *Runtime) acceptedLocked(params runtime.AuthTransitionParams) runtime.TransitionResult { - return runtime.TransitionResult{ - TransitionID: params.TransitionID, - RuntimeID: f.identity.RuntimeID, - ExpectedGeneration: params.ExpectedGeneration, - AuthGeneration: f.identity.AuthGeneration, - Outcome: runtime.TransitionCommitted, - AccountID: cloneString(f.identity.AccountID), - } -} - -func (f *Runtime) rejectedLocked(params runtime.AuthTransitionParams, code, message string) runtime.TransitionResult { - codeCopy, messageCopy := code, message - return runtime.TransitionResult{ - TransitionID: params.TransitionID, - RuntimeID: f.identity.RuntimeID, - ExpectedGeneration: params.ExpectedGeneration, - AuthGeneration: f.identity.AuthGeneration, - Outcome: runtime.TransitionRejected, - AccountID: cloneString(f.identity.AccountID), - ErrorCode: &codeCopy, - ErrorMessage: &messageCopy, - } -} - -func cloneIdentity(identity runtime.Identity) runtime.Identity { - identity.AccountID = cloneString(identity.AccountID) - return identity -} - -func cloneString(value *string) *string { - if value == nil { - return nil - } - copy := *value - return © -} - -func clonePending(value *runtime.PendingTransition) *runtime.PendingTransition { - if value == nil { - return nil - } - copy := *value - return © -} - -func cloneRecoveries(values map[string]runtime.RecoveryState) []runtime.RecoveryState { - if len(values) == 0 { - return nil - } - result := make([]runtime.RecoveryState, 0, len(values)) - for _, value := range values { - result = append(result, value) - } - return result -} - -func mustJSON(value any) []byte { - payload, _ := json.Marshal(value) - return payload -} - -func reloadStartedEvent(identity runtime.Identity, params runtime.AuthTransitionParams) runtime.Event { - return runtime.Event{EventBase: runtime.EventBase{EventType: runtime.EventAuthReloadStarted, OccurredAt: time.Now().Unix(), RuntimeID: identity.RuntimeID, AuthGeneration: identity.AuthGeneration, TransitionID: params.TransitionID}, Payload: mustJSON(runtime.AuthReloadStartedEvent{TargetAccountID: params.TargetAccountID})} -} - -func reloadSucceededEvent(identity, previous runtime.Identity, params runtime.AuthTransitionParams) runtime.Event { - return runtime.Event{EventBase: runtime.EventBase{EventType: runtime.EventAuthReloadSucceeded, OccurredAt: time.Now().Unix(), RuntimeID: identity.RuntimeID, AuthGeneration: identity.AuthGeneration, TransitionID: params.TransitionID}, Payload: mustJSON(runtime.AuthReloadSucceededEvent{AccountID: cloneString(identity.AccountID), PreviousAccountID: cloneString(previous.AccountID), Changed: true})} -} - -func identityChangedEvent(identity, previous runtime.Identity, params runtime.AuthTransitionParams) runtime.Event { - return runtime.Event{EventBase: runtime.EventBase{EventType: runtime.EventIdentityChanged, OccurredAt: time.Now().Unix(), RuntimeID: identity.RuntimeID, AuthGeneration: identity.AuthGeneration, TransitionID: params.TransitionID}, Payload: mustJSON(runtime.IdentityChangedEvent{AccountID: cloneString(identity.AccountID), PreviousAccountID: cloneString(previous.AccountID)})} -} diff --git a/integration/go.mod b/integration/go.mod deleted file mode 100644 index 6b05dfa3..00000000 --- a/integration/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module codexmarathon/controller/integration - -go 1.22 - -require codexmarathon/controller v0.0.0 - -replace codexmarathon/controller => ../controller diff --git a/integration/tests/transitions_test.go b/integration/tests/transitions_test.go deleted file mode 100644 index d70090d5..00000000 --- a/integration/tests/transitions_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package integration_test - -import ( - "context" - "errors" - "sync" - "testing" - "time" - - fakeruntime "codexmarathon/controller/integration/fake-runtime" - "codexmarathon/controller/internal/credentials" - "codexmarathon/controller/internal/runtime" - "codexmarathon/controller/internal/transitions" -) - -type memoryDeployer struct { - mu sync.Mutex - disk string - deploys []string - err error -} - -func (d *memoryDeployer) Deploy(accountID string) (credentials.DeploymentResult, error) { - d.mu.Lock() - defer d.mu.Unlock() - if d.err != nil { - return credentials.DeploymentResult{}, d.err - } - d.disk = accountID - d.deploys = append(d.deploys, accountID) - return credentials.DeploymentResult{AccountID: accountID, Path: "memory-auth.json", DeployedAt: time.Now().UTC()}, nil -} - -func (d *memoryDeployer) Disk() string { - d.mu.Lock() - defer d.mu.Unlock() - return d.disk -} - -func (d *memoryDeployer) DeployCount() int { - d.mu.Lock() - defer d.mu.Unlock() - return len(d.deploys) -} - -func TestSafeTransitionDefersUntilTurnBoundary(t *testing.T) { - rt := fakeruntime.New("account-a", 1) - rt.SetActiveTurnCount(1) - deployer := &memoryDeployer{disk: "account-a"} - coordinator := transitions.NewCoordinator(transitions.Config{ - Runtime: rt, - Deployer: deployer, - Disk: transitions.DiskIdentityFunc(func() (string, error) { return deployer.Disk(), nil }), - BoundaryPollInterval: 5 * time.Millisecond, - }) - - resultCh := make(chan struct { - result *transitions.TransitionResult - err error - }, 1) - go func() { - result, err := coordinator.RequestTransition(context.Background(), "account-b") - resultCh <- struct { - result *transitions.TransitionResult - err error - }{result, err} - }() - - deadline := time.After(time.Second) - for len(rt.PrepareCalls()) == 0 { - select { - case <-deadline: - t.Fatal("transition was not prepared") - default: - time.Sleep(time.Millisecond) - } - } - if deployer.DeployCount() != 0 { - t.Fatal("credentials deployed during active turn") - } - rt.FinishTurn("turn-1") - - select { - case result := <-resultCh: - if result.err != nil { - t.Fatalf("RequestTransition() error = %v", result.err) - } - if result.result == nil || result.result.Outcome != transitions.TransitionCommitted { - t.Fatalf("unexpected transition result: %#v", result.result) - } - if result.result.AdoptedAccountID != "account-b" || result.result.FinalGeneration != 2 { - t.Fatalf("unexpected adopted identity: %#v", result.result) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for safe transition") - } - if rt.ActiveTurnCount() != 0 || rt.Identity().AuthGeneration != 2 { - t.Fatalf("runtime did not finish transition: %+v", rt.Identity()) - } - if deployer.Disk() != "account-b" { - t.Fatalf("disk identity = %q, want account-b", deployer.Disk()) - } - prepareCalls, commitCalls := rt.PrepareCalls(), rt.CommitCalls() - if len(prepareCalls) != 1 || len(commitCalls) != 1 { - t.Fatalf("command counts prepare=%d commit=%d", len(prepareCalls), len(commitCalls)) - } - if prepareCalls[0].TransitionID != commitCalls[0].TransitionID || prepareCalls[0].ExpectedGeneration != commitCalls[0].ExpectedGeneration { - t.Fatal("prepare and commit did not preserve one transition correlation") - } -} - -func TestLostCommitAckReconcilesCommittedState(t *testing.T) { - rt := fakeruntime.New("account-a", 7) - deployer := &memoryDeployer{disk: "account-a"} - coordinator := transitions.NewCoordinator(transitions.Config{ - Runtime: rt, - Deployer: deployer, - Disk: transitions.DiskIdentityFunc(func() (string, error) { return deployer.Disk(), nil }), - }) - rt.DropNextCommitAck() - result, err := coordinator.RequestTransition(context.Background(), "account-b") - if result == nil || result.Outcome != transitions.TransitionUncertain { - t.Fatalf("lost ACK should be uncertain, result=%#v err=%v", result, err) - } - if !errors.Is(err, fakeruntime.ErrAckLost) { - t.Fatalf("lost ACK error = %v, want ErrAckLost", err) - } - if got := rt.Identity(); got.AuthGeneration != 8 || got.AccountID == nil || *got.AccountID != "account-b" { - t.Fatalf("runtime state was not applied before lost ACK: %+v", got) - } - - reconciled, err := coordinator.Reconcile(context.Background(), result.TransitionID) - if err != nil { - t.Fatalf("Reconcile() error = %v", err) - } - if reconciled == nil || reconciled.Outcome != transitions.TransitionCommitted || reconciled.FinalGeneration != 8 { - t.Fatalf("unexpected reconciled result: %#v", reconciled) - } - if len(rt.CommitCalls()) != 1 { - t.Fatalf("reconcile reissued an unnecessary commit: %d calls", len(rt.CommitCalls())) - } - if _, pending := coordinator.Pending(); pending { - t.Fatal("resolved transition still blocks new requests") - } -} - -func TestStaleGenerationRejectedWithoutApplyingAuth(t *testing.T) { - rt := fakeruntime.New("account-a", 43) - result, err := rt.CommitAuthTransition(context.Background(), runtime.AuthTransitionParams{ - TransitionID: "stale", - TargetAccountID: "account-b", - ExpectedGeneration: 42, - }) - if err != nil { - t.Fatalf("stale commit transport error = %v", err) - } - if result.Outcome != runtime.TransitionRejected || result.ErrorCode == nil || *result.ErrorCode != "stale_transition" { - t.Fatalf("unexpected stale response: %#v", result) - } - identity := rt.Identity() - if identity.AuthGeneration != 43 || identity.AccountID == nil || *identity.AccountID != "account-a" { - t.Fatalf("stale command changed runtime identity: %+v", identity) - } -} - -func TestIdentityComparisonDistinguishesSplitBrainAndReload(t *testing.T) { - base := transitions.IdentityObservation{ - DesiredAccountID: "account-b", - PreviousAccountID: "account-a", - ExpectedRuntimeID: "runtime-1", - RuntimeID: "runtime-1", - ControllerGeneration: 1, - ExpectedGeneration: 2, - } - committed := base - committed.DiskAccountID = "account-b" - committed.RuntimeAccountID = "account-b" - committed.RuntimeGeneration = 2 - if got := transitions.CompareIdentity(committed); got != transitions.ReconcileCommitted { - t.Fatalf("matching state classified as %q", got) - } - reload := base - reload.DiskAccountID = "account-b" - reload.RuntimeAccountID = "account-a" - reload.RuntimeGeneration = 1 - if got := transitions.CompareIdentity(reload); got != transitions.ReconcileRuntimeReload { - t.Fatalf("disk-target/runtime-source classified as %q", got) - } - split := base - split.DiskAccountID = "account-a" - split.RuntimeAccountID = "account-b" - split.RuntimeGeneration = 2 - if got := transitions.CompareIdentity(split); got != transitions.ReconcileSplitBrain { - t.Fatalf("split-brain state classified as %q", got) - } -} diff --git a/machine-setting.md b/machine-setting.md index 76e2764c..7b0716a3 100644 --- a/machine-setting.md +++ b/machine-setting.md @@ -1,123 +1,62 @@ -# Ubuntu development-machine setup +# CodexMarathon development machine -The normal CodexMarathon release is a Go companion for a separately installed -Codex CLI. Go, Python, Git, and the existing Codex executable are enough for -companion development and live handoff tests. Cargo is needed only when -working on the optional local-control adapter or building the explicitly -opt-in embedded-runtime package. +The project is a Rust workspace containing a custom Codex CLI. Install the +native build dependencies, Rust, Python for provenance checks, and Git. No Go +toolchain or separate Marathon executable is required. -## 1. Install operating-system packages +## System packages + +On Ubuntu: ```bash sudo apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ - build-essential ca-certificates curl file git jq pkg-config unzip xz-utils + build-essential ca-certificates curl file git jq pkg-config unzip xz-utils \ + libssl-dev libsqlite3-dev ``` -The imported optional runtime may additionally need `clang`, `cmake`, -`libclang-dev`, `libssl-dev`, `libsqlite3-dev`, `protobuf-compiler`, -`libcap-dev`, `libseccomp-dev`, and `zlib1g-dev`. - -## 2. Install Go - -Use Go 1.22 or newer. The following installs the version used by the release -checks and maps the host architecture correctly: - -```bash -GO_VERSION=1.25.4 -case "$(uname -m)" in - x86_64) GO_ARCH=amd64 ;; - aarch64|arm64) GO_ARCH=arm64 ;; - *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; -esac - -curl -fLO "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -sudo mkdir -p "/opt/go${GO_VERSION}" -sudo tar -C "/opt/go${GO_VERSION}" --strip-components=1 \ - -xzf "go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -sudo ln -sfn "/opt/go${GO_VERSION}/bin/go" /usr/local/bin/go -sudo ln -sfn "/opt/go${GO_VERSION}/bin/gofmt" /usr/local/bin/gofmt -rm "go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -go version -``` +The full Codex workspace may additionally need `clang`, `cmake`, +`libclang-dev`, `protobuf-compiler`, `libcap-dev`, `libseccomp-dev`, and +`zlib1g-dev` for platform-specific crates. -## 3. Install and verify Codex +## Rust toolchain -Install Codex through its normal distribution and ensure the executable is -available: +Use the toolchain pinned or selected by `runtime/codex-rs` and install the +formatter: ```bash -command -v codex -codex --version +rustup toolchain install stable +rustup component add rustfmt clippy ``` -If it is installed elsewhere, retain the absolute path and pass it to the -companion's `--codex` option for acceptance tests. Use a disposable -`CODEX_HOME` and test account when exercising authentication or process -handoff. - -## 4. Run the companion checks +## Build and focused checks From the repository root: ```bash -(cd controller && go mod download && go test ./... && go vet ./...) -(cd integration && go mod download && go test ./...) -python3 scripts/test_package_release.py python3 scripts/verify_provenance.py --root . -python3 scripts/verify_package.py --root . -python3 scripts/verify_protocol.py --root . -``` - -Build the normal Linux companion package: - -```bash -bash scripts/build-release.sh +cd runtime/codex-rs +cargo fmt --all -- --check +cargo check -p codexmarathon-runtime +cargo check -p codex-login +cargo check -p codex-app-server +cargo check -p codex-tui +cargo check -p codex-cli +cargo test --locked -p codexmarathon-runtime +cargo build --locked --release -p codex-cli ``` -On an ARM64 machine this produces `linux-aarch64`; on amd64 it produces -`linux-x86_64`. The build compiles only the Go companion and does not invoke -Cargo. - -## 5. Optional Rust adapter/runtime work +The executable is `runtime/codex-rs/target/release/codex`. Use an isolated +`CODEX_HOME` for login and transition checks. On a headless server, use +`codex marathon login ALIAS --device-code` and complete the displayed code on +another machine. -Install the pinned Rust toolchain only if you need the local-control adapter -or optional self-contained package: +## Formatting ```bash -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --profile minimal --default-toolchain 1.95.0 -source "$HOME/.cargo/env" -rustup component add --toolchain 1.95.0 rustfmt clippy rust-src - -(cd runtime/codexmarathon-adapter && cargo test) -(cd runtime/codex-rs && cargo test --locked -p codexmarathon-runtime) -CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME=1 bash scripts/build-release.sh -``` - -The optional build can consume substantial temporary disk space because Cargo -retains intermediate objects. The resulting companion archive remains small; -the large Rust target tree is a build cache and is not included by default. - -## 6. Formatting and repository verifier - -```bash -find controller -name '*.go' -type f -print0 | xargs -0 gofmt -w -find integration -name '*.go' -type f -print0 | xargs -0 gofmt -w +cd runtime/codex-rs +cargo fmt --all ``` -On a machine with PowerShell 7, run: - -```bash -pwsh -NoProfile -File ./verify.ps1 -``` - -The verifier reports missing optional toolchains as `BLOCKED`; it does not -turn an unavailable Rust or live Codex check into a pass. - -## Recommended capacity - -- 2 CPU cores and 2 GB RAM are sufficient for the normal Go companion build. -- 4+ cores and 8 GB RAM are recommended for optional Rust compilation. -- Keep at least 5 GB free for normal tests and packaging. -- Keep at least 30 GB free only when compiling the full optional Codex runtime. +Keep Cargo target output, local credentials, and `.cache`/`.toolchains` +outside commits. diff --git a/packaging/README.md b/packaging/README.md deleted file mode 100644 index b4fa3784..00000000 --- a/packaging/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# CodexMarathon release packaging - -The default release is a small companion package. It contains the Go -`codexmarathon` executable and the protocol, documentation, provenance, and -license metadata needed by the companion. It does not contain Codex itself. -Users install Codex separately, and the companion discovers the existing -`codex` executable on `PATH` (or accepts an explicit executable path) when it -starts or resumes a session. - -Build the normal Linux package with: - -```text -bash scripts/build-release.sh -``` - -The script builds only the Go controller and selects `linux-x86_64` or -`linux-aarch64` from the host architecture. Override the label with -`CODEXMARATHON_PLATFORM` when cross-building. The direct packaging command -is: - -```text -python scripts/package_release.py --root . --output dist/release \ - --controller-binary dist/build/codexmarathon \ - --platform linux-aarch64 --version 0.1.0 --format tar.gz -``` - -The archive contains one required executable, `codexmarathon`, and never -contains `auth.json`, account state, credential snapshots, journals, donor -checkouts, Cargo targets, or developer caches. The generated -`release-manifest.json` records the distribution as `companion` and hashes -every archive member. - -An embedded Codex app-server is retained only as an explicit diagnostic or -offline package variant. It is intentionally excluded from the default -release and requires both a runtime binary and the opt-in flag: - -```text -CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME=1 bash scripts/build-release.sh - -python scripts/package_release.py --root . --output dist/release \ - --controller-binary dist/build/codexmarathon \ - --include-embedded-runtime \ - --runtime-binary runtime/codex-rs/target/release/codex-app-server \ - --platform linux-aarch64 --version 0.1.0 --format tar.gz -``` - -That variant is marked `companion-with-embedded-runtime` in its generated -manifest so it cannot be mistaken for the normal installed-Codex package. -Use `scripts/live_smoke.py` only for this opt-in variant; it tests the -optional runtime boundary and is not part of companion installation. - -Verify either archive independently: - -```text -python scripts/verify_package.py --artifact dist/release/ \ - --manifest packaging/release-manifest.json -python scripts/clean_machine_check.py --artifact dist/release/ -``` - -Packaging is archive assembly, not a signed installer. Signing, notarization, -and platform-specific installer work remain separate release tasks. diff --git a/packaging/release-manifest.json b/packaging/release-manifest.json deleted file mode 100644 index 1ee16fa0..00000000 --- a/packaging/release-manifest.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "schema_version": 1, - "product": "codexmarathon", - "artifact_root": "codexmarathon-{version}-{platform}", - "distribution": "companion", - "entrypoints": { - "controller": "codexmarathon" - }, - "optional_entrypoints": { - "runtime": "codexmarathon-runtime" - }, - "include": [ - "README.md", - "docs/features-and-usage.md", - "docs/release.md", - "docs/companion-cli.md", - "protocol/VERSION", - "protocol/protocol.json", - "protocol/commands.json", - "protocol/events.json", - "protocol/README.md", - "runtime/PROVENANCE.md", - "runtime/PATCH_LEDGER.md", - "runtime/codex-rs/LICENSE", - "runtime/codex-rs/NOTICE" - ], - "forbidden_path_segments": [ - ".git", - ".codexmarathon", - ".codexmarathon-dev", - ".state", - ".venv", - "__pycache__", - "donor", - "node_modules", - "target" - ], - "forbidden_file_names": [ - ".env", - ".env.local", - "auth.json", - "accounts.json", - "events.jsonl", - "diagnostics.json" - ], - "provenance": { - "codext": { - "repository": "https://github.com/Loongphy/codext", - "commit": "10c0989f282050b8617103d904788d994de8c971", - "license": "Apache-2.0", - "license_files": [ - "runtime/codex-rs/LICENSE", - "runtime/codex-rs/NOTICE" - ], - "distributed_source": true - }, - "codex_switch": { - "repository": "https://github.com/humeo/codex-switch", - "commit": "ca5c7dd454780272ed429f6fb1dceebda44397ae", - "license": "not established in pinned checkout", - "distributed_source": false, - "clearance": "required before distributing copied codex-switch source" - } - } -} diff --git a/protocol/README.md b/protocol/README.md deleted file mode 100644 index caf0e219..00000000 --- a/protocol/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Marathon protocol v1 - -`VERSION` is the compatibility integer for this contract. `protocol.json` -defines the JSON-RPC 2.0 envelopes. `commands.json` defines the controller to -runtime request methods and their typed params/results. `events.json` defines -the flat event params sent in a `codexmarathon/event` notification. - -Streams use newline-delimited JSON. A peer must negotiate a common version via -`protocol/negotiate` before normal commands are sent. Runtime events always -include `event_type`, `occurred_at`, `runtime_id`, and `auth_generation`. -Transition events additionally include `transition_id`. - -The `account/login`, `account/authSnapshot/read`, and `account/refresh` methods -are native-auth seams for a compatible installed Codex runtime. Login and -snapshot read return an opaque `auth_json` value only over the authenticated -user-scoped local control connection so the companion can place it in its -protected vault. Refresh accepts the same opaque snapshot and returns only -write-back token fields. None of these payloads belongs in the controller -journal, registry, logs, or CLI output. The imported Rust runtime implements -the same seam for optional protocol development; it is not required by the -default companion package. - -## TUI to controller control socket - -When `codexmarathon run` owns the Codex session it also exposes a separate -user-scoped local socket for TUI commands. The default Unix endpoint is -`unix:///controller.sock`; a launched Codex process receives the -same value in `CODEXMARATHON_CONTROL`. This endpoint is distinct from the -runtime socket because it can initiate a credential transition. - -The client first sends the same `protocol/negotiate` request with -`supported_versions: [1]`. The controller then accepts these secret-free -requests: - -* `marathon/status` with `{}` returns the registered account aliases, active - marker, runtime identity/turn count when a Marathon runtime is attached, - and any controller transition metadata. -* `marathon/switch` with - `{"target":"","timeout_ms":120000}` resolves the - alias in the controller and runs the complete controller-owned transition. - The request path is `prepare -> wait for the runtime's safe boundary -> - atomically deploy the selected snapshot -> commit/reload -> verify identity`; - the TUI never edits `auth.json`. - -The switch result contains only account ID/alias, outcome, mode, generation, -transition ID, and a redacted reason. A `committed` result is safe to display -as the new active identity. An `uncertain` result carries the transition ID -for controller reconciliation and must not be treated as a successful switch. - -Codext owns the `UsageLimitExceeded` synthetic recovery turn. It emits -`recovery_parked` with a recovery/thread correlation, and the controller may -send `recovery/release` only after a committed identity-changing transition. -The release is idempotent by `recovery_id`; `already_released` is success, and -`uncertain` remains a durable retry intent. The controller never sends the -resume prompt or drains Codext's user-input queue. - -The nested rate-limit shape intentionally preserves Codext app-server's -camelCase members (`rateLimits`, `limitId`, `usedPercent`, and so on), while -Marathon correlation and controller state fields use snake_case. Unknown -upstream fields are forward-compatible and must not be used by policy until -they have a typed meaning. diff --git a/protocol/VERSION b/protocol/VERSION deleted file mode 100644 index d00491fd..00000000 --- a/protocol/VERSION +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/protocol/commands.json b/protocol/commands.json deleted file mode 100644 index bfa5b266..00000000 --- a/protocol/commands.json +++ /dev/null @@ -1,411 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://codexmarathon.local/protocol/v1/commands.json", - "title": "CodexMarathon protocol v1 controller-to-runtime commands", - "description": "JSON-RPC requests and typed result payloads for the controller/runtime boundary. Method names are intentionally namespaced and stable on the wire; the display names match the architecture document.", - "oneOf": [ - { "$ref": "#/$defs/NegotiateVersionRequest" }, - { "$ref": "#/$defs/GetRuntimeStateRequest" }, - { "$ref": "#/$defs/PrepareAuthTransitionRequest" }, - { "$ref": "#/$defs/CommitAuthTransitionRequest" }, - { "$ref": "#/$defs/CancelAuthTransitionRequest" }, - { "$ref": "#/$defs/GetIdentityRequest" }, - { "$ref": "#/$defs/GetAuthGenerationRequest" }, - { "$ref": "#/$defs/NativeLoginRequest" }, - { "$ref": "#/$defs/NativeAuthSnapshotRequest" }, - { "$ref": "#/$defs/NativeRefreshRequest" }, - { "$ref": "#/$defs/ReleaseRecoveryRequest" } - ], - "$defs": { - "request_id": { - "oneOf": [ - { "type": "string", "minLength": 1 }, - { "type": "integer" } - ] - }, - "request_base": { - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "type": "string", "minLength": 1 }, - "params": { "type": "object" } - }, - "additionalProperties": false - }, - "NegotiateVersionRequest": { - "title": "NegotiateVersion", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "protocol/negotiate" }, - "params": { "$ref": "#/$defs/NegotiateVersionParams" } - }, - "additionalProperties": false - }, - "NegotiateVersionParams": { - "type": "object", - "required": ["supported_versions"], - "properties": { - "supported_versions": { - "type": "array", - "items": { "type": "integer", "minimum": 1 }, - "minItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false - }, - "NegotiateVersionResult": { - "type": "object", - "required": ["protocol_version", "server_versions"], - "properties": { - "protocol_version": { "type": "integer", "minimum": 1 }, - "server_versions": { - "type": "array", - "items": { "type": "integer", "minimum": 1 }, - "minItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false - }, - "GetRuntimeStateRequest": { - "title": "GetRuntimeState", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "runtime/state/read" }, - "params": { "const": {} } - }, - "additionalProperties": false - }, - "RuntimeIdentity": { - "type": "object", - "required": ["runtime_id", "account_id", "auth_generation"], - "properties": { - "runtime_id": { "type": "string", "minLength": 1 }, - "account_id": { "type": ["string", "null"] }, - "auth_generation": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": true - }, - "PendingTransition": { - "type": "object", - "required": ["transition_id", "target_account_id", "expected_generation"], - "properties": { - "transition_id": { "type": "string", "minLength": 1 }, - "target_account_id": { "type": "string", "minLength": 1 }, - "expected_generation": { "type": "integer", "minimum": 1 } - }, - "additionalProperties": false - }, - "RecoveryState": { - "type": "object", - "required": ["recovery_id", "phase"], - "properties": { - "recovery_id": { "type": "string", "minLength": 1 }, - "thread_id": { "type": "string", "minLength": 1 }, - "turn_id": { "type": "string", "minLength": 1 }, - "source_account_id": { "type": "string", "minLength": 1 }, - "transition_id": { "type": "string", "minLength": 1 }, - "target_account_id": { "type": "string", "minLength": 1 }, - "expected_generation": { "type": "integer", "minimum": 1 }, - "phase": { "type": "string", "enum": ["parked", "released", "started", "completed"] } - }, - "additionalProperties": false - }, - "GetRuntimeStateResult": { - "type": "object", - "required": ["identity", "active_turn_count", "pending_transition"], - "properties": { - "identity": { "$ref": "#/$defs/RuntimeIdentity" }, - "active_turn_count": { "type": "integer", "minimum": 0 }, - "pending_transition": { - "anyOf": [ - { "$ref": "#/$defs/PendingTransition" }, - { "type": "null" } - ] - }, - "recoveries": { - "type": "array", - "items": { "$ref": "#/$defs/RecoveryState" } - } - }, - "additionalProperties": true - }, - "PrepareAuthTransitionRequest": { - "title": "PrepareAuthTransition", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "auth/transition/prepare" }, - "params": { "$ref": "#/$defs/AuthTransitionParams" } - }, - "additionalProperties": false - }, - "CommitAuthTransitionRequest": { - "title": "CommitAuthTransition", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "auth/transition/commit" }, - "params": { "$ref": "#/$defs/AuthTransitionParams" } - }, - "additionalProperties": false - }, - "CancelAuthTransitionRequest": { - "title": "CancelAuthTransition", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "auth/transition/cancel" }, - "params": { "$ref": "#/$defs/CancelAuthTransitionParams" } - }, - "additionalProperties": false - }, - "AuthTransitionParams": { - "type": "object", - "required": ["transition_id", "target_account_id", "expected_generation"], - "properties": { - "transition_id": { "type": "string", "minLength": 1 }, - "target_account_id": { "type": "string", "minLength": 1 }, - "expected_generation": { "type": "integer", "minimum": 1 } - }, - "additionalProperties": false - }, - "CancelAuthTransitionParams": { - "type": "object", - "required": ["transition_id", "expected_generation"], - "properties": { - "transition_id": { "type": "string", "minLength": 1 }, - "expected_generation": { "type": "integer", "minimum": 1 }, - "reason": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false - }, - "TransitionOutcome": { - "type": "string", - "enum": ["committed", "rejected", "uncertain"] - }, - "TransitionResult": { - "type": "object", - "required": ["transition_id", "runtime_id", "auth_generation", "outcome", "account_id"], - "properties": { - "transition_id": { "type": "string", "minLength": 1 }, - "runtime_id": { "type": "string", "minLength": 1 }, - "auth_generation": { "type": "integer", "minimum": 0 }, - "outcome": { "$ref": "#/$defs/TransitionOutcome" }, - "account_id": { "type": ["string", "null"] }, - "error_code": { "type": ["string", "null"] }, - "error_message": { "type": ["string", "null"] } - }, - "additionalProperties": true - }, - "GetIdentityRequest": { - "title": "GetIdentity", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "runtime/identity/read" }, - "params": { "const": {} } - }, - "additionalProperties": false - }, - "GetAuthGenerationRequest": { - "title": "GetAuthGeneration", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "runtime/authGeneration/read" }, - "params": { "const": {} } - }, - "additionalProperties": false - }, - "AuthGenerationResult": { - "type": "object", - "required": ["auth_generation"], - "properties": { - "auth_generation": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": false - }, - "NativeLoginRequest": { - "title": "NativeLogin", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "account/login" }, - "params": { "$ref": "#/$defs/NativeLoginParams" } - }, - "additionalProperties": false - }, - "NativeLoginParams": { - "type": "object", - "properties": { - "account_id": { "type": "string", "minLength": 1 }, - "alias": { "type": "string" }, - "overwrite": { "type": "boolean" } - }, - "additionalProperties": false - }, - "NativeLoginResult": { - "type": "object", - "required": ["account_id", "auth_json"], - "properties": { - "account_id": { "type": "string", "minLength": 1 }, - "alias": { "type": "string" }, - "auth_json": { "type": "object" }, - "metadata": { - "type": "object", - "additionalProperties": { "type": "string" } - } - }, - "additionalProperties": false - }, - "NativeAuthSnapshotRequest": { - "title": "NativeAuthSnapshot", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "account/authSnapshot/read" }, - "params": { "type": "object", "maxProperties": 0 } - }, - "additionalProperties": false - }, - "NativeAuthSnapshotResult": { - "type": "object", - "required": ["account_id", "auth_json"], - "properties": { - "account_id": { "type": "string", "minLength": 1 }, - "auth_json": { "type": "object" } - }, - "additionalProperties": false - }, - "NativeRefreshRequest": { - "title": "NativeRefresh", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "account/refresh" }, - "params": { "$ref": "#/$defs/NativeRefreshParams" } - }, - "additionalProperties": false - }, - "NativeRefreshParams": { - "type": "object", - "required": ["account_id", "auth_json"], - "properties": { - "account_id": { "type": "string", "minLength": 1 }, - "auth_json": { "type": "object" } - }, - "additionalProperties": false - }, - "NativeRefreshResult": { - "type": "object", - "properties": { - "access_token": { "type": "string" }, - "id_token": { "type": "string" }, - "refresh_token": { "type": "string" }, - "account_id": { "type": "string" } - }, - "minProperties": 1, - "additionalProperties": false - }, - "ReleaseRecoveryRequest": { - "title": "ReleaseRecovery", - "type": "object", - "required": ["jsonrpc", "id", "method", "params"], - "properties": { - "jsonrpc": { "const": "2.0" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "const": "recovery/release" }, - "params": { "$ref": "#/$defs/RecoveryReleaseParams" } - }, - "additionalProperties": false - }, - "RecoveryReleaseParams": { - "type": "object", - "required": ["recovery_id", "transition_id", "expected_generation"], - "properties": { - "recovery_id": { "type": "string", "minLength": 1 }, - "thread_id": { "type": "string", "minLength": 1 }, - "transition_id": { "type": "string", "minLength": 1 }, - "expected_generation": { "type": "integer", "minimum": 1 } - }, - "additionalProperties": false - }, - "RecoveryReleaseResult": { - "type": "object", - "required": ["recovery_id", "outcome"], - "properties": { - "recovery_id": { "type": "string", "minLength": 1 }, - "thread_id": { "type": "string", "minLength": 1 }, - "outcome": { "type": "string", "enum": ["released", "already_released", "rejected", "uncertain"] }, - "error_code": { "type": "string" }, - "error_message": { "type": "string" } - }, - "additionalProperties": false - }, - "CommandResult": { - "description": "The result member of a successful JSON-RPC response. Select the matching result definition by request method.", - "oneOf": [ - { "$ref": "#/$defs/NegotiateVersionResult" }, - { "$ref": "#/$defs/GetRuntimeStateResult" }, - { "$ref": "#/$defs/TransitionResult" }, - { "$ref": "#/$defs/RuntimeIdentity" }, - { "$ref": "#/$defs/AuthGenerationResult" }, - { "$ref": "#/$defs/NativeLoginResult" }, - { "$ref": "#/$defs/NativeAuthSnapshotResult" }, - { "$ref": "#/$defs/NativeRefreshResult" }, - { "$ref": "#/$defs/RecoveryReleaseResult" } - ] - }, - "method_catalog": { - "type": "array", - "items": { - "type": "object", - "required": ["name", "wire_method"], - "properties": { - "name": { "type": "string" }, - "wire_method": { "type": "string" } - }, - "additionalProperties": false - }, - "default": [ - { "name": "GetRuntimeState", "wire_method": "runtime/state/read" }, - { "name": "PrepareAuthTransition", "wire_method": "auth/transition/prepare" }, - { "name": "CommitAuthTransition", "wire_method": "auth/transition/commit" }, - { "name": "CancelAuthTransition", "wire_method": "auth/transition/cancel" }, - { "name": "GetIdentity", "wire_method": "runtime/identity/read" }, - { "name": "GetAuthGeneration", "wire_method": "runtime/authGeneration/read" }, - { "name": "NegotiateVersion", "wire_method": "protocol/negotiate" }, - { "name": "NativeLogin", "wire_method": "account/login" }, - { "name": "NativeAuthSnapshot", "wire_method": "account/authSnapshot/read" }, - { "name": "NativeRefresh", "wire_method": "account/refresh" }, - { "name": "ReleaseRecovery", "wire_method": "recovery/release" } - ] - } - } -} diff --git a/protocol/events.json b/protocol/events.json deleted file mode 100644 index 94c3b45c..00000000 --- a/protocol/events.json +++ /dev/null @@ -1,310 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://codexmarathon.local/protocol/v1/events.json", - "title": "CodexMarathon protocol v1 runtime-to-controller events", - "description": "Typed event parameters carried in the codexmarathon/event JSON-RPC notification. Common correlation fields are snake_case; rate-limit payload members preserve the upstream app-server camelCase names.", - "oneOf": [ - { "$ref": "#/$defs/RuntimeReady" }, - { "$ref": "#/$defs/RateLimitsSnapshot" }, - { "$ref": "#/$defs/RateLimitsUpdated" }, - { "$ref": "#/$defs/TurnStarted" }, - { "$ref": "#/$defs/TurnCompleted" }, - { "$ref": "#/$defs/SafeBoundaryReached" }, - { "$ref": "#/$defs/AuthReloadStarted" }, - { "$ref": "#/$defs/AuthReloadSucceeded" }, - { "$ref": "#/$defs/AuthReloadFailed" }, - { "$ref": "#/$defs/IdentityChanged" }, - { "$ref": "#/$defs/RecoveryParked" }, - { "$ref": "#/$defs/RecoveryStarted" }, - { "$ref": "#/$defs/RecoveryCompleted" } - ], - "$defs": { - "EventBase": { - "type": "object", - "required": ["event_type", "occurred_at", "runtime_id", "auth_generation"], - "properties": { - "event_type": { "type": "string", "minLength": 1 }, - "occurred_at": { "type": "integer", "minimum": 0 }, - "runtime_id": { "type": "string", "minLength": 1 }, - "auth_generation": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": true - }, - "TransitionFields": { - "type": "object", - "required": ["transition_id", "runtime_id", "auth_generation"], - "properties": { - "transition_id": { "type": "string", "minLength": 1 }, - "runtime_id": { "type": "string", "minLength": 1 }, - "auth_generation": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": true - }, - "RateLimitWindow": { - "type": "object", - "required": ["usedPercent", "windowDurationMins", "resetsAt"], - "properties": { - "usedPercent": { "type": "number", "minimum": 0, "maximum": 100 }, - "windowDurationMins": { "type": ["integer", "null"], "minimum": 1 }, - "resetsAt": { "type": ["integer", "null"], "minimum": 0 } - }, - "additionalProperties": true - }, - "RateLimitSnapshot": { - "type": "object", - "required": ["limitId", "limitName", "planType", "rateLimitReachedType", "primary", "secondary"], - "properties": { - "limitId": { "type": ["string", "null"] }, - "limitName": { "type": ["string", "null"] }, - "planType": { "type": ["string", "null"] }, - "rateLimitReachedType": { "type": ["string", "null"] }, - "primary": { - "anyOf": [ - { "$ref": "#/$defs/RateLimitWindow" }, - { "type": "null" } - ] - }, - "secondary": { - "anyOf": [ - { "$ref": "#/$defs/RateLimitWindow" }, - { "type": "null" } - ] - } - }, - "additionalProperties": true - }, - "RuntimeReady": { - "title": "RuntimeReady", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "protocol_version", "account_id"], - "properties": { - "event_type": { "const": "runtime_ready" }, - "protocol_version": { "type": "integer", "minimum": 1 }, - "account_id": { "type": ["string", "null"] }, - "capabilities": { - "type": "array", - "items": { "type": "string", "minLength": 1 }, - "uniqueItems": true - } - }, - "additionalProperties": true - } - ] - }, - "RateLimitsSnapshot": { - "title": "RateLimitsSnapshot", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "account_id", "rateLimits", "rateLimitsByLimitId"], - "properties": { - "event_type": { "const": "rate_limits_snapshot" }, - "account_id": { "type": ["string", "null"] }, - "rateLimits": { "$ref": "#/$defs/RateLimitSnapshot" }, - "rateLimitsByLimitId": { - "type": ["object", "null"], - "additionalProperties": { "$ref": "#/$defs/RateLimitSnapshot" } - } - }, - "additionalProperties": true - } - ] - }, - "RateLimitsUpdated": { - "title": "RateLimitsUpdated", - "description": "A sparse rolling update. The controller must merge it against the runtime connection's latest full snapshot or refetch; null metadata does not clear prior values.", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "account_id", "rateLimits"], - "properties": { - "event_type": { "const": "rate_limits_updated" }, - "account_id": { "type": ["string", "null"] }, - "rateLimits": { "$ref": "#/$defs/RateLimitSnapshot" } - }, - "additionalProperties": true - } - ] - }, - "TurnStarted": { - "title": "TurnStarted", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "turn_id"], - "properties": { - "event_type": { "const": "turn_started" }, - "turn_id": { "type": "string", "minLength": 1 }, - "thread_id": { "type": ["string", "null"] } - }, - "additionalProperties": true - } - ] - }, - "TurnCompleted": { - "title": "TurnCompleted", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "turn_id", "outcome"], - "properties": { - "event_type": { "const": "turn_completed" }, - "turn_id": { "type": "string", "minLength": 1 }, - "thread_id": { "type": ["string", "null"] }, - "outcome": { "type": "string", "enum": ["completed", "failed", "interrupted"] }, - "error_code": { "type": ["string", "null"] } - }, - "additionalProperties": true - } - ] - }, - "SafeBoundaryReached": { - "title": "SafeBoundaryReached", - "description": "Emitted only after Codext's authoritative running-turn guard reaches a safe boundary.", - "allOf": [ - { "$ref": "#/$defs/TransitionFields" }, - { - "type": "object", - "required": ["event_type", "occurred_at", "transition_id", "reason"], - "properties": { - "event_type": { "const": "safe_boundary_reached" }, - "occurred_at": { "type": "integer", "minimum": 0 }, - "reason": { "type": "string", "minLength": 1 }, - "turn_id": { "type": ["string", "null"] } - }, - "additionalProperties": true - } - ] - }, - "AuthReloadStarted": { - "title": "AuthReloadStarted", - "allOf": [ - { "$ref": "#/$defs/TransitionFields" }, - { - "type": "object", - "required": ["event_type", "occurred_at", "transition_id", "target_account_id"], - "properties": { - "event_type": { "const": "auth_reload_started" }, - "occurred_at": { "type": "integer", "minimum": 0 }, - "target_account_id": { "type": "string", "minLength": 1 } - }, - "additionalProperties": true - } - ] - }, - "AuthReloadSucceeded": { - "title": "AuthReloadSucceeded", - "allOf": [ - { "$ref": "#/$defs/TransitionFields" }, - { - "type": "object", - "required": ["event_type", "occurred_at", "transition_id", "account_id", "changed"], - "properties": { - "event_type": { "const": "auth_reload_succeeded" }, - "occurred_at": { "type": "integer", "minimum": 0 }, - "account_id": { "type": ["string", "null"] }, - "previous_account_id": { "type": ["string", "null"] }, - "changed": { "type": "boolean" } - }, - "additionalProperties": true - } - ] - }, - "AuthReloadFailed": { - "title": "AuthReloadFailed", - "allOf": [ - { "$ref": "#/$defs/TransitionFields" }, - { - "type": "object", - "required": ["event_type", "occurred_at", "transition_id", "error_code", "error_message"], - "properties": { - "event_type": { "const": "auth_reload_failed" }, - "occurred_at": { "type": "integer", "minimum": 0 }, - "error_code": { "type": "string", "minLength": 1 }, - "error_message": { "type": "string", "minLength": 1 } - }, - "additionalProperties": true - } - ] - }, - "IdentityChanged": { - "title": "IdentityChanged", - "allOf": [ - { "$ref": "#/$defs/TransitionFields" }, - { - "type": "object", - "required": ["event_type", "occurred_at", "transition_id", "previous_account_id", "account_id"], - "properties": { - "event_type": { "const": "identity_changed" }, - "occurred_at": { "type": "integer", "minimum": 0 }, - "previous_account_id": { "type": ["string", "null"] }, - "account_id": { "type": ["string", "null"] } - }, - "additionalProperties": true - } - ] - }, - "RecoveryParked": { - "title": "RecoveryParked", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "recovery_id", "reason"], - "properties": { - "event_type": { "const": "recovery_parked" }, - "recovery_id": { "type": "string", "minLength": 1 }, - "reason": { "type": "string", "minLength": 1 }, - "thread_id": { "type": "string", "minLength": 1 }, - "turn_id": { "type": "string", "minLength": 1 }, - "source_account_id": { "type": "string", "minLength": 1 } - }, - "additionalProperties": true - } - ] - }, - "RecoveryStarted": { - "title": "RecoveryStarted", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "recovery_id"], - "properties": { - "event_type": { "const": "recovery_started" }, - "recovery_id": { "type": "string", "minLength": 1 }, - "thread_id": { "type": "string", "minLength": 1 }, - "turn_id": { "type": "string", "minLength": 1 } - }, - "additionalProperties": true - } - ] - }, - "RecoveryCompleted": { - "title": "RecoveryCompleted", - "allOf": [ - { "$ref": "#/$defs/EventBase" }, - { - "type": "object", - "required": ["event_type", "recovery_id", "outcome"], - "properties": { - "event_type": { "const": "recovery_completed" }, - "recovery_id": { "type": "string", "minLength": 1 }, - "outcome": { "type": "string", "enum": ["completed", "failed", "skipped"] }, - "error_code": { "type": ["string", "null"] }, - "thread_id": { "type": "string", "minLength": 1 }, - "turn_id": { "type": "string", "minLength": 1 } - }, - "additionalProperties": true - } - ] - } - } -} diff --git a/protocol/protocol.json b/protocol/protocol.json deleted file mode 100644 index 4a3544c6..00000000 --- a/protocol/protocol.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://codexmarathon.local/protocol/v1/protocol.json", - "title": "CodexMarathon protocol v1 JSON-RPC envelope", - "description": "The transport-neutral envelope for CodexMarathon controller/runtime IPC. Messages are newline-delimited JSON objects when carried over a stream.", - "oneOf": [ - { "$ref": "#/$defs/request" }, - { "$ref": "#/$defs/notification" }, - { "$ref": "#/$defs/success_response" }, - { "$ref": "#/$defs/error_response" } - ], - "$defs": { - "request_id": { - "description": "A JSON-RPC request identifier. The Go client emits strings; integers remain valid for interoperability.", - "oneOf": [ - { "type": "string", "minLength": 1 }, - { "type": "integer" } - ] - }, - "jsonrpc": { - "const": "2.0" - }, - "params": { - "type": "object", - "additionalProperties": true - }, - "request": { - "type": "object", - "required": ["jsonrpc", "id", "method"], - "properties": { - "jsonrpc": { "$ref": "#/$defs/jsonrpc" }, - "id": { "$ref": "#/$defs/request_id" }, - "method": { "type": "string", "minLength": 1 }, - "params": { "$ref": "#/$defs/params" } - }, - "additionalProperties": false - }, - "notification": { - "type": "object", - "required": ["jsonrpc", "method", "params"], - "properties": { - "jsonrpc": { "$ref": "#/$defs/jsonrpc" }, - "method": { - "const": "codexmarathon/event" - }, - "params": { - "type": "object", - "required": ["event_type"], - "additionalProperties": true - } - }, - "not": { "required": ["id"] }, - "additionalProperties": false - }, - "success_response": { - "type": "object", - "required": ["jsonrpc", "id", "result"], - "properties": { - "jsonrpc": { "$ref": "#/$defs/jsonrpc" }, - "id": { "$ref": "#/$defs/request_id" }, - "result": {} - }, - "not": { "required": ["error", "method"] }, - "additionalProperties": false - }, - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { "type": "integer" }, - "message": { "type": "string", "minLength": 1 }, - "data": {} - }, - "additionalProperties": false - }, - "error_response": { - "type": "object", - "required": ["jsonrpc", "id", "error"], - "properties": { - "jsonrpc": { "$ref": "#/$defs/jsonrpc" }, - "id": { "$ref": "#/$defs/request_id" }, - "error": { "$ref": "#/$defs/error" } - }, - "not": { "required": ["result", "method"] }, - "additionalProperties": false - }, - "identity": { - "type": "object", - "description": "The runtime's observed identity. A null account_id means that the runtime is currently unauthenticated or cannot identify the account.", - "required": ["runtime_id", "account_id", "auth_generation"], - "properties": { - "runtime_id": { "type": "string", "minLength": 1 }, - "account_id": { "type": ["string", "null"] }, - "auth_generation": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": true - }, - "transition_identity": { - "type": "object", - "description": "Fields required on every event that is part of an authentication transition.", - "required": ["transition_id", "runtime_id", "auth_generation"], - "properties": { - "transition_id": { "type": "string", "minLength": 1 }, - "runtime_id": { "type": "string", "minLength": 1 }, - "auth_generation": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": true - } - } -} diff --git a/runtime/README.md b/runtime/README.md index 913e13e8..3ca247fd 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -1,34 +1,26 @@ -# Optional Codex runtime and local-control adapter +# Native Codex runtime -`runtime/codex-rs` is the tracked, pinned Codex-derived source used to develop -and test CodexMarathon's supported local-control protocol. It is retained for -provenance and for an explicitly opt-in self-contained diagnostic package. -The normal CodexMarathon release is a Go companion and uses the user's -separately installed `codex` executable. +`runtime/codex-rs` is the tracked Codex Rust workspace used to build the +custom CLI. Marathon is compiled into the same `codex` executable and uses the +native app-server, AuthManager, turn manager, transport invalidation, and +recovery authorities. -`codexmarathon-adapter` is the narrow protocol seam, and -`codexmarathon-runtime` is the in-process bridge to Codex's native AuthManager, -turn state, telemetry, transport invalidation, and recovery authorities. The -adapter does not read the companion vault, refresh tokens independently, -create a second turn counter, invalidate transports itself, or create a -second recovery prompt. +`codexmarathon-runtime` contains the account registry, opaque snapshot vault, +transition journal, quota policy, automatic-reset state, and native authority +bridge. `codexmarathon-adapter` contains the typed internal bridge interfaces +used by the app-server composition. Neither crate starts a second Codex +process or prints credentials. -Build this tree only when working on the optional runtime path: +Build the CLI from the Rust workspace: -```text +```bash cd runtime/codex-rs +cargo fmt --all -- --check cargo test --locked -p codexmarathon-runtime -cargo build --locked --release -p codex-app-server +cargo build --locked --release -p codex-cli ``` -The resulting app-server is never packaged by default. Use -`--include-embedded-runtime` or `CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME=1` -only for controlled protocol diagnostics. No donor path appears in the Cargo -workspace. - -## Native Marathon user flow - -The integrated Codex build exposes Marathon from the `codex` executable itself: +The resulting executable is `target/release/codex`. It provides: ```text codex marathon status @@ -36,9 +28,6 @@ codex marathon login work --device-code codex marathon switch work ``` -`codex marathon login ALIAS` prompts in an interactive terminal to choose a -browser link or device/auth-code login. Use `--browser` or `--device-code` for -scripted or headless use. Inside the TUI, `/marathon` shows the current status -and help, and `/marathon login ALIAS` opens the same choice. Device-code login -prints a verification URL and one-time code that can be completed from another -machine; the account is imported automatically after native auth reloads. +The interactive TUI provides the matching `/marathon` commands and status-line +items. Automatic quota reset remains disabled by default and is tested with +mocks only. diff --git a/runtime/codex-rs/codexmarathon-runtime/README.md b/runtime/codex-rs/codexmarathon-runtime/README.md index d06d217e..9ec26591 100644 --- a/runtime/codex-rs/codexmarathon-runtime/README.md +++ b/runtime/codex-rs/codexmarathon-runtime/README.md @@ -1,41 +1,19 @@ -# Embedded CodexMarathon runtime bridge +# Native Marathon runtime -This crate is the in-process integration seam between the pinned Codex Rust -runtime and the CodexMarathon protocol adapter. It is part of the embedded -workspace; it is not a replacement runtime and it does not start Codext as a -child process. +This crate is the in-process domain and authority bridge for Marathon inside +the custom Codex CLI. It composes with Codex's existing AuthManager, running +turn watch, model-transport invalidation, rate-limit observations, and parked +recovery lifecycle. -Construct [`CodexNativeRuntime`](src/native.rs) from the Codex composition -root when the shared authorities are available, or implement -[`NativeCodexRuntime`](src/lib.rs) for another native host. The concrete bridge -forwards: +The crate owns account aliases, opaque credential snapshots, atomic storage, +transition evidence, quota policy, and mock-only automatic-reset state. It +does not print credentials, create a second turn counter, or start another +Codex executable. -- `AuthManager` identity and storage reload; -- the authoritative running-turn count; -- account rate-limit snapshots and sparse updates; -- account-bound model-transport invalidation; and -- Codex's existing parked recovery lifecycle. +The CLI and TUI call the app-server's typed Marathon requests. Login continues +through Codex's native login service, including browser-link and device-code +flows. A device-code login is suitable for a headless server because the +verification URL and one-time code can be completed on another machine. -`CodexNativeRuntime` also exposes the Marathon `account/login` and -`account/refresh` handlers through Codex's `run_login_server` and -`AuthManager::refresh_token_from_authority`. Login and refresh stage -credentials in an isolated temporary `CODEX_HOME`; only the opaque snapshot -or token result crosses the local adapter boundary. A secondary account -operation never overwrites the active `auth.json`. - -The `account/authSnapshot/read` handler reads the current serializable -snapshot from that same shared `AuthManager`. The controller uses it at the -safe boundary to write back refreshed Account A credentials to its protected -vault before deploying Account B; the runtime and adapter never log or -journal the opaque value. - -Wrap that implementation in [`EmbeddedRuntime`](src/lib.rs). The existing -adapter then owns only protocol framing, transition correlation, generation -checks, safe-boundary notifications, and event translation. It never -interprets opaque token snapshots or creates a second turn/recovery authority. - -The bridge is intentionally synchronous at its native authority boundary. -The production app-server wraps it in `RuntimeServer` when the launcher sets -`CODEXMARATHON_LISTEN`; that listener serves the adapter over a user-scoped -Unix socket or Windows named pipe. The server keeps pending transition intent -across reconnects and resets only per-connection protocol negotiation. +Automatic reset is disabled by default. The reset capability and executor are +mocked in tests; no real provider reset action is called by this crate. diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 index 008b8aef..aba66cc7 100644 --- a/scripts/build-release.ps1 +++ b/scripts/build-release.ps1 @@ -1,50 +1,21 @@ [CmdletBinding()] param( - [string]$Version = $(if ($env:CODEXMARATHON_VERSION) { $env:CODEXMARATHON_VERSION } else { '0.1.0' }), - [string]$Platform = $(if ($env:CODEXMARATHON_PLATFORM) { $env:CODEXMARATHON_PLATFORM } else { 'windows-x86_64' }), - [string]$Output = $(if ($env:CODEXMARATHON_OUTPUT) { $env:CODEXMARATHON_OUTPUT } else { 'dist/release' }), - [switch]$IncludeEmbeddedRuntime + [string]$Output = $(if ($env:CODEXMARATHON_OUTPUT) { $env:CODEXMARATHON_OUTPUT } else { 'dist/release' }) ) $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$buildDir = Join-Path $repoRoot 'dist/build' +$runtimeDir = Join-Path $repoRoot 'runtime/codex-rs' $outputDir = if ([IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $repoRoot $Output } -New-Item -ItemType Directory -Force -Path $buildDir, $outputDir | Out-Null +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null -Push-Location (Join-Path $repoRoot 'controller') +Push-Location $runtimeDir try { - & go build -trimpath -o (Join-Path $buildDir 'codexmarathon.exe') ./cmd/codexmarathon - if ($LASTEXITCODE -ne 0) { throw "go build failed with exit code $LASTEXITCODE" } + & cargo build --locked --release -p codex-cli + if ($LASTEXITCODE -ne 0) { throw "cargo build failed with exit code $LASTEXITCODE" } +} finally { + Pop-Location } -finally { Pop-Location } -$packageArgs = @( - '--root', $repoRoot, - '--output', $outputDir, - '--controller-binary', (Join-Path $buildDir 'codexmarathon.exe'), - '--platform', $Platform, - '--version', $Version, - '--format', 'zip', - '--windows-exe' -) -if ($IncludeEmbeddedRuntime -or $env:CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME -eq '1') { - Push-Location (Join-Path $repoRoot 'runtime/codex-rs') - try { - & cargo build --locked --release -p codex-app-server - if ($LASTEXITCODE -ne 0) { throw "cargo build failed with exit code $LASTEXITCODE" } - } - finally { Pop-Location } - $packageArgs += @( - '--include-embedded-runtime', - '--runtime-binary', (Join-Path $repoRoot 'runtime/codex-rs/target/release/codex-app-server.exe') - ) -} - -& python (Join-Path $repoRoot 'scripts/package_release.py') @packageArgs -if ($LASTEXITCODE -ne 0) { throw "release packaging failed with exit code $LASTEXITCODE" } - -Write-Host "Companion release archive created under $outputDir." -if ($IncludeEmbeddedRuntime -or $env:CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME -eq '1') { - Write-Host "Embedded runtime was included by explicit opt-in; run scripts/live_smoke.py separately for runtime evidence." -} +Copy-Item -LiteralPath (Join-Path $runtimeDir 'target/release/codex.exe') -Destination (Join-Path $outputDir 'codex.exe') -Force +Write-Host "Native CodexMarathon CLI created at $(Join-Path $outputDir 'codex.exe')." diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 6a823d49..592e7e27 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -2,52 +2,14 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -version="${CODEXMARATHON_VERSION:-0.1.0}" -if [[ -z "${CODEXMARATHON_PLATFORM:-}" ]]; then - case "$(uname -m)" in - x86_64) platform="linux-x86_64" ;; - aarch64|arm64) platform="linux-aarch64" ;; - *) echo "unsupported Linux architecture: $(uname -m)" >&2; exit 1 ;; - esac -else - platform="${CODEXMARATHON_PLATFORM}" -fi output_dir="${CODEXMARATHON_OUTPUT:-${repo_root}/dist/release}" -build_dir="${repo_root}/dist/build" -include_runtime="${CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME:-0}" +runtime_dir="${repo_root}/runtime/codex-rs" -mkdir -p "${build_dir}" "${output_dir}" +mkdir -p "${output_dir}" ( - cd "${repo_root}/controller" - go build -trimpath -o "${build_dir}/codexmarathon" ./cmd/codexmarathon + cd "${runtime_dir}" + cargo build --locked --release -p codex-cli ) -package_args=( - --root "${repo_root}" - --output "${output_dir}" - --controller-binary "${build_dir}/codexmarathon" - --platform "${platform}" - --version "${version}" - --format tar.gz -) -if [[ "${include_runtime}" == "1" ]]; then - ( - cd "${repo_root}/runtime/codex-rs" - cargo build --locked --release -p codex-app-server - ) - package_args+=( - --include-embedded-runtime - --runtime-binary "${repo_root}/runtime/codex-rs/target/release/codex-app-server" - ) -elif [[ "${include_runtime}" != "0" ]]; then - echo "CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME must be 0 or 1" >&2 - exit 1 -fi - -python3 "${repo_root}/scripts/package_release.py" \ - "${package_args[@]}" - -echo "Companion release archive created under ${output_dir}." -if [[ "${include_runtime}" == "1" ]]; then - echo "Embedded runtime was included by explicit opt-in; run scripts/live_smoke.py separately for runtime evidence." -fi +install -m 0755 "${runtime_dir}/target/release/codex" "${output_dir}/codex" +echo "Native CodexMarathon CLI created at ${output_dir}/codex." diff --git a/scripts/clean_machine_check.py b/scripts/clean_machine_check.py deleted file mode 100644 index 0718deeb..00000000 --- a/scripts/clean_machine_check.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise a release archive from an empty, toolchain-free directory. - -The check intentionally stops at controller initialization/status. It proves -that the packaged controller can create and read its own state without a -repository, donor checkout, Go/Rust toolchain, or the operator's real Codex -home. The default companion archive does not need a bundled Codex runtime; -the optional embedded-runtime entrypoint is inspected when present. It does -not claim OAuth, provider quota, or continuation success. -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import tempfile -from pathlib import Path, PurePosixPath - -SCRIPT_DIR = Path(__file__).resolve().parent -sys.path.insert(0, str(SCRIPT_DIR)) -from verify_package import ( # noqa: E402 - expected_required_files, - load_manifest, - read_archive, - safe_relative, - validate_archive, -) - - -def fail(message: str) -> None: - raise RuntimeError(message) - - -def extract_archive(artifact: Path, destination: Path) -> Path: - files = read_archive(artifact) - roots = {PurePosixPath(name).parts[0] for name in files} - if len(roots) != 1: - fail(f"release archive must contain one root directory: {sorted(roots)}") - artifact_root = destination / next(iter(roots)) - for name, data in files.items(): - parts = PurePosixPath(name).parts - relative = PurePosixPath(*parts[1:]) - safe_relative(relative.as_posix()) - target = destination / Path(*parts) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(data) - return artifact_root - - -def entrypoint(root: Path, expected: str) -> Path: - matches = sorted( - path - for path in root.rglob("*") - if path.is_file() and path.name in {expected, expected + ".exe"} - ) - if len(matches) != 1: - fail(f"expected exactly one packaged {expected} entrypoint, found {len(matches)}") - return matches[0] - - -def optional_entrypoint(root: Path, expected: str) -> Path | None: - matches = sorted( - path - for path in root.rglob("*") - if path.is_file() and path.name in {expected, expected + ".exe"} - ) - if len(matches) > 1: - fail(f"expected at most one packaged optional {expected} entrypoint, found {len(matches)}") - return matches[0] if matches else None - - -def run_controller(controller: Path, args: list[str], environment: dict[str, str]) -> subprocess.CompletedProcess[str]: - try: - return subprocess.run( - [str(controller), *args], - cwd=str(controller.parent), - env=environment, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=30, - check=False, - ) - except OSError as error: - fail(f"clean-machine controller launch failed: {error}") - except subprocess.TimeoutExpired as error: - fail(f"clean-machine controller command timed out: {error.cmd}") - - -def run_check(root: Path, artifact: Path, manifest: dict, skip_run: bool) -> None: - validate_archive(artifact, manifest) - with tempfile.TemporaryDirectory(prefix="codexmarathon-clean-") as temporary: - extraction = Path(temporary) / "install" - extraction.mkdir() - installed = extract_archive(artifact, extraction) - controller = entrypoint(installed, str(manifest["entrypoints"]["controller"])) - optional = manifest.get("optional_entrypoints", {}) - runtime_name = optional.get("runtime") if isinstance(optional, dict) else None - runtime = optional_entrypoint(installed, runtime_name) if isinstance(runtime_name, str) else None - if runtime is None: - print("INFO: clean-machine companion archive has no bundled Codex runtime") - else: - print(f"INFO: clean-machine found optional bundled runtime: {runtime.name}") - if os.name != "nt": - controller.chmod(0o755) - if skip_run: - print("PASS: clean-machine archive extraction and boundary checks") - return - - state = Path(temporary) / "state" - codex_home = Path(temporary) / "codex-home" - codex_home.mkdir(mode=0o700) - # An empty PATH is deliberate: init/status must not shell out to a - # developer toolchain or discover a donor executable. - environment = os.environ.copy() - environment.update( - { - "PATH": "", - "CODEX_HOME": str(codex_home), - "CODEX_DISABLE_UPDATE_CHECK": "1", - } - ) - auth = codex_home / "auth.json" - init = run_controller( - controller, - ["init", "--state-dir", str(state), "--auth", str(auth)], - environment, - ) - if init.returncode != 0: - fail(f"clean-machine init failed ({init.returncode}): {init.stderr[-2000:]}") - status = run_controller( - controller, - ["status", "--state-dir", str(state), "--auth", str(auth), "--json"], - environment, - ) - if status.returncode != 0: - fail(f"clean-machine status failed ({status.returncode}): {status.stderr[-2000:]}") - try: - status_view = json.loads(status.stdout) - except json.JSONDecodeError as error: - fail(f"clean-machine status returned invalid JSON: {error}") - if status_view.get("account_count") != 0 or status_view.get("runtime_connected"): - fail(f"unexpected clean-machine status: {status_view}") - accounts = run_controller( - controller, - ["accounts", "list", "--state-dir", str(state), "--auth", str(auth), "--json"], - environment, - ) - if accounts.returncode != 0: - fail(f"clean-machine account listing failed ({accounts.returncode}): {accounts.stderr[-2000:]}") - if json.loads(accounts.stdout) != []: - fail("clean-machine account listing was not empty") - print("PASS: clean-machine install, init, status, and account-list checks") - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) - parser.add_argument("--manifest", type=str) - parser.add_argument("--artifact", type=Path, required=True) - parser.add_argument( - "--skip-run", - action="store_true", - help="only extract and validate the artifact; useful when no built controller exists", - ) - args = parser.parse_args(argv) - root = args.root.resolve() - try: - manifest = load_manifest(root, args.manifest) - # Keep this explicit so the clean-machine check fails if the source - # release contract loses its notices or required metadata. - expected_required_files(manifest) - run_check(root, args.artifact.resolve(), manifest, args.skip_run) - except (OSError, RuntimeError, KeyError, json.JSONDecodeError) as error: - print(f"FAIL: clean-machine release check: {error}") - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/scripts/live_smoke.py b/scripts/live_smoke.py deleted file mode 100644 index 8590e2b1..00000000 --- a/scripts/live_smoke.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise an explicitly built optional embedded runtime over Unix IPC. - -This check applies only to the opt-in self-contained package variant. It -proves that the optional app-server can start, expose the Marathon listener, -negotiate protocol v1, and answer an identity read. The normal companion -package uses the user's separately installed Codex executable and does not -run this binary. This smoke does not claim OAuth, quota, or task-continuation -success. -""" - -from __future__ import annotations - -import argparse -import json -import os -import socket -import subprocess -import sys -import tempfile -import time -from pathlib import Path - - -def recv_line(connection: socket.socket, buffer: bytearray) -> tuple[dict, bytearray]: - deadline = time.monotonic() + 10 - while b"\n" not in buffer: - remaining = deadline - time.monotonic() - if remaining <= 0: - raise RuntimeError("timed out waiting for runtime protocol frame") - connection.settimeout(remaining) - chunk = connection.recv(65536) - if not chunk: - raise RuntimeError("runtime closed the IPC connection before replying") - buffer.extend(chunk) - line, _, rest = buffer.partition(b"\n") - try: - value = json.loads(line.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise RuntimeError(f"runtime returned invalid JSON: {error}") from error - if not isinstance(value, dict): - raise RuntimeError("runtime protocol frame is not an object") - return value, bytearray(rest) - - -def request(connection: socket.socket, request_id: str, method: str, params: dict, buffer: bytearray) -> tuple[dict, bytearray]: - payload = { - "jsonrpc": "2.0", - "id": request_id, - "method": method, - "params": params, - } - connection.sendall((json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")) - while True: - value, buffer = recv_line(connection, buffer) - # Runtime events may be emitted alongside a response. Ignore them for - # this handshake but keep reading the same stream. - if value.get("id") == request_id: - return value, buffer - - -def run(binary: Path, timeout: float) -> int: - if os.name == "nt": - print( - "BLOCKED: live Unix-socket smoke is not implemented on Windows; " - "run the Windows-native named-pipe acceptance harness before claiming live IPC" - ) - return 2 - if not binary.is_file(): - print(f"BLOCKED: optional embedded runtime binary not found: {binary}") - return 2 - with tempfile.TemporaryDirectory(prefix="codexmarathon-live-") as temporary: - root = Path(temporary) - socket_path = root / "runtime.sock" - codex_home = root / "codex-home" - codex_home.mkdir(mode=0o700) - env = os.environ.copy() - env.update( - { - "CODEX_HOME": str(codex_home), - "CODEXMARATHON_LISTEN": f"unix://{socket_path}", - "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG": "1", - "CODEX_DISABLE_UPDATE_CHECK": "1", - } - ) - process = subprocess.Popen( - [str(binary), "--listen", "off"], - cwd=str(binary.parent), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - try: - deadline = time.monotonic() + timeout - while not socket_path.exists(): - if process.poll() is not None: - stderr = process.stderr.read().decode("utf-8", errors="replace") if process.stderr else "" - raise RuntimeError(f"embedded runtime exited with {process.returncode}: {stderr[-2000:]}") - if time.monotonic() >= deadline: - raise RuntimeError("embedded runtime did not create its Unix socket") - time.sleep(0.1) - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: - connection.settimeout(max(1.0, timeout)) - connection.connect(str(socket_path)) - buffer = bytearray() - negotiate, buffer = request(connection, "smoke-1", "protocol/negotiate", {"supported_versions": [1]}, buffer) - if negotiate.get("error") is not None or negotiate.get("result", {}).get("protocol_version") != 1: - raise RuntimeError(f"protocol negotiation failed: {negotiate}") - identity, buffer = request(connection, "smoke-2", "runtime/identity/read", {}, buffer) - result = identity.get("result") - if identity.get("error") is not None or not isinstance(result, dict) or not result.get("runtime_id"): - raise RuntimeError(f"runtime identity read failed: {identity}") - print(f"PASS: live optional embedded runtime smoke: runtime_id={result['runtime_id']} generation={result.get('auth_generation', 0)}") - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - return 0 - finally: - if process.poll() is None: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--runtime-binary", type=Path, required=True) - parser.add_argument("--timeout", type=float, default=30.0) - args = parser.parse_args(argv) - try: - return run(args.runtime_binary.resolve(), args.timeout) - except (OSError, RuntimeError) as error: - print(f"FAIL: live optional embedded runtime smoke: {error}") - return 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/scripts/package_release.py b/scripts/package_release.py deleted file mode 100644 index 48fe5bab..00000000 --- a/scripts/package_release.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -"""Assemble and verify a CodexMarathon companion release archive. - -Only explicit product inputs are copied. The script never walks donor/, build -trees, controller state, or a user's Codex home, which makes accidental secret -inclusion materially harder than a broad directory archive. The normal mode -copies only the Go companion; the embedded runtime requires an explicit opt-in. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import shutil -import sys -import tarfile -import tempfile -import zipfile -from datetime import datetime, timezone -from pathlib import Path, PurePosixPath - -SCRIPT_DIR = Path(__file__).resolve().parent -sys.path.insert(0, str(SCRIPT_DIR)) -from verify_package import ( # noqa: E402 - check_secret_text, - expected_required_files, - safe_relative, - validate_archive, -) - - -def fail(message: str) -> None: - raise RuntimeError(message) - - -def load_manifest(root: Path, path_arg: str | None) -> dict: - path = Path(path_arg) if path_arg else root / "packaging" / "release-manifest.json" - if not path.is_absolute(): - path = root / path - try: - manifest = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - fail(f"cannot read release manifest: {error}") - if not isinstance(manifest, dict) or manifest.get("schema_version") != 1: - fail("release manifest schema_version must be 1") - return manifest - - -def resolved_input(root: Path, value: str, label: str, *, allow_build_output: bool = False) -> Path: - raw = Path(value) - path = raw if raw.is_absolute() else root / raw - path = path.resolve() - try: - path.relative_to(root.resolve()) - except ValueError: - fail(f"{label} must be inside the repository: {value}") - if not path.is_file(): - fail(f"{label} does not exist: {path}") - forbidden = {"donor", ".git", ".codexmarathon", ".state"} - if not allow_build_output: - forbidden.add("target") - if any(part.lower() in forbidden for part in path.relative_to(root).parts): - fail(f"{label} points into a forbidden source tree: {path}") - return path - - -def digest(path: Path) -> tuple[int, str]: - hasher = hashlib.sha256() - size = 0 - with path.open("rb") as stream: - while chunk := stream.read(1024 * 1024): - hasher.update(chunk) - size += len(chunk) - return size, hasher.hexdigest() - - -def copy_file(root: Path, staging_root: Path, source: Path, destination: str) -> None: - safe = safe_relative(destination) - target = staging_root / Path(*safe.parts) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, target) - check_secret_text(str(safe), target.read_bytes()) - - -def make_generated_manifest( - staging_root: Path, - artifact_root: str, - manifest: dict, - version: str, - platform: str, - distribution: str, -) -> None: - records = [] - for path in sorted(staging_root.rglob("*")): - if not path.is_file(): - continue - relative = path.relative_to(staging_root).as_posix() - if relative == "release-manifest.json": - continue - size, sha256 = digest(path) - records.append({"path": relative, "size": size, "sha256": sha256}) - generated = { - "schema_version": 1, - "product": manifest["product"], - "version": version, - "platform": platform, - "distribution": distribution, - "artifact_root": artifact_root, - "created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), - "provenance": manifest.get("provenance", {}), - "files": records, - } - output = staging_root / "release-manifest.json" - output.write_text(json.dumps(generated, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n") - - -def archive_directory(staging_parent: Path, artifact_root: str, output: Path, archive_format: str) -> None: - output.parent.mkdir(parents=True, exist_ok=True) - if output.exists(): - fail(f"refusing to overwrite existing release artifact: {output}; remove it explicitly") - if archive_format == "zip": - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for path in sorted((staging_parent / artifact_root).rglob("*")): - if path.is_file(): - archive.write(path, PurePosixPath(artifact_root, path.relative_to(staging_parent / artifact_root)).as_posix()) - elif archive_format == "tar.gz": - with tarfile.open(output, "w:gz") as archive: - archive.add(staging_parent / artifact_root, arcname=artifact_root, recursive=True, filter=tar_filter) - else: - fail(f"unsupported archive format: {archive_format}") - - -def tar_filter(member: tarfile.TarInfo) -> tarfile.TarInfo: - # Preserve executable bits but normalize ownership and timestamps so an - # archive never leaks the builder's username or host metadata. - member.uid = 0 - member.gid = 0 - member.uname = "" - member.gname = "" - member.mtime = 0 - return member - - -def build(args: argparse.Namespace) -> Path: - root = args.root.resolve() - manifest = load_manifest(root, args.manifest) - version = args.version.strip() - platform = args.platform.strip() - if not version or any(char in version for char in "/\\"): - fail("version must be a non-empty path-safe value") - if not platform or any(char in platform for char in "/\\"): - fail("platform must be a non-empty path-safe value") - controller_binary = resolved_input(root, args.controller_binary, "controller binary") - runtime_argument = getattr(args, "runtime_binary", None) - include_embedded_runtime = bool(getattr(args, "include_embedded_runtime", False)) - if include_embedded_runtime and not runtime_argument: - fail("--include-embedded-runtime requires --runtime-binary") - if not include_embedded_runtime and runtime_argument: - fail("--runtime-binary requires the explicit --include-embedded-runtime opt-in") - runtime_binary = None - if include_embedded_runtime: - runtime_binary = resolved_input(root, runtime_argument, "runtime binary", allow_build_output=True) - include = expected_required_files(manifest) - source_paths = {relative: resolved_input(root, relative, "allow-listed release input") for relative in include} - artifact_root = str(manifest.get("artifact_root", "codexmarathon-{version}-{platform}")).format(version=version, platform=platform) - if not artifact_root or "/" in artifact_root or "\\" in artifact_root or artifact_root in {".", ".."}: - fail("manifest artifact_root is not a safe directory name") - extension = ".zip" if args.format == "zip" else ".tar.gz" - output = args.output.resolve() / f"{artifact_root}{extension}" - with tempfile.TemporaryDirectory(prefix=f"{artifact_root}-", dir=str(args.output.resolve().parent if args.output.resolve().parent.exists() else root)) as temporary: - staging_parent = Path(temporary) - staging_root = staging_parent / artifact_root - staging_root.mkdir() - copy_file(root, staging_root, controller_binary, manifest["entrypoints"]["controller"] + (".exe" if args.windows_exe else "")) - if runtime_binary is not None: - optional_entrypoints = manifest.get("optional_entrypoints", {}) - runtime_name = optional_entrypoints.get("runtime") - if not isinstance(runtime_name, str) or not runtime_name.strip(): - fail("manifest optional_entrypoints.runtime is required for embedded-runtime packages") - copy_file(root, staging_root, runtime_binary, runtime_name + (".exe" if args.windows_exe else "")) - for relative, source in sorted(source_paths.items()): - copy_file(root, staging_root, source, relative) - entrypoint_names = {manifest["entrypoints"]["controller"]} - if runtime_binary is not None: - entrypoint_names.add(runtime_name) - entrypoint_names |= {name + ".exe" for name in entrypoint_names} - for path in staging_root.rglob("*"): - if path.is_file() and path.name in entrypoint_names: - if os.name != "nt": - path.chmod(0o755) - distribution = "companion-with-embedded-runtime" if include_embedded_runtime else "companion" - make_generated_manifest(staging_root, artifact_root, manifest, version, platform, distribution) - archive_directory(staging_parent, artifact_root, output, args.format) - # Re-read the final archive. This validates the exact bytes handed to the - # caller rather than merely trusting the temporary staging directory. - validate_archive(output, manifest) - return output - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) - parser.add_argument("--manifest", type=str) - parser.add_argument("--output", type=Path, default=Path("dist/release")) - parser.add_argument("--controller-binary", required=True) - parser.add_argument( - "--runtime-binary", - help="embedded Codex app-server binary; usable only with --include-embedded-runtime", - ) - parser.add_argument( - "--include-embedded-runtime", - action="store_true", - help="explicitly produce the optional self-contained package variant", - ) - parser.add_argument("--platform", required=True) - parser.add_argument("--version", required=True) - parser.add_argument("--format", choices=("tar.gz", "zip"), default="tar.gz") - parser.add_argument("--windows-exe", action="store_true", help="append .exe to packaged entrypoint names") - args = parser.parse_args(argv) - try: - artifact = build(args) - except (OSError, RuntimeError, KeyError) as error: - print(f"FAIL: package release: {error}") - return 1 - print(f"PASS: package release: {artifact}") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/scripts/test_package_release.py b/scripts/test_package_release.py deleted file mode 100644 index c4147153..00000000 --- a/scripts/test_package_release.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -"""Regression tests for companion and opt-in embedded-runtime packages.""" - -from __future__ import annotations - -import argparse -import json -import tempfile -import unittest -from pathlib import Path, PurePosixPath - -import package_release -from verify_package import read_archive - - -class PackageReleaseTests(unittest.TestCase): - def make_fixture(self, temporary: Path) -> tuple[Path, dict[str, str]]: - root = temporary / "repo" - (root / "packaging").mkdir(parents=True) - (root / "docs").mkdir() - (root / "README.md").write_text("companion documentation\n", encoding="utf-8") - (root / "docs" / "release.md").write_text("release documentation\n", encoding="utf-8") - controller = root / "codexmarathon.bin" - runtime = root / "codexmarathon-runtime.bin" - controller.write_bytes(b"controller\n") - runtime.write_bytes(b"embedded runtime\n") - manifest = { - "schema_version": 1, - "product": "codexmarathon", - "distribution": "companion", - "artifact_root": "codexmarathon-{version}-{platform}", - "entrypoints": {"controller": "codexmarathon"}, - "optional_entrypoints": {"runtime": "codexmarathon-runtime"}, - "include": ["README.md", "docs/release.md"], - "provenance": {}, - } - (root / "packaging" / "release-manifest.json").write_text( - json.dumps(manifest) + "\n", encoding="utf-8" - ) - return root, {"controller": str(controller.relative_to(root)), "runtime": str(runtime.relative_to(root))} - - def build_args(self, root: Path, output: Path, binaries: dict[str, str], *, include_runtime: bool) -> argparse.Namespace: - return argparse.Namespace( - root=root, - manifest=None, - output=output, - controller_binary=binaries["controller"], - runtime_binary=binaries["runtime"] if include_runtime else None, - include_embedded_runtime=include_runtime, - platform="linux-aarch64", - version="test", - format="tar.gz", - windows_exe=False, - ) - - @staticmethod - def suffixes(artifact: Path) -> set[str]: - files = read_archive(artifact) - return {"/".join(PurePosixPath(name).parts[1:]) for name in files} - - @staticmethod - def generated_manifest(artifact: Path) -> dict[str, object]: - files = read_archive(artifact) - manifest_name = next(name for name in files if PurePosixPath(name).name == "release-manifest.json") - return json.loads(files[manifest_name].decode("utf-8")) - - def test_default_package_contains_only_companion(self) -> None: - with tempfile.TemporaryDirectory(prefix="codexmarathon-package-test-") as directory: - root, binaries = self.make_fixture(Path(directory)) - artifact = package_release.build( - self.build_args(root, Path(directory) / "out", binaries, include_runtime=False) - ) - files = self.suffixes(artifact) - self.assertIn("codexmarathon", files) - self.assertNotIn("codexmarathon-runtime", files) - self.assertEqual(self.generated_manifest(artifact)["distribution"], "companion") - - def test_embedded_runtime_requires_explicit_opt_in(self) -> None: - with tempfile.TemporaryDirectory(prefix="codexmarathon-package-test-") as directory: - root, binaries = self.make_fixture(Path(directory)) - with self.assertRaisesRegex(RuntimeError, "explicit --include-embedded-runtime"): - bad_args = self.build_args(root, Path(directory) / "out", binaries, include_runtime=False) - bad_args.runtime_binary = binaries["runtime"] - package_release.build(bad_args) - - def test_opt_in_package_contains_runtime_and_marks_distribution(self) -> None: - with tempfile.TemporaryDirectory(prefix="codexmarathon-package-test-") as directory: - root, binaries = self.make_fixture(Path(directory)) - artifact = package_release.build( - self.build_args(root, Path(directory) / "out", binaries, include_runtime=True) - ) - files = self.suffixes(artifact) - self.assertIn("codexmarathon", files) - self.assertIn("codexmarathon-runtime", files) - self.assertEqual( - self.generated_manifest(artifact)["distribution"], - "companion-with-embedded-runtime", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/verify_package.py b/scripts/verify_package.py deleted file mode 100644 index 6ed1bbab..00000000 --- a/scripts/verify_package.py +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env python3 -"""Verify companion release inputs or an assembled CodexMarathon archive.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import sys -import tarfile -import zipfile -from pathlib import Path, PurePosixPath - - -SECRET_PATTERNS = ( - re.compile(r"\bgh[pousr]_[A-Za-z0-9_\-]{20,}\b"), - re.compile(r"\bgithub_pat_[A-Za-z0-9_\-]{20,}\b"), - re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), - re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{24,}"), -) -FORBIDDEN_SEGMENTS = { - ".git", - ".codexmarathon", - ".codexmarathon-dev", - ".state", - ".venv", - "__pycache__", - "donor", - "node_modules", - "target", -} -FORBIDDEN_FILES = { - ".env", - ".env.local", - "auth.json", - "accounts.json", - "events.jsonl", - "diagnostics.json", -} - - -def fail(message: str) -> None: - raise RuntimeError(message) - - -def load_manifest(root: Path, path_arg: str | None) -> dict: - path = Path(path_arg) if path_arg else root / "packaging" / "release-manifest.json" - if not path.is_absolute(): - path = root / path - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - fail(f"cannot read release manifest {path}: {error}") - if not isinstance(value, dict) or value.get("schema_version") != 1: - fail("release manifest schema_version must be 1") - return value - - -def safe_relative(value: str) -> PurePosixPath: - normalized = value.replace("\\", "/") - path = PurePosixPath(normalized) - if not normalized or str(path) in {"", "."}: - fail(f"archive path is empty: {value!r}") - if path.is_absolute() or ".." in path.parts: - fail(f"archive path escapes its root: {value!r}") - if any(segment.lower() in FORBIDDEN_SEGMENTS for segment in path.parts): - fail(f"forbidden path segment in release artifact: {value!r}") - if path.name.lower() in FORBIDDEN_FILES: - fail(f"forbidden state/credential file in release artifact: {value!r}") - return path - - -def digest_bytes(data: bytes) -> str: - return hashlib.sha256(data).hexdigest() - - -def check_secret_text(name: str, data: bytes) -> None: - if len(data) > 8 * 1024 * 1024: - return - text = data.decode("utf-8", errors="ignore") - for pattern in SECRET_PATTERNS: - if pattern.search(text): - fail(f"possible credential material detected in release file: {name}") - - -def expected_required_files(manifest: dict) -> set[str]: - include = {str(value).replace("\\", "/") for value in manifest.get("include", [])} - if not include: - fail("release manifest include list is empty") - return include - - -def expected_optional_entrypoints(manifest: dict) -> dict[str, str]: - optional = manifest.get("optional_entrypoints", {}) - if not isinstance(optional, dict): - fail("release manifest optional_entrypoints must be an object") - result: dict[str, str] = {} - for role, value in optional.items(): - if not isinstance(role, str) or not isinstance(value, str) or not value.strip(): - fail("release manifest optional entrypoint names must be non-empty strings") - safe_relative(value) - if "/" in value.replace("\\", "/"): - fail(f"release entrypoint must be a file name, not a path: {value!r}") - result[role] = value - return result - - -def validate_source(root: Path, manifest: dict) -> list[str]: - required = expected_required_files(manifest) - entrypoints = manifest.get("entrypoints", {}) - if not isinstance(entrypoints, dict) or not isinstance(entrypoints.get("controller"), str): - fail("release manifest must define the codexmarathon controller entrypoint") - expected_optional_entrypoints(manifest) - for relative in required: - safe_relative(relative) - path = root / Path(relative) - if not path.is_file(): - fail(f"release input is missing: {relative}") - return sorted(required) - - -def read_archive(path: Path) -> dict[str, bytes]: - if path.suffix.lower() == ".zip": - with zipfile.ZipFile(path) as archive: - result: dict[str, bytes] = {} - for info in archive.infolist(): - if info.is_dir(): - continue - safe = safe_relative(info.filename) - if safe.as_posix() in result: - fail(f"duplicate archive member: {info.filename}") - result[str(safe)] = archive.read(info) - return result - if path.name.endswith(".tar.gz") or path.name.endswith(".tgz") or path.suffix.lower() == ".tar": - mode = "r:gz" if path.name.endswith((".tar.gz", ".tgz")) else "r:" - with tarfile.open(path, mode) as archive: - result = {} - for member in archive.getmembers(): - if member.isdir(): - continue - if member.issym() or member.islnk(): - fail(f"symbolic links are not allowed in release archives: {member.name}") - safe = safe_relative(member.name) - if safe.as_posix() in result: - fail(f"duplicate archive member: {member.name}") - extracted = archive.extractfile(member) - if extracted is None: - fail(f"archive member is not readable: {member.name}") - result[str(safe)] = extracted.read() - return result - fail(f"unsupported release artifact format: {path}") - - -def validate_archive(path: Path, manifest: dict) -> list[str]: - files = read_archive(path) - required = expected_required_files(manifest) - optional_entrypoints = expected_optional_entrypoints(manifest) - # Archives have a generated root directory. Match required metadata and - # notices by suffix so the verifier is independent of version/platform. - by_suffix: dict[str, tuple[str, bytes]] = {} - artifact_roots: set[str] = set() - for name, data in files.items(): - parts = PurePosixPath(name).parts - if len(parts) < 2: - fail(f"release archive member is not inside an artifact root: {name}") - artifact_roots.add(parts[0]) - suffix = "/".join(parts[1:]) - if suffix in by_suffix: - fail(f"duplicate artifact member after root stripping: {suffix}") - by_suffix[suffix] = (name, data) - check_secret_text(name, data) - if len(artifact_roots) != 1: - fail(f"release archive must contain exactly one artifact root: {sorted(artifact_roots)}") - for required_path in required: - if required_path not in by_suffix: - fail(f"required release file is missing from archive: {required_path}") - entrypoints = manifest.get("entrypoints", {}) - for role, expected_name in entrypoints.items(): - candidates = [suffix for suffix in by_suffix if PurePosixPath(suffix).name == expected_name or PurePosixPath(suffix).name == expected_name + ".exe"] - if not candidates: - fail(f"release entrypoint {role!r} is missing ({expected_name})") - manifest_candidates = [value for suffix, value in by_suffix.items() if PurePosixPath(suffix).name == "release-manifest.json"] - if len(manifest_candidates) != 1: - fail("archive must contain exactly one generated release-manifest.json") - try: - generated = json.loads(manifest_candidates[0][1].decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - fail(f"generated release manifest is invalid: {error}") - if generated.get("product") != manifest.get("product"): - fail("generated release manifest product does not match source manifest") - distribution = generated.get("distribution") - if distribution not in {"companion", "companion-with-embedded-runtime"}: - fail("generated release manifest has an invalid distribution") - runtime_name = optional_entrypoints.get("runtime") - runtime_candidates = [] - if runtime_name: - runtime_candidates = [ - suffix - for suffix in by_suffix - if PurePosixPath(suffix).name in {runtime_name, runtime_name + ".exe"} - ] - if distribution == "companion" and runtime_candidates: - fail("companion archive unexpectedly contains the embedded runtime") - if distribution == "companion-with-embedded-runtime" and runtime_name and not runtime_candidates: - fail("embedded-runtime archive is missing its optional runtime entrypoint") - records = generated.get("files") - if not isinstance(records, list): - fail("generated release manifest has no file hash records") - record_paths: set[str] = set() - for record in records: - if not isinstance(record, dict) or not isinstance(record.get("path"), str): - fail("generated release manifest contains an invalid file record") - suffix = safe_relative(record["path"]).as_posix() - if suffix == "release-manifest.json" or suffix in record_paths: - fail(f"generated release manifest contains a duplicate/reserved path: {suffix}") - record_paths.add(suffix) - if suffix not in by_suffix: - fail(f"generated file hash names a missing member: {suffix}") - data = by_suffix[suffix][1] - if record.get("sha256") != digest_bytes(data): - fail(f"SHA-256 mismatch for generated member: {suffix}") - actual_paths = set(by_suffix) - {"release-manifest.json"} - if record_paths != actual_paths: - fail( - "generated release manifest does not enumerate exactly the archive files: " - f"missing={sorted(actual_paths - record_paths)}, " - f"unrecorded={sorted(record_paths - actual_paths)}" - ) - return sorted(files) - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) - parser.add_argument("--manifest", type=str) - parser.add_argument("--artifact", type=Path) - args = parser.parse_args(argv) - root = args.root.resolve() - try: - manifest = load_manifest(root, args.manifest) - if args.artifact: - checked = validate_archive(args.artifact.resolve(), manifest) - print(f"PASS: release archive: {args.artifact} ({len(checked)} files)") - else: - checked = validate_source(root, manifest) - print(f"PASS: release inputs: {len(checked)} allow-listed files and notices present") - except (OSError, RuntimeError) as error: - print(f"FAIL: release package: {error}") - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/scripts/verify_protocol.py b/scripts/verify_protocol.py deleted file mode 100644 index 004915d5..00000000 --- a/scripts/verify_protocol.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 -"""Check that protocol schemas and both language constants expose the same API.""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path - - -def fail(message: str) -> None: - raise RuntimeError(message) - - -def load_schema(path: Path) -> dict: - try: - document = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - fail(f"cannot read protocol schema {path}: {error}") - if not isinstance(document, dict): - fail(f"protocol schema root is not an object: {path}") - return document - - -def has_const(node: object, property_name: str, values: set[str]) -> None: - if isinstance(node, dict): - properties = node.get("properties") - if isinstance(properties, dict): - field = properties.get(property_name) - if isinstance(field, dict) and isinstance(field.get("const"), str): - values.add(field["const"]) - for child in node.values(): - has_const(child, property_name, values) - elif isinstance(node, list): - for child in node: - has_const(child, property_name, values) - - -def schema_constants(path: Path, property_name: str) -> set[str]: - document = load_schema(path) - definitions = document.get("$defs", {}) - if not isinstance(definitions, dict): - fail(f"protocol schema has no $defs object: {path}") - values: set[str] = set() - has_const(definitions, property_name, values) - if not values: - fail(f"protocol schema contains no {property_name!r} constants: {path}") - return values - - -def schema_definition_names(path: Path, property_name: str) -> set[str]: - document = load_schema(path) - definitions = document.get("$defs", {}) - if not isinstance(definitions, dict): - fail(f"protocol schema has no $defs object: {path}") - names = set() - for name, definition in definitions.items(): - values: set[str] = set() - has_const(definition, property_name, values) - if values: - names.add(name) - return names - - -def top_level_refs(path: Path, expected_names: set[str]) -> set[str]: - document = load_schema(path) - refs = document.get("oneOf", []) - if not isinstance(refs, list): - fail(f"protocol schema oneOf is not an array: {path}") - names = set() - for ref in refs: - if isinstance(ref, dict) and isinstance(ref.get("$ref"), str): - names.add(ref["$ref"].rsplit("/", 1)[-1]) - # Every method/event definition must be reachable from the schema entry - # point. This catches a definition that exists but can never validate. - unreachable = { - name - for name in expected_names - if name not in names - } - if unreachable: - fail(f"protocol definitions are unreachable from {path.name}: {sorted(unreachable)}") - return names - - -def constants_from_source(path: Path, pattern: str) -> set[str]: - text = path.read_text(encoding="utf-8") - return set(re.findall(pattern, text)) - - -def validate(root: Path) -> None: - commands = root / "protocol" / "commands.json" - events = root / "protocol" / "events.json" - command_values = schema_constants(commands, "method") - event_values = schema_constants(events, "event_type") - - top_level_refs(commands, schema_definition_names(commands, "method")) - top_level_refs(events, schema_definition_names(events, "event_type")) - - go_source = root / "controller" / "internal" / "runtime" / "wire.go" - rust_source = root / "runtime" / "codexmarathon-adapter" / "src" / "protocol.rs" - go_methods = constants_from_source(go_source, r'Method\w+\s+Method\s*=\s*"([^"]+)"') - go_events = constants_from_source(go_source, r'Event\w+\s+EventType\s*=\s*"([^"]+)"') - rust_methods = constants_from_source( - rust_source, r'pub const METHOD_[A-Z0-9_]+:\s*&str\s*=\s*"([^"]+)"' - ) - rust_events = constants_from_source( - rust_source, r'pub const EVENT_[A-Z0-9_]+:\s*&str\s*=\s*"([^"]+)"' - ) - # The adapter also exposes the JSON-RPC notification method under the - # EVENT_ prefix, but it is transport metadata rather than an event type. - rust_events.discard("codexmarathon/event") - if command_values != go_methods or command_values != rust_methods: - fail( - "command constants differ: " - f"schema-only={sorted(command_values - go_methods - rust_methods)}, " - f"go-only={sorted(go_methods - command_values)}, " - f"rust-only={sorted(rust_methods - command_values)}" - ) - if event_values != go_events or event_values != rust_events: - fail( - "event constants differ: " - f"schema-only={sorted(event_values - go_events - rust_events)}, " - f"go-only={sorted(go_events - event_values)}, " - f"rust-only={sorted(rust_events - event_values)}" - ) - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) - args = parser.parse_args(argv) - try: - validate(args.root.resolve()) - except (OSError, RuntimeError, json.JSONDecodeError) as error: - print(f"FAIL: protocol consistency: {error}") - return 1 - print("PASS: protocol consistency: schema, Go, and Rust method/event catalogs agree") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/scripts/verify_provenance.py b/scripts/verify_provenance.py index 991d000b..5761307d 100644 --- a/scripts/verify_provenance.py +++ b/scripts/verify_provenance.py @@ -1,16 +1,9 @@ #!/usr/bin/env python3 -"""Validate the source/provenance boundary before building a release. - -This script intentionally uses only Python's standard library so it can run on -a clean CI image before Go or Rust are installed. It validates the tracked -embedded runtime, not the read-only donor checkouts, and never reads auth -files or credential state. -""" +"""Validate the embedded Codex source and native Marathon boundary.""" from __future__ import annotations import argparse -import json import re import sys from pathlib import Path @@ -23,126 +16,43 @@ def fail(message: str) -> None: raise RuntimeError(message) -def relative_file(root: Path, value: str) -> Path: - candidate = Path(value) - if candidate.is_absolute() or ".." in candidate.parts: - fail(f"manifest path is not repository-relative: {value!r}") - path = root / candidate - if not path.is_file(): - fail(f"required provenance file is missing: {value}") - return path - - -def load_manifest(root: Path, manifest_arg: str | None) -> tuple[dict, Path]: - manifest = Path(manifest_arg) if manifest_arg else root / "packaging" / "release-manifest.json" - if not manifest.is_absolute(): - manifest = root / manifest - if not manifest.is_file(): - fail(f"release manifest is missing: {manifest}") - try: - value = json.loads(manifest.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - fail(f"cannot read release manifest: {error}") - if not isinstance(value, dict) or value.get("schema_version") != 1: - fail("release manifest schema_version must be 1") - return value, manifest - - -def validate(root: Path, manifest: dict, require_switch_clearance: bool) -> list[str]: - messages: list[str] = [] - provenance = manifest.get("provenance") - if not isinstance(provenance, dict): - fail("release manifest has no provenance object") - codext = provenance.get("codext") - if not isinstance(codext, dict): - fail("release manifest has no codext provenance") - codext_commit = str(codext.get("commit", "")) - if not HEX40.fullmatch(codext_commit): - fail("codext provenance commit must be a 40-character hexadecimal SHA") - if codext.get("license") != "Apache-2.0": - fail("codext provenance must identify Apache-2.0") - - provenance_path = relative_file(root, "runtime/PROVENANCE.md") - provenance_text = provenance_path.read_text(encoding="utf-8") - if codext_commit not in provenance_text: - fail("runtime/PROVENANCE.md does not contain the manifest codext commit") - if "Apache-2.0" not in provenance_text: +def validate(root: Path) -> list[str]: + provenance_path = root / "runtime" / "PROVENANCE.md" + ledger_path = root / "runtime" / "PATCH_LEDGER.md" + workspace_path = root / "runtime" / "codex-rs" / "Cargo.toml" + native_manifest = root / "runtime" / "codex-rs" / "codexmarathon-runtime" / "Cargo.toml" + adapter_manifest = root / "runtime" / "codexmarathon-adapter" / "Cargo.toml" + for path in (provenance_path, ledger_path, workspace_path, native_manifest, adapter_manifest): + if not path.is_file(): + fail(f"required provenance file is missing: {path.relative_to(root)}") + + provenance = provenance_path.read_text(encoding="utf-8") + commits = HEX40.findall(provenance) + if not commits: + fail("runtime/PROVENANCE.md does not contain an upstream source commit") + if "Apache-2.0" not in provenance: fail("runtime/PROVENANCE.md does not document the embedded license") - for license_path in codext.get("license_files", []): - relative_file(root, str(license_path)) - relative_file(root, "runtime/PATCH_LEDGER.md") - - cargo_manifests = list((root / "runtime" / "codex-rs").rglob("Cargo.toml")) - if not cargo_manifests: - fail("embedded runtime has no Cargo manifests") - for cargo in cargo_manifests: + for cargo in (root / "runtime" / "codex-rs").rglob("Cargo.toml"): text = cargo.read_text(encoding="utf-8", errors="replace") if re.search(r"(?:^|[\\/])donor(?:[\\/]|$)", text, re.IGNORECASE): - fail(f"embedded Cargo manifest references donor source: {cargo.relative_to(root)}") + fail(f"Cargo manifest references donor source: {cargo.relative_to(root)}") if (root / "runtime" / "codex-rs" / ".git").exists(): - fail("embedded runtime contains a nested .git directory") - - for include in manifest.get("include", []): - relative_file(root, str(include)) - - switch = provenance.get("codex_switch") - if not isinstance(switch, dict): - fail("release manifest has no codex_switch provenance") - if switch.get("distributed_source") is not False: - fail("codex-switch source must remain excluded from release archives until licensed") - if require_switch_clearance: - clearance = str(switch.get("clearance", "")) - if "required" in clearance.lower(): - fail("codex-switch redistribution clearance is still required") - else: - messages.append("codex-switch source is correctly excluded; redistribution clearance remains documented") - - # Scan only product files for obvious accidental inclusion of a donor - # dependency. We intentionally do not scan donor/. - scanned_roots = [ - root / "controller", - root / "integration", - root / "protocol", - root / "runtime" / "codexmarathon-adapter", - root / "runtime" / "codex-rs" / "codexmarathon-runtime", - ] - for scanned_root in scanned_roots: - if not scanned_root.exists(): - continue - for path in scanned_root.rglob("*"): - if not path.is_file() or path.stat().st_size > 2 * 1024 * 1024: - continue - text = path.read_text(encoding="utf-8", errors="replace") - if re.search(r"(?:^|[\\/])donor(?:[\\/])", text, re.IGNORECASE): - # Provenance comments are expected in controller quota code and - # docs; only executable manifests are forbidden. - if path.name in {"Cargo.toml", "go.mod"}: - fail(f"product build manifest contains donor reference: {path.relative_to(root)}") - - messages.append(f"embedded Codext commit {codext_commit} and required notices verified") - return messages + fail("embedded workspace contains a nested .git directory") + return [f"embedded Codex commit {commits[0]} and native Marathon manifests verified"] def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) - parser.add_argument("--manifest", type=str) - parser.add_argument( - "--require-codex-switch-clearance", - action="store_true", - help="fail while the manifest still records unresolved codex-switch redistribution clearance", - ) args = parser.parse_args(argv) - root = args.root.resolve() try: - manifest, manifest_path = load_manifest(root, args.manifest) - messages = validate(root, manifest, args.require_codex_switch_clearance) + messages = validate(args.root.resolve()) except (OSError, RuntimeError) as error: print(f"FAIL: provenance: {error}") return 1 - print(f"PASS: provenance: {manifest_path.relative_to(root)}") + print("PASS: native provenance") for message in messages: - print(f"INFO: provenance: {message}") + print(f"INFO: {message}") return 0 diff --git a/verify.ps1 b/verify.ps1 index 4ec91665..98f6b55d 100644 --- a/verify.ps1 +++ b/verify.ps1 @@ -1,21 +1,17 @@ [CmdletBinding()] param( - [switch]$SkipGo, [switch]$SkipRust ) -# One Windows-friendly verification entry point. The default release is a Go -# companion for an installed Codex CLI; Rust checks cover only the optional -# local-control/runtime source. It deliberately keeps going after a missing -# toolchain so the report separates observed checks from blocked evidence. +# Windows-friendly verification entry point for the native Rust Codex CLI. +# Missing toolchains are reported as BLOCKED so local setup problems are not +# mistaken for source failures. $ErrorActionPreference = 'Continue' $repo = (Resolve-Path (Join-Path $PSScriptRoot '.')).Path $failures = [System.Collections.Generic.List[string]]::new() $blocked = [System.Collections.Generic.List[string]]::new() -$observed = [System.Collections.Generic.List[string]]::new() function Pass([string]$Name, [string]$Detail) { - $script:observed.Add("PASS: $Name - $Detail") Write-Host "PASS: $Name - $Detail" -ForegroundColor Green } @@ -30,22 +26,19 @@ function Blocked([string]$Name, [string]$Detail) { } function Require-File([string]$RelativePath) { - $path = Join-Path $repo $RelativePath - if (Test-Path -LiteralPath $path -PathType Leaf) { + if (Test-Path -LiteralPath (Join-Path $repo $RelativePath) -PathType Leaf) { Pass "file $RelativePath" 'present' - return $true + } else { + Fail "file $RelativePath" 'missing' } - Fail "file $RelativePath" 'missing' - return $false } function Invoke-Tool([string]$Name, [string]$Command, [string]$WorkingDirectory, [string[]]$Arguments) { $commandInfo = Get-Command $Command -ErrorAction SilentlyContinue if ($null -eq $commandInfo) { - Blocked $Name "$Command not found; install the matching toolchain" - return $false + Blocked $Name "$Command not found; install the Rust toolchain" + return } - Write-Host "RUN: $Name ($Command $($Arguments -join ' '))" -ForegroundColor Cyan Push-Location -LiteralPath $WorkingDirectory try { & $commandInfo.Source @Arguments 2>&1 @@ -55,26 +48,17 @@ function Invoke-Tool([string]$Name, [string]$Command, [string]$WorkingDirectory, } if ($exitCode -eq 0) { Pass $Name 'completed' - return $true + } else { + Fail $Name "exit code $exitCode" } - Fail $Name "exit code $exitCode" - return $false } Write-Host "CodexMarathon verification: $repo" -ForegroundColor White -Write-Host "Observed checks and toolchain-gated checks are reported separately." -ForegroundColor Gray foreach ($required in @( - 'PLAN.md', - 'protocol/VERSION', - 'protocol/protocol.json', - 'protocol/commands.json', - 'protocol/events.json', - 'controller/go.mod', - 'controller/app/app.go', - 'controller/cmd/codexmarathon/main.go', - 'integration/go.mod', + 'README.md', 'runtime/PROVENANCE.md', + 'runtime/PATCH_LEDGER.md', 'runtime/codex-rs/Cargo.toml', 'runtime/codex-rs/Cargo.lock', 'runtime/codex-rs/LICENSE', @@ -82,95 +66,30 @@ foreach ($required in @( 'runtime/codex-rs/cli/Cargo.toml', 'runtime/codex-rs/codexmarathon-runtime/Cargo.toml', 'runtime/codexmarathon-adapter/Cargo.toml', - 'packaging/release-manifest.json', - 'packaging/README.md', - 'scripts/verify_provenance.py', - 'scripts/verify_package.py', - 'scripts/verify_protocol.py', - 'scripts/package_release.py', - 'scripts/test_package_release.py', - 'scripts/live_smoke.py', - 'docs/release.md' + 'scripts/verify_provenance.py' )) { - [void](Require-File $required) -} - -$embeddedManifest = Join-Path $repo 'runtime/codex-rs/Cargo.toml' -if (Test-Path -LiteralPath $embeddedManifest -PathType Leaf) { - if ((Get-Content -LiteralPath $embeddedManifest -Raw) -match 'donor[\\/]') { - Fail 'embedded workspace isolation' 'runtime/codex-rs/Cargo.toml references donor/' - } else { - Pass 'embedded workspace isolation' 'Cargo manifest has no donor dependency' - } -} - -foreach ($schema in @('protocol/protocol.json', 'protocol/commands.json', 'protocol/events.json', 'packaging/release-manifest.json')) { - $schemaPath = Join-Path $repo $schema - if (-not (Test-Path -LiteralPath $schemaPath -PathType Leaf)) { - continue - } - try { - $null = Get-Content -LiteralPath $schemaPath -Raw | ConvertFrom-Json - Pass "json $schema" 'valid JSON' - } catch { - Fail "json $schema" $_.Exception.Message - } -} - -$versionPath = Join-Path $repo 'protocol/VERSION' -if (Test-Path -LiteralPath $versionPath -PathType Leaf) { - $version = (Get-Content -LiteralPath $versionPath -Raw).Trim() - if ($version -match '^[1-9][0-9]*$') { - Pass 'protocol version' "v$version" - } else { - Fail 'protocol version' "expected a positive integer, got '$version'" - } -} - -if (Get-Command python -ErrorAction SilentlyContinue) { - [void](Invoke-Tool 'companion packaging mode tests' 'python' $repo @('scripts/test_package_release.py')) -} else { - Blocked 'companion packaging mode tests' 'python not found; install Python 3.12 or newer' -} - -if (-not $SkipGo) { - $controllerDir = Join-Path $repo 'controller' - $integrationDir = Join-Path $repo 'integration' - if (Get-Command go -ErrorAction SilentlyContinue) { - [void](Invoke-Tool 'controller Go tests' 'go' $controllerDir @('test', './...')) - [void](Invoke-Tool 'integration Go tests' 'go' $integrationDir @('test', './...')) - } else { - Blocked 'Go tests' 'go.exe not found; install Go 1.22 or newer' - } -} else { - Write-Host 'SKIP: Go tests requested by -SkipGo' -ForegroundColor Gray + Require-File $required } if (-not $SkipRust) { - $runtimeDir = Join-Path $repo 'runtime/codexmarathon-adapter' - $embeddedRuntimeDir = Join-Path $repo 'runtime/codex-rs' - if (Get-Command cargo -ErrorAction SilentlyContinue) { - [void](Invoke-Tool 'Rust adapter tests' 'cargo' $runtimeDir @('test')) - [void](Invoke-Tool 'optional runtime bridge tests' 'cargo' $embeddedRuntimeDir @('test', '-p', 'codexmarathon-runtime')) - [void](Invoke-Tool 'optional Codex CLI build' 'cargo' $embeddedRuntimeDir @('build', '-p', 'codex-cli')) - } else { - Blocked 'Rust runtime checks' 'cargo.exe not found; install Rust/Cargo' - } + $runtimeDir = Join-Path $repo 'runtime/codex-rs' + Invoke-Tool 'Rust formatting check' 'cargo' $runtimeDir @('fmt', '--all', '--', '--check') + Invoke-Tool 'native Marathon runtime tests' 'cargo' $runtimeDir @('test', '--locked', '-p', 'codexmarathon-runtime') + Invoke-Tool 'native Codex CLI build' 'cargo' $runtimeDir @('build', '--locked', '--release', '-p', 'codex-cli') } else { - Write-Host 'SKIP: Rust tests requested by -SkipRust' -ForegroundColor Gray + Write-Host 'SKIP: Rust checks requested by -SkipRust' -ForegroundColor Gray } Write-Host '' Write-Host 'Verification summary' -ForegroundColor White -Write-Host " observed: $($observed.Count)" -ForegroundColor Green Write-Host " failures: $($failures.Count)" -ForegroundColor Red -Write-Host " blocked: $($blocked.Count)" -ForegroundColor Yellow +Write-Host " blocked: $($blocked.Count)" -ForegroundColor Yellow if ($failures.Count -gt 0) { - Write-Host 'Result: FAILED (static or executed check failed).' -ForegroundColor Red + Write-Host 'Result: FAILED' -ForegroundColor Red exit 1 } if ($blocked.Count -gt 0) { - Write-Host 'Result: BLOCKED (required toolchain checks were unavailable).' -ForegroundColor Yellow + Write-Host 'Result: BLOCKED' -ForegroundColor Yellow exit 2 } Write-Host 'Result: PASS' -ForegroundColor Green