diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md new file mode 100644 index 00000000000..79f9ec451c9 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md @@ -0,0 +1,116 @@ +# Ephemeral Rust Child Link (`native/ocx-child`) Eliminates Full-Proxy Child Footprint While Preserving Exact Link, Sync, and Offline Restore Parity + +> Reader contract: For OpenCodex maintainers and reviewers evaluating Remote Link Child architecture, security boundaries, and phased execution on `feat/ocx-child-ephemeral-link` targeting `dev`. Document type: Decision & Roadmap Specification (`000`-range, zero implementation diffs per `LEXICO-SPLIT-01`). + +Remote Link today requires every Child machine (`src/server/management/link-enrollment.ts:147-180`) to pre-install and run a full standalone Bun OpenCodex proxy (~100-217 MB runtime footprint) and recycles back into a persistent standalone daemon on disconnect (`src/client/runtime.ts:133`), which is unsuitable for disposable SSH devboxes, containers, and lightweight VMs. How can OpenCodex attach a Remote Link Child with a megabyte-scale, zero-Bun/zero-Node footprint that relays inference/SSE traffic, participates byte-for-byte in Home `ocx sync` fan-out and OCX-6 parity, and restores native `~/.codex` state 100% offline with zero leftover daemon? We introduce `native/ocx-child` (standalone Rust crate compiling to a stripped ~1-2 MB `ocx-child` binary) paired with minimal Home-side discovery and detached-preflight support in `src/link/` and `src/server/management/`, locked to TypeScript behavior via a shared golden conformance fixture (`tests/fixtures/link/ocx-child-conformance.json`). + +## Contents + +1. [Loop Specification & HOTL Bounds](#1-loop-specification--hotl-bounds) +2. [Scope](#2-scope) +3. [Architect Consultation & Design Dispositions](#3-architect-consultation--design-dispositions) +4. [Dependency-Ordered Work-Phase Map (`PHASE-SPLIT-01`)](#4-dependency-ordered-work-phase-map-phase-split-01) +5. [Cross-Boundary Field & Type Chains (`PLAN-FIELD-CHAIN-01`)](#5-cross-boundary-field--type-chains-plan-field-chain-01) +6. [Verification scope](#6-verification-scope) +7. [Grounded Verifiers (`PLAN-VERIFIER-REAL-01`)](#7-grounded-verifiers-plan-verifier-real-01) +8. [Non-Goals, Alternatives Rejected, & Consequences](#8-non-goals-alternatives-rejected--consequences) + +## 1. Loop Specification & HOTL Bounds + +| Field | Specification | +|---|---| +| Loop archetype | `satisfy-spec` (Class C4 multi-cycle HOTL PABCD with docs-first `wp0` entry per `LOOP-DOCS-FIRST-01`) | +| Trigger | Dispatched GOAT task lane `lane-ocx-child-ephemeral-link` on branch `feat/ocx-child-ephemeral-link` + user steering requiring seamless `ocx sync` fan-out, OCX-6 runtime freshness/parity, and full sibling CLI compatibility | +| Goal | Deliver `native/ocx-child`, Home-side ephemeral-child Remote Link integration, shared TS/Rust golden conformance vectors, updated `structure/` and `docs-site/` documentation, and a verified unmerged PR targeting `dev` | +| Non-goals | Merging the PR (`authority.merge = false`), force-pushing, dispatching manual `lane=all` CI, mutating `.codexclaw` subagent configs, editing `.github/` workflows, touching `src/lab/` or `core-link-boundary` protected files (`src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`), raising file-size ratchets, or implementing Child-initiated (`ssh -L` `join`) / web-dashboard modes inside `ocx-child` | +| Verifier | Baseline & gate commands enumerated in [Section 7](#7-grounded-verifiers-plan-verifier-real-01): `bun run typecheck`, `cargo test --locked --manifest-path native/ocx-child/Cargo.toml`, `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml`, focused Bun conformance/unit/E2E suites, `bun run test:changed`, `bun run structure:check`, `cd docs-site && bun run build`, and `bun run privacy:scan` | +| Stop condition | All work-phases `wp0..wp4` and criteria `c-1..c-6` met with fresh command receipts, PR opened against `dev` and linked via `goat_pull_request_link`, exact-head PR checks green and recorded via `cxc loop pr-record`, `goat_pull_request_unwatch` called, and `update_goal({ status: "complete" })` accepted | +| Memory artifact | `devlog/_plan/261107_ocx_child_ephemeral_link/` (`000_roadmap.md` + `010..040` decade PRDs) and `.codexclaw/goalplans/loop-archetype-satisfy-spec-class-c4-hotl-multi/goalplan.json` | +| Expected terminal outcomes | `DONE`: all `c-1..c-6` verified + green unmerged PR recorded; `NOOP`: N/A (greenfield subsystem); `BLOCKED`: reproducible toolchain/CI/policy refusal reported with `file:line` and command output without weakening gates; `UNSAFE`: unmitigated credential/smuggling/restore defect; `NEEDS_HUMAN`: out-of-scope permission needed; `BUDGET_EXHAUSTED`: host limit reached | +| Escalation condition | Any requirement to edit outside `writeScope` (`native/ocx-child`, `src/link`, `src/server/management`, `src/cli`, `structure`, `docs-site`, `scripts`, `tests`, `package.json`, `devlog`), weaken a check, or merge the PR. Subagent recovery follows `DISPATCH-RETIRE-01` (inherit `google-antigravity/gemini-4-argon` per user directive) | +| HOTL resource bounds | Token/cost/wall-clock bounds: `none stated; host limits apply`. Tool/credential scope: local git/bun/cargo, `gh` PR creation/inspection on `feat/ocx-child-ephemeral-link`, GOAT PR link/watch/unwatch | + +## 2. Scope + +Security working analysis is kept outside the tracked repository. + +## 3. Architect Consultation & Design Dispositions + +- **Proposals consulted**: + - Initial read-only architect handle `01a1b183-8618-7280-bcc9-f1da84898e7d` (retired after wait timeout, completed text captured on `close_agent`) and replacement live read-only architect handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7` (both inherited `google-antigravity/gemini-4-argon`). +- **Main dispositions on `ARCH-01..ARCH-07`**: + - **`ARCH-01` (Amended)**: Adopt standalone `native/ocx-child` crate with `std::net` + bounded `std::thread` worker pool, surgical line/span TOML module, and minimal audited dependencies (`serde`, `serde_json`, `sha2`, `hmac`, `base64`, Unix `libc`, Windows `windows-sys`) matching `native/remote-workspace-helper` and `desktop/src-tauri`. Reject `tokio`/`hyper`/`rustls`/`toml_edit` because `ocx-child` speaks loopback HTTP/1.1 only (never TLS), keeping dependency count minimal and stripped release binary ~1-2 MB. + - **`ARCH-02` (Amended)**: Adopt strict HTTP/1.1 framing + `linkRouteAllowed` / `linkManagementRouteAllowed` + `CALLER_CREDENTIAL_HEADERS` stripping + `/healthz` + `/readyz` + `child-parity` reporter. Reject upstream socket pooling: Home's `src/server/index/link-relay-sessions.ts:105` consumes each `nonce` once (`consume(nonce)`), so each relayed request opens one `TcpStream` and runs `preflight 204 -> single data request` on that exact socket, matching `SingleConnectionAgent` (`src/client/link-relay-transport.ts:41`). + - **`ARCH-03` (Amended)**: Adopt `0600` journal + ordered locks (`$OPENCODEX_HOME/child/lifecycle.lock` -> `$CODEX_HOME/.opencodex-write.lock`) + fail-closed checks (full-`ocx` runtime/journal, foreign listener, external `model_provider`, user-owned root `openai_base_url`) + two-tier offline detach (byte-exact restore when `untouchedByExternalWriter` holds across attaches/syncs; surgical value-matched strip surviving `#1798` comment-stripping rewrites; fail-closed exit `4` on ambiguous/conflicting edits). Reject `[model_providers.opencodex]` table injection: scope strictly to Design B loopback root keys + Home `codex-settings` (`[features]`, `[agents]`, `web_search`) + `models_cache.json` refresh with websockets disabled. + - **`ARCH-04` (Amended)**: Adopt unified native (`attach`, `sync`, `detach`, `serve`, `status`, `port`) and `ocx`-compatible (`--version` -> `opencodex 2.98.0`, `link port`, `config show --json`, `connect --link --key-stdin`, `restart`, `disconnect`, `sync [--expect-link ] [--json]`, `status [--json]`, `connect status [--json]`) CLI verbs. Enforce exact `src/cli/sync-expectation.ts:55` stderr mismatch JSON `{"phase":"child_link_mismatch","reason":"..."}` with exit `5` (including `pending_operation` on lock contention) and post-sync live daemon rollover + immediate `child-parity` POST. + - **`ARCH-05` (Amended)**: Adopt ordered remote candidate fallback (all full-`ocx` paths first, then `ocx-child` in `~/.local/bin`, `~/.cache/opencodex/bin`, Windows `%LOCALAPPDATA%\opencodex\bin`, and `PATH`, keeping Windows encoded PowerShell `< 8000` chars) and detached `ocx-child` preflight in `src/server/management/link-enrollment.ts`. Reject adding new fields to persisted `LinkRecord` in `links.json` (`src/link/store.ts:16` enforces closed `exactKeys`, and `remoteOcxArgv(["disconnect"])` + `remotePinnedSyncArgv` already operate uniformly once `ocx-child` is in candidate discovery). + - **`ARCH-06` (Accepted)**: Shared golden conformance fixture `tests/fixtures/link/ocx-child-conformance.json` (`schema: "ocx.child.conformance/1"`) executed by both `bun test` and `cargo test`. + - **`ARCH-07` (Accepted)**: Place new TS helper at `src/link/ephemeral-child.ts`, keep zero edits in `src/cli/index.ts` or any `file-size-baseline.json` file, keep `core-link-boundary` and `core-lab-boundary` untouched, register all new TS tests in `scripts/test-layout/layout-clients.json` + `tests/fixtures/test-layout-expected.json`, and update `structure/remote-link.md` + `docs-site/`. +- **Same-architect reflection (`01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`)**: + - Submitted concrete plan revision: `devlog/_plan/261107_ocx_child_ephemeral_link/{000_roadmap.md,010_wp1_conformance_contract.md,020_wp2_rust_ocx_child.md,030_wp3_home_link_integration.md,040_wp4_docs_gates_and_pr.md}` (recorded in Section 3.1 immediately after reflection response). + +### 3.1 Reflection Record + +- **Handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7` reflection verdict**: `ALIGNED`. +- **Decision-ID mapping verified**: `ARCH-01` -> `000` §3 + `020` §1.1–§1.2; `ARCH-02` -> `000` §3 + `020` §1.2.3–§1.2.4; `ARCH-03` -> `000` §3, §5 + `020` §1.2.5; `ARCH-04` -> `000` §3, §5 + `020` §1.2.6; `ARCH-05` -> `000` §3, §8 + `030` §1.1–§1.3; `ARCH-06` -> `000` §5 + `010` §1.2–§1.3 + `020` §1.3; `ARCH-07` -> `000` §3, §7 + `010` §1.1 + `030` §1.5 + `040` §1.1–§1.2. +- **Minor notes folded into `020_wp2_rust_ocx_child.md`**: + - `Gap-1` (Accepted): `ChildJournalV1` snapshots `preAttachModelsCacheExisted` / `preAttachModelsCacheBytesB64` / `latestModelsCacheSha256` so offline `detach` restores pre-attach `models_cache.json` (or unlinks an unchanged cache `ocx-child` created) and never leaves stale Home slugs in Codex's model picker. + - `Gap-2` (Accepted): `native/ocx-child/Cargo.toml` includes `Win32_Security_Authorization` on `windows-sys` and `write_private_atomic` on Windows hardens `$OPENCODEX_HOME/child` and its `0600`-equivalent secret/journal files with an owner-only protected DACL. + +## 4. Dependency-Ordered Work-Phase Map (`PHASE-SPLIT-01`) + +Phases follow strict dependency order (shared wire/golden contracts -> standalone Child binary -> Home orchestration & E2E integration -> SoT documentation & publication): + +| Work Phase | Decade PRD | Depends On | Deliverable & Independent Verification Closure | +|---|---|---|---| +| `wp0` | `000_roadmap.md` + `010..040` | — | Complete roadmap and diff-level PRDs audited and closed with zero production edits (`LOOP-DOCS-FIRST-01`) | +| `wp1` | `010_wp1_conformance_contract.md` | `wp0` | `src/link/ephemeral-child.ts` pure helpers + `tests/fixtures/link/ocx-child-conformance.json` + `tests/clients/link-ocx-child-conformance.test.ts` registered in dual test-layout maps; passes `bun test` + `bun run typecheck` | +| `wp2` | `020_wp2_rust_ocx_child.md` | `wp1` | `native/ocx-child/` crate (`Cargo.toml`, `Cargo.lock`, `src/**`, `tests/conformance.rs`, `tests/relay_and_lifecycle.rs`) passing `cargo test --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo build --release --locked` (~1-2 MB stripped binary) | +| `wp3` | `030_wp3_home_link_integration.md` | `wp2` | Home candidate discovery (`src/link/ssh-argv.ts`, `src/link/remote-windows.ts`), detached `ocx-child` enrollment preflight (`src/link/remote-probe.ts`, `src/server/management/link-enrollment.ts`), `package.json` scripts/files, and focused + real-binary E2E tests (`tests/clients/link-ephemeral-child-discovery.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/ocx-child-e2e.test.ts`) | +| `wp4` | `040_wp4_docs_gates_and_pr.md` | `wp3` | `structure/manifest.json` + `structure/remote-link.md` + `structure/overview.md` (`INV-LINK-01` or bound invariant) + `docs-site/` updates, full verification matrix, branch push, unmerged PR targeting `dev` with Security Review Summary, `goat_pull_request_link`, green checks, and `cxc loop pr-record` | + +Single-PR delivery decision (`DEV-STACK-01`): Delivered as a single focused PR targeting `dev` because `native/ocx-child`, its golden conformance vectors, and the ~60-line Home discovery/preflight integration form one cohesive feature slice within the lane's authorized branch `feat/ocx-child-ephemeral-link`. + +## 5. Cross-Boundary Field & Type Chains (`PLAN-FIELD-CHAIN-01`) + +| Field / Schema | Creation | Serialization | Deserialization | Consumers | +|---|---|---|---|---| +| `ephemeralChild: true` on Child `config show --json` (`EphemeralChildConfigProjection`) | `native/ocx-child/src/cli.rs` (`cmd_config_show`) | `serde_json::to_string` on `ocx-child` stdout over SSH | `JSON.parse` in `src/server/management/link-enrollment.ts` -> `isDetachedEphemeralChildConfig` in `src/link/ephemeral-child.ts` | `src/server/management/link-enrollment.ts` (`apply` preflight branching between live standalone `ocx` vs clean detached `ocx-child`); full `ocx` omits field -> defaults to existing standalone branch | +| `ephemeralChild: true` on Child `GET /healthz` | `native/ocx-child/src/relay.rs` (`handle_healthz`) | `serde_json::to_vec` HTTP `200` body | `parseChildHttp` (`src/link/remote-probe.ts:30`) / `Response.json()` in `src/cli/link-apply-restart.ts:36` & `src/server/proxy-liveness.ts` | `isChildHealth(..., "client", childPort)` (`src/link/remote-probe.ts:35` ignores extra fields and verifies `service`, `port`, `pid`, `role === "client"`), `readClientRuntimeStartedAt`, E2E/diagnostic callers | +| `ChildStateV1` (`schema: "ocx.child.state/1"`) | `native/ocx-child/src/state.rs` (`write_state_atomic`) | `serde_json::to_vec_pretty` into `$OPENCODEX_HOME/child/state.json` (`0600`) | `serde_json::from_slice` (`#[serde(deny_unknown_fields)]`) in `native/ocx-child/src/state.rs` | Internal to `ocx-child` (`serve`, `sync`, `detach`/`disconnect`, `restart`, `status`, `config show`); N/A outside `ocx-child` because full `ocx` uses `config.json` and fail-closed guards forbid mixing | +| `ChildJournalV1` (`schema: "ocx.child.journal/1"`) | `native/ocx-child/src/journal.rs` (`create_or_update_journal`) | `serde_json::to_vec_pretty` into `$OPENCODEX_HOME/child/codex-journal.json` (`0600`) | `serde_json::from_slice` (`#[serde(deny_unknown_fields)]`) in `native/ocx-child/src/journal.rs` | `ocx-child` `sync` (updates `latestInjectedConfigSha256`, `ledger`, `latestCatalogSha256`) and offline `detach`/`disconnect` (Tier-1 hash restore or Tier-2 surgical value strip); also conformance harness in `native/ocx-child/tests/conformance.rs` | +| `ConformanceSuite` (`schema: "ocx.child.conformance/1"`) | Checked-in JSON fixture `tests/fixtures/link/ocx-child-conformance.json` verified against TS oracle functions | Static UTF-8 JSON file in git | `JSON.parse` in `tests/clients/link-ocx-child-conformance.test.ts` AND `serde_json::from_str` in `native/ocx-child/tests/conformance.rs` | Both `bun test` and `cargo test` conformance runners asserting identical outputs across route admission, framing/stripping, HMAC v2, OCX-6 digest, child-parity validation, and TOML/catalog round-trips | +| `ClientSyncMismatchReason` stderr JSON (`{"phase":"child_link_mismatch","reason":"..."}`) | Existing closed union in `src/client/sync-expectation.ts:17` (no new enum members added); emitted by `native/ocx-child/src/cli.rs` on `--expect-link` mismatch/busy lock | `serde_json::to_string` as final stderr line with exit code `5` | `JSON.parse(last ?? "")` in `src/cli/sync-fanout.ts:62` checking `mismatchReasons` set | Home `ocx sync [--json]` fan-out classifier (`classifySyncResult` -> `skipped("child-link-mismatch:")`) | + +## 6. Verification scope + +The work packages record implementation and conformance-test status. + +## 7. Grounded Verifiers (`PLAN-VERIFIER-REAL-01`) + +Every existing repository gate below was executed on the clean worktree before authoring this roadmap: + +| Verifier Command | Baseline Exit Today | How It Reads This Unit's Change Target | +|---|---|---| +| `bun run typecheck` (`bun check --no-script --noEmit`) | `0` (`2,318 files`) | `tsconfig.json` includes `src/**/*.ts`, `scripts/**/*.ts`, and `tests/**/*.ts`, directly typechecking `src/link/ephemeral-child.ts`, `src/server/management/link-enrollment.ts`, and all new `tests/**/*.test.ts` | +| `bun scripts/structure-ssot.ts` (`bun run structure:check`) | `0` | Reads `structure/manifest.json`, `structure/INDEX.md`, `structure/overview.md`, `structure/remote-link.md`, and `git ls-files` (`scripts/structure-ssot.ts:315-635`), validating `src/link/` & `native/ocx-child/` path references and invariant bindings | +| `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | `0` (`28 pass`) | Walks `tests/**/*.test.ts`, `scripts/test-layout/layout.json` + `explicitShards` (`scripts/test-layout/layout-clients.json`), and `tests/fixtures/test-layout-expected.json` (`tests/test-layout-tooling.test.ts:28,287`) | +| `bun test tests/lab/core-lab-boundary.test.ts tests/lab/core-link-boundary.test.ts` | `0` | Walks static imports from `src/router.ts`, `src/server/lifecycle.ts`, and `src/server/responses/core.ts` (`tests/lab/core-link-boundary.test.ts:9-48`) | +| `bun test tests/clients/link-pinned-sync-argv.test.ts tests/clients/link-ssh-argv.test.ts tests/clients/link-ssh-argv-discovery.test.ts` | `0` | Directly imports `src/link/ssh-argv.ts` and `src/link/remote-windows.ts` and executes `REMOTE_OCX_SCRIPT`, `REMOTE_PINNED_SYNC_SCRIPT`, `windowsOcxArgv`, and `windowsPinnedSyncArgv` (`POWERSHELL_ENCODED_MAX = 8000`) | +| `bun run privacy:scan` (`bun scripts/privacy-scan.ts`) | `0` (verified in `wp0` Check) | Scans tracked git files across `devlog/`, `src/`, `native/`, `tests/`, and `docs-site/` | +| `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` & `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` | Phase-created in `wp2` (`rustc 1.99.0` / `cargo 1.99.0` verified present) | Compiles and executes `native/ocx-child/src/**` and `native/ocx-child/tests/**` directly from `native/ocx-child/Cargo.toml` | +| `cd docs-site && bun install --frozen-lockfile && bun run build` | Executed in `wp4` when `docs-site/` changes | Astro/Starlight build reading `docs-site/src/content/docs/guides/remote-link.md` and `reference/cli/lifecycle.md` | + +## 8. Non-Goals, Alternatives Rejected, & Consequences + +- **Non-goals**: + - SCP/SFTP binary auto-upload from Home during `ocx link add` (`ocx-child` is discovered on the remote host like `ocx`). + - Child-initiated (`POST /api/link/join` / local `ssh -L` tunnel supervisor) or local web dashboard on `ocx-child`. + - Provider-table (`[model_providers.opencodex]`) auth-header injection on `ocx-child` (loopback Design B needs no local bearer token). +- **Alternatives rejected**: + - *Compiling `src/` with `bun build --compile` for Children*: Still ~100-217 MB and embeds the full proxy/GUI/provider runtime. + - *Async `tokio` + `hyper` + `toml_edit` Rust stack*: Adds ~60+ crates and megabytes of binary weight for loopback-only HTTP/1.1 streams and line-oriented TOML edits. + - *Adding `childKind` to `links.json`*: Breaks strict `exactKeys` compatibility in `src/link/store.ts:16` on downgrade while providing zero benefit once `remoteOcxArgv` and `remotePinnedSyncArgv` discover `ocx-child` in fallback order. +- **Consequences**: + - Ephemeral SSH hosts need only a single ~1-2 MB static-friendly Rust binary (`ocx-child`) and `curl` (POSIX) / PowerShell (Windows). + - Both `bun test` and `cargo test` must keep `tests/fixtures/link/ocx-child-conformance.json` green whenever Link routes, relay-auth v2, or OCX-6 settings digests evolve. diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md b/devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md new file mode 100644 index 00000000000..8e60f63a076 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md @@ -0,0 +1,27 @@ +# `261107_ocx_child_ephemeral_link` — Audit Disclosures & Phase Status Ledger + +> Reader contract: Maintainer and reviewer ledger tracking PABCD audit rounds, model-inheritance disclosures (`REVIEW-DECORRELATE-01`), and per-phase verification evidence (`LOOP-PESSIMIST-01`). + +## 1. Model Inheritance Disclosure (`REVIEW-DECORRELATE-01`) + +Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents (`explorer`, `architect`, `reviewer`) inherit the coordinator's model (`google-antigravity/gemini-4-argon`) without mutating shared `.codexclaw` subagent configuration files. Cross-family decorrelation was therefore intentionally waived by user instruction and compensated by fresh zero-conclusion prompts and real command-backed verification. + +## 2. `wp0` (Docs-Only Roadmap Cycle) Audit & Closure + +- **Architect consultation**: Handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7` returned `ALIGNED` on `ARCH-01..ARCH-07`; `Gap-1` (`models_cache.json` snapshot/restore in `ChildJournalV1`) and `Gap-2` (Windows owner-only DACL via `Win32_Security_Authorization`) folded into `020_wp2_rust_ocx_child.md`. +- **Independent A-gate review**: Handle `01a1b197-8e50-7722-bf0d-d13bb303f767` Round 1 returned `VERDICT: GO-WITH-FIXES (blockers=2)`; all 5 findings (omitting `restartCapability` on `ocx-child` client `/healthz`, `ephemeralChild`-specific `apply()` failure recovery string + fail-closed detached `restart`, `010` header dependencies + exact `isAllowedRequestOrigin` loopback Host/Origin check, `sync --json` stderr redirection + attached `serve` start rationale, and clean `corrupt_managed_span_refused` vector) were folded into `010`, `020`, and `030`. Round 2 re-audit returned `VERDICT: PASS` (`blocking_issues: []`). +- **`LOOP-PESSIMIST-01` notes (`wp0`)**: + - *Dead hypothesis*: Upstream socket pooling across relayed requests (`ARCH-02` alternative) died against `src/server/index/link-relay-sessions.ts:105` (`consume(nonce)` is strictly single-use), confirming one-shot `SingleSocketUpstream` per relayed request. + - *Dead hypothesis*: Advertising `restartCapability: "v1"` on `ocx-child` `GET /healthz` died in Round-1 audit because `ocx-child` does not serve `POST /api/system/restart` and `isChildHealth(..., "client", port)` does not require it. + - *Refutation trigger for `wp1..wp3`*: Any divergence between TypeScript and Rust outputs on `tests/fixtures/link/ocx-child-conformance.json`, or Windows encoded PowerShell exceeding `8000` chars. + +## 3. Work-Phase Status Table + +| Work Phase | Status | Summary | +|---|---|---| +| `wp0` | Completed | Docs-first roadmap (`000_roadmap.md`, `001_audit_and_status.md`, `010..040` decade PRDs) audited and locked with zero production edits | +| `wp1` | Completed | Shared golden conformance fixture (`ocx-child-conformance.json`), `src/link/ephemeral-child.ts`, and `tests/clients/link-ocx-child-conformance.test.ts` (65 pass, 0 fail; typecheck clean) | +| `wp2` | Completed | Standalone Rust crate `native/ocx-child` (stripped release binary; conformance, relay, and process-level lifecycle suites in `native/ocx-child/tests/`; `clippy -D warnings` & `fmt --check` clean). Current test counts are in the PR #8237 Verification section | +| `wp3` | Completed | Home discovery (`ssh-argv.ts`, `remote-windows.ts`), detached enrollment preflight (`link-enrollment.ts`), `package.json` scripts/files, and focused + real-binary E2E suites (`link-ocx-child-e2e.test.ts` 811ms pass) | +| `wp4` | Completed | `structure/manifest.json` + `structure/remote-link.md` + `structure/overview.md` (`INV-LINK-CHILD-01`) + `structure/INDEX.md`, `docs-site/` build (641 pages, 93,153 links), full gate matrix, and PR targeting `dev` | +| `wp5` | In Progress | PR #8237 review rounds: write-ahead recovery journal, multiline TOML structural scanner, overwritten user `[features]` surgical restoration, shared `CODEX_HOME` ownership, per-step home identity rechecks during restoration, committed daemon generations, bounded relay shutdown, and `models_cache.json` slug ownership across sync and detach, each with shared golden or Rust regression tests | diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md b/devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md new file mode 100644 index 00000000000..085eb40623f --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md @@ -0,0 +1,124 @@ +# `wp1` — Shared Golden Conformance Vectors & Ephemeral Child Contract Helpers + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp0` D at `a5c605fafc`): `wp0` locked the docs-first roadmap with zero production edits (`VERDICT: PASS` / `GO-WITH-FIXES (blockers=0)`). Revalidated `010_wp1_conformance_contract.md` against current HEAD: `isAllowedRequestOrigin` (`src/server/auth-cors.ts:118`) is added to `Header dependencies` and `routeCases` so DNS-rebinding `Host` and cross-origin `Origin` `403` rejections are locked in the shared golden fixture alongside route admission, smuggling, HMAC v2, OCX-6 digests, child-parity, and `#1798` journal round-trips. Architect consultation: covered by `ARCH-06` & `ARCH-07` (`ALIGNED` on handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp1` (depends on `wp0`; satisfies `c-2`) +- **Header dependencies**: + - Existing types/functions consumed: `linkRouteAllowed` (`src/link/routes.ts:15`), `linkManagementRouteAllowed` (`src/link/management-routes.ts:72`), `isAllowedRequestOrigin` (`src/server/auth-cors.ts:118`), `computeLinkRelayProof`, `computeLinkConnectionProof`, `verifyLinkRelayProof`, `verifyLinkConnectionProof` (`src/link/relay-auth.ts:45-95`), `HomeCodexSettings`, `codexSettingsDigestPayload`, `codexSettingsDigest` (`src/link/codex-settings.ts:30-182`), `parseLinkChildParityReport` (`src/link/child-parity.ts:67`), `validateHubRelayRequestHeaders` (`src/client/hub-relay.ts:49`), `classifySyncResult` (`src/cli/sync-fanout.ts:52`), `externalCodexModelProvider` & `ensureRootWebSearchDisabled` (`src/codex/inject/config-toml.ts:30,366`), and `stripJournaledOpenaiBaseUrl` (`src/codex/injected-marker.ts:106`). + - Shared types exported/validated in this phase: `EphemeralChildConfigProjection`, `isDetachedEphemeralChildConfig`, `isEphemeralChildHealth` (`src/link/ephemeral-child.ts`), and `ConformanceSuite` (`tests/fixtures/link/ocx-child-conformance.json`). +- **Scope**: + - **IN**: `src/link/ephemeral-child.ts` (NEW), `tests/fixtures/link/ocx-child-conformance.json` (NEW), `tests/clients/link-ocx-child-conformance.test.ts` (NEW — named `link-*` to satisfy `seeds.json` vs `cli` prefix rule), `scripts/test-layout/layout-clients.json` (MODIFY), `tests/fixtures/test-layout-expected.json` (MODIFY). + - **OUT**: `native/ocx-child/**` (`wp2`), `src/server/management/**` (`wp3`), `structure/**` & `docs-site/**` (`wp4`). + +## 1. Exact File Changes + +### 1.1 NEW `src/link/ephemeral-child.ts` + +Pure, zero-side-effect TypeScript contract helper module in `src/link/` (starts no timers, sockets, or processes on import; unreachable from `src/router.ts`, `src/server/lifecycle.ts`, and `src/server/responses/core.ts`): + +```ts +/** + * Contract predicates for the lightweight `ocx-child` ephemeral Remote Link Child runtime. + * + * Kept pure inside `src/link/` so enrollment and diagnostics can distinguish a clean detached + * `ocx-child` host from a full standalone `ocx` daemon without changing `LinkStore` on disk. + */ + +export interface EphemeralChildConfigProjection { + readonly port: number; + readonly runtimeRole: "standalone" | "client"; + readonly ephemeralChild: true; +} + +function isValidPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535; +} + +/** + * True when `config show --json` came from a clean, detached `ocx-child` binary ready for + * Home-initiated enrollment (`runtimeRole: "standalone"`, `ephemeralChild: true`, valid port). + * An already-attached `ocx-child` reports `runtimeRole: "client"` and returns `false`. + */ +export function isDetachedEphemeralChildConfig(value: unknown): value is EphemeralChildConfigProjection { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return record.ephemeralChild === true + && (record.runtimeRole ?? "standalone") === "standalone" + && isValidPort(record.port); +} + +/** True when a `/healthz` payload identifies a live `ocx-child` client relay on `expectedPort`. */ +export function isEphemeralChildHealth(value: unknown, expectedPort: number): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return record.service === "opencodex" + && record.role === "client" + && record.ephemeralChild === true + && record.port === expectedPort + && typeof record.pid === "number" + && Number.isInteger(record.pid) + && record.pid > 0 + && typeof record.startedAt === "string" + && Number.isFinite(Date.parse(record.startedAt)); +} +``` + +### 1.2 NEW `tests/fixtures/link/ocx-child-conformance.json` + +Deterministic JSON document (`"schema": "ocx.child.conformance/1"`) containing 6 golden vector arrays shared verbatim between `bun test` and `cargo test`: + +1. `routeCases` (24 vectors): + - Every allowed `POST` data-plane route (`/v1/responses`, `/v1/responses/compact`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/chat/completions`, `/v1/audio/transcriptions`, `/v1/alpha/search`, `/v1/images/generations`, `/v1/images/edits`, `/v1/live`, `/v1/realtime/calls`, plus all 10 `CONTEXT_PATHS` under `/v1/alpha/{history,notes}/v2/*`) -> `expectedAction: "relay_data"`, `expectedAuthHeader: "bearer"`. + - `GET /v1/catalog`, `HEAD /v1/catalog`, `GET /v1/hub-state`, `HEAD /v1/hub-state`, `GET /v1/models`, `GET /v1/opencodex/artifacts/item-1` -> `expectedAction: "relay_data"`, `expectedAuthHeader: "bearer"`. + - `GET /v1/usage` -> `expectedAction: "relay_data"`, `expectedAuthHeader: "x-opencodex-api-key"`. + - `GET /v1/link-management/codex-settings` (empty query -> `relay_management`; `?foo=1` -> `reject_404`), `GET /v1/link-management/api/settings` (clean query -> `relay_management`; `?refresh=1` -> `reject_404`). + - `GET /healthz` -> `local_healthz`; `GET /readyz` -> `local_readyz`; `HEAD /readyz` -> `reject_404`. + - `GET /v1/responses` with `Upgrade: websocket` (loopback `Origin`) -> `reject_426_websocket`; `GET /v1/responses` with `Upgrade: websocket` + `Origin: https://evil.example` -> `reject_403_origin`; `POST /v1/responses` with `Host: evil.example:10100` -> `reject_403_origin`; `POST /v1/responses` with `Origin: https://evil.example` -> `reject_403_origin`; `GET /v1/catalog` with `Upgrade: h2c` -> `reject_404`; `GET /api/config` & `DELETE /v1/responses` -> `reject_404`. +2. `framingCases` (10 vectors): + - Valid `Content-Length: 42` with caller credentials (`Authorization: Bearer sk-user`, `x-api-key: ant-user`, `x-goog-api-key: g-user`, `api-key: az-user`, `x-opencodex-api-key: ocx-user`, `chatgpt-account-id: acc-1`, `cookie: sid=1`, `Connection: keep-alive, x-custom-secret`, `x-custom-secret: drop-me`, `x-request-trace: keep-me`) -> `allow` and exact `strippedHeaderNames` leaving only `x-request-trace`. + - Valid `Transfer-Encoding: chunked` without `Content-Length` -> `allow`. + - Simultaneous `Content-Length: 10` + `Transfer-Encoding: chunked` -> `reject_400_smuggling`. + - Duplicate `Content-Length` (`10`, `10` or `10`, `20`) -> `reject_400_smuggling`. + - Non-digit `Content-Length: +10` / `10abc` -> `reject_400_smuggling`. + - Non-chunked `Transfer-Encoding: gzip, chunked` -> `reject_400_smuggling`. + - `Upgrade: websocket` in relay header validator -> `reject_400_smuggling`. + - Header value containing `\r` or `\n` -> `reject_400_invalid`. +3. `relayAuthCases` (3 deterministic vectors using synthetic test-only tokens like `"ocx_data_0000000000000000000000000000000000000001"` so `privacy:scan` sees obvious zero-entropy test fixtures): + - Locks `tokenSha256Hex`, `expectedCallerProof`, `expectedListenerProof`, and `expectedConnectionProof` computed over raw 32-byte `Buffer.from(tokenSha256Hex, "hex")`. +4. `settingsDigestCases` (4 vectors): + - All-null defaults (`fastMode: null, multiAgentMode: null, webSearchSidecarEnabled: true, subagentDefaults: null`). + - Populated `fastMode: true, multiAgentMode: "v1", webSearchSidecarEnabled: false, subagentDefaults: { model: "gpt-5.6-sol", reasoningEffort: "high" }`. + - Populated `fastMode: false, multiAgentMode: "v2", webSearchSidecarEnabled: true, subagentDefaults: { model: "anthropic/claude-sonnet-4-5", reasoningEffort: null }`. + - `multiAgentMode: "default"` with `subagentDefaults: null`. +5. `childParityCases` (6 vectors): + - Valid pre-apply (`lastApplyAt: null, appliedCodexSettingsDigest: null`), valid post-apply, mismatched nullability (`lastApplyAt: null` with non-null digest -> invalid), extra unknown property -> invalid, out-of-range timestamp -> invalid, negative `sequence` -> invalid. +6. `journalRoundtripCases` (8 vectors): + - `clean_absent_config`: `preAttachToml: null` -> attach -> unchanged detach -> `expectedFinalToml: null` (file unlinked), `expectedDetachOutcome: "exact_restore"`. + - `preserve_comments_and_crlf`: CRLF `config.toml` with `# user comment` and `[mcp_servers.git]` -> attach -> sync with updated `fastMode` + `subagentDefaults` -> unchanged detach -> byte-exact CRLF restoration (`"exact_restore"`). + - `app_rewrite_1798_comment_strip`: attach -> Codex App strips all `# Auto-injected by opencodex...` comments and adds `model = "gpt-5.6-sol"` + `[projects."/work"]` -> detach -> strips `openai_base_url`, `experimental_realtime_ws_base_url`, `model_catalog_json`, and managed settings by exact journaled value while preserving `[projects."/work"]` (`"surgical_restore"`). + - `web_search_disabled_and_restored`: user had `web_search = "live"` -> attach with `webSearchSidecarEnabled: false` writes `web_search = "disabled"` -> `#1798` strips marker + user adds `approval_policy = "never"` -> detach restores `web_search = "live"` and keeps `approval_policy = "never"` (`"surgical_restore"`). + - `user_changed_base_url_preserved`: attach -> user edits `openai_base_url = "https://custom.gateway.example/v1"` -> detach preserves user's custom `openai_base_url` while removing remaining owned keys (`"surgical_restore"`). + - `corrupt_managed_span_refused`: attach wrote Design B root keys -> external edit corrupts `config.toml` with an unterminated quoted assignment on a managed root key (`openai_base_url = "http://127.0.0.1:10100/v1`) -> detach refuses (`expectedDetachOutcome: "refuse_conflict"`) leaving `config.toml` and `codex-journal.json` untouched. + - `conflict_tampered_catalog_refused`: attach wrote `opencodex-catalog.json` -> user hand-edits `opencodex-catalog.json` -> detach refuses (`expectedDetachOutcome: "refuse_conflict"`) rather than deleting user-modified catalog. + - `refuse_external_model_provider_on_attach`: `preAttachToml` has `model_provider = "openrouter"` + `[model_providers.openrouter]\nbase_url = "https://openrouter.ai/api/v1"` -> `expectedAttachOk: false`, `expectedDetachOutcome: "not_attached"`. + +### 1.3 NEW `tests/clients/link-ocx-child-conformance.test.ts` & Test-Layout Shards + +- Resolves fixture via `fixturePath("link", "ocx-child-conformance.json")` from `../helpers/repo-root`. +- Verifies every vector in `routeCases`, `framingCases`, `relayAuthCases`, `settingsDigestCases`, `childParityCases`, and `journalRoundtripCases` against OpenCodex's live TypeScript implementations (`linkRouteAllowed`, `linkManagementRouteAllowed`, `validateHubRelayRequestHeaders`, `computeLinkRelayProof`, `computeLinkConnectionProof`, `codexSettingsDigestPayload`, `codexSettingsDigest`, `parseLinkChildParityReport`, `externalCodexModelProvider`, `stripJournaledOpenaiBaseUrl`, `ensureRootWebSearchDisabled`), plus unit tests for `isDetachedEphemeralChildConfig` and `isEphemeralChildHealth`. +- Registers `"link-ocx-child-conformance.test.ts": "clients"` in BOTH: + - `scripts/test-layout/layout-clients.json` + - `tests/fixtures/test-layout-expected.json` + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **WebSocket `Upgrade` vs generic `Upgrade` rejection**: Driven in `routeCases` (`Upgrade: websocket` on `/v1/responses` -> `reject_426_websocket`; `Upgrade: h2c` on `/v1/catalog` -> `reject_404`) and asserted against `linkRouteAllowed` + ingress rules. +2. **Smuggling header combinations (`CL+TE`, duplicate `CL`)**: Driven in `framingCases` and asserted to return `reason: "smuggling"` from `validateHubRelayRequestHeaders`. +3. **Raw 32-byte vs ASCII-hex HMAC key**: Driven in `relayAuthCases` and asserted against `computeLinkRelayProof` / `verifyLinkRelayProof` (plus negative assertion that HMAC over ASCII hex differs). +4. **Detached vs already-attached `ocx-child` config**: Driven against `isDetachedEphemeralChildConfig` (`runtimeRole: "client"` returns `false`; `ephemeralChild` missing returns `false`; port `< 1024` returns `false`). + +## 3. Phase Verifiers + +- `bun test tests/clients/link-ocx-child-conformance.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/lab/core-link-boundary.test.ts tests/lab/core-lab-boundary.test.ts` (exit `0`) +- `bun run typecheck` (exit `0`) +- `bun run privacy:scan` (exit `0`) diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md b/devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md new file mode 100644 index 00000000000..fd2d5da1a63 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md @@ -0,0 +1,124 @@ +# `wp2` — Standalone Lightweight Rust Child Crate (`native/ocx-child`) + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp1` D at `07f50076e5`): `wp1` landed the 74-vector `tests/fixtures/link/ocx-child-conformance.json` and `tests/clients/link-ocx-child-conformance.test.ts` (`65 pass, 0 fail`). Revalidated `020_wp2_rust_ocx_child.md` against `07f50076e5` and folded the `wp0` non-blocking Medium note: `Cargo.toml` sets crate `version = "0.1.0"` (matching `native/remote-workspace-helper/Cargo.toml:3`) while `pub const OCX_PROTOCOL_COMPAT_VERSION: &str = "2.98.0"` drives `--version` (`opencodex 2.98.0`) and peer headers, preventing `dev-version-bump.yml` drift on `package.json` bumps. Architect consultation: covered by `ARCH-01..ARCH-04` (`ALIGNED` on `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp2` (depends on `wp1`; satisfies `c-3`, `c-6`) +- **Header dependencies**: + - Consumes shared fixture `ConformanceSuite` (`tests/fixtures/link/ocx-child-conformance.json`) and wire/schema contracts `EphemeralChildConfigProjection` (`src/link/ephemeral-child.ts`), `HomeCodexSettingsDocument` / `HomeCodexSettings` (`src/link/codex-settings.ts:30`), `LinkChildParityReport` (`src/link/child-parity.ts:28`), `ClientSyncMismatchReason` (`src/client/sync-expectation.ts:17`), and `FanoutDocument` (`src/cli/sync-fanout.ts:86`). + - Introduces Rust types `ChildStateV1`, `ChildJournalV1`, `ChildInjectedLedger`, `RelayServer`, `SingleSocketUpstream`, and binary `ocx-child` (`2.98.0`). +- **Scope**: + - **IN**: `native/ocx-child/Cargo.toml` (NEW), `native/ocx-child/Cargo.lock` (NEW), `native/ocx-child/src/**` (NEW), `native/ocx-child/tests/**` (NEW). + - **OUT**: `src/**`, `package.json` (`wp3`), `structure/**` & `docs-site/**` (`wp4`). + +## 1. Exact File Map & Diff-Level Specification + +### 1.1 NEW `native/ocx-child/Cargo.toml` & `Cargo.lock` + +```toml +[package] +name = "opencodex-child" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +publish = false + +[[bin]] +name = "ocx-child" +path = "src/main.rs" + +[lib] +name = "ocx_child" +path = "src/lib.rs" + +[dependencies] +base64 = "=0.22.1" +hmac = "=0.12.1" +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +sha2 = "=0.10.9" + +[target.'cfg(unix)'.dependencies] +libc = "=0.2.191" + +[target.'cfg(windows)'.dependencies] +windows-sys = { + version = "=0.61.2", + features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", + "Win32_System_Threading", + ], +} + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +strip = "symbols" + +[profile.release.build-override] +strip = false +``` + +### 1.2 NEW `native/ocx-child/src/` Module Layout + +All modules set `#![deny(unsafe_op_in_unsafe_fn)]` at crate root (`lib.rs` / `main.rs`): + +1. `src/lib.rs` & `src/main.rs`: + - Exports testable library modules (`routes`, `http_wire`, `relay_auth`, `codex_settings`, `parity`, `toml_surgeon`, `journal`, `state`, `platform`, `relay`, `daemon`, `cli`). + - `pub const OCX_PROTOCOL_COMPAT_VERSION: &str = "2.98.0";` (`>= 2.93.0`, decoupled from crate `0.1.0` so automated `package.json` bumps never cause version-lock drift). +2. `src/platform/mod.rs`, `src/platform/unix.rs`, `src/platform/windows.rs`: + - `fill_random_bytes(&mut [u8])`: reads `/dev/urandom` on Unix and `BCryptGenRandom(..., BCRYPT_USE_SYSTEM_PREFERRED_RNG)` on Windows. + - `volatile_zero_bytes(&mut [u8])`: `core::ptr::write_volatile` byte loop + `core::sync::atomic::compiler_fence(Ordering::SeqCst)` (`SecretBytes` zeroes on `Drop`). + - `FileLock::acquire_blocking(path)` & `FileLock::try_acquire_nonblocking(path)`: opens lockfile with `0600` mode, acquires `libc::flock(fd, LOCK_EX)` / `LOCK_EX | LOCK_NB` on Unix and `LockFileEx(LOCKFILE_EXCLUSIVE_LOCK)` / `LOCKFILE_FAIL_IMMEDIATELY` on Windows. + - `write_private_atomic(path, bytes)`: ensures parent directory (`0700` on Unix; owner-only protected DACL via `SetNamedSecurityInfoW` / `PROTECTED_DACL_SECURITY_INFORMATION` on Windows), creates sibling temp file (`.tmp..`, `O_CREAT | O_EXCL | O_WRONLY` mode `0600` on Unix and owner-only DACL on Windows), writes all bytes, `sync_all()`, and atomically `fs::rename`s into `path`. + - `spawn_detached_serve(exe, opencodex_home, codex_home, listen_port)`: Unix `setsid` + `Stdio::null()`; Windows `CREATE_BREAKAWAY_FROM_JOB | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` breaking out of Home's `OcxJob` (`JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` in `src/link/remote-windows.ts:69`). + - `terminate_verified_daemon(pid, expected_port)`: verifies `GET http://127.0.0.1:/healthz` reports `service == "opencodex"`, `ephemeralChild == true`, and `pid == expected_pid` before sending `SIGTERM` / attested shutdown, polling up to 5 s until exit and port release. +3. `src/routes.rs` & `src/http_wire.rs`: + - `classify_route(method, path, query, headers) -> RouteDecision`: exact port of `src/link/routes.ts:15`, `src/link/management-routes.ts:72`, and `src/client/link-ingress.ts:88-96`. + - `GET /healthz` -> `LocalHealthz` returning `{ "service": "opencodex", "version": "2.98.0", "role": "client", "ephemeralChild": true, "uptime": , "pid": , "port": , "startedAt": "" }` (`restartCapability` / `restartBudgetMs` are deliberately omitted because `ocx-child` does not serve `POST /api/system/restart`, and `isChildHealth(..., "client", port)` at `src/link/remote-probe.ts:35` checks only `port`, `service`, `pid`, and `role === "client"`). + - `GET /readyz` (without `Upgrade`) -> `LocalReadyz`. + - Loopback `Host` & `Origin` anti-rebinding gate (matching `isAllowedRequestOrigin` in `src/server/auth-cors.ts:83-125` & `src/client/link-ingress.ts:89-96`): on every relayed route and `/v1/responses` WebSocket `Upgrade`, `Host` (when parseable) must be a loopback hostname (`127.0.0.1`, `localhost`, `[::1]`) and `Origin` (when present) must be `http(s)://(:port)`; otherwise return `403 origin_rejected`. + - `/v1/responses` with `Upgrade: websocket` (after passing `Host`/`Origin` check) -> `Reject426Websocket` (`426 upgrade_required`, `"Responses WebSocket transport is disabled; use HTTP"`). + - Any other `Upgrade` header -> `Reject404`. + - Exact `POST` routes + 10 `CONTEXT_PATHS`, `GET|HEAD` `/v1/catalog` & `/v1/hub-state`, `GET /v1/models`, `GET /v1/opencodex/artifacts/*` -> `RelayData { auth: Bearer }`. + - `GET /v1/usage` -> `RelayData { auth: ApiKeyHeader }`. + - `GET /v1/link-management/codex-settings` (empty query) and `GET|HEAD /v1/link-management/api/...` (20 allowlisted paths without `refresh|reload|force|quota` query params) -> `RelayManagement` (16 MiB response cap). + - Everything else -> `Reject404`. + - `parse_and_sanitize_request_headers`: enforces `64 KiB` (`65_536` B) cap (`431`), rejects CR/LF in names/values (`400`), rejects `CL+TE` / duplicate `CL` / non-digit `CL` / non-`chunked` `TE` / `Upgrade` (`400 {"error":"link relay request headers refused"}`), strips `HOP_BY_HOP_HEADERS`, `REQUEST_OMITTED_HEADERS`, `CALLER_CREDENTIAL_HEADERS`, and `Connection`-listed tokens. + - Enforces `256 MiB` (`268_435_456` B) inbound/non-SSE outbound body cap (`413` / `502`); streams chunked and SSE (`text/event-stream`) bodies chunk-by-chunk with immediate socket `flush()` and `300 s` header / SSE idle timeout. +4. `src/relay_auth.rs`, `src/codex_settings.rs`, & `src/parity.rs`: + - Raw 32-byte `SHA-256(token)` HMAC key; exact `opencodex-link-relay-v2` caller/listener proofs and `opencodex-link-connection-v1` generation proofs with constant-time 32-byte comparison. + - `SingleSocketUpstream::open_authenticated(tunnel_port, creds, probe_only)`: opens one `TcpStream` to `127.0.0.1:` (5 s timeout), sends `GET /.well-known/opencodex/link-relay-auth?version=2&...` with peer version/features headers (plus `x-opencodex-link-probe: 1` when `probe_only`), verifies `204` + 0-byte body + `x-opencodex-link-proof` (+ optional `x-opencodex-link-connection-proof`), and returns that **exact `TcpStream`** to carry the single data request with `x-opencodex-link-session: `. + - Exact OCX-6 `codex_settings_digest` and `LinkChildParityReport` (`<= 1024` B, immediate send on start / post-sync rollover + `[2s, 5s, 15s, 30s, 60s]` backoff then `60s` period, halting on `404`). +5. `src/toml_surgeon.rs` & `src/journal.rs`: + - Ordered locks: `$OPENCODEX_HOME/child/lifecycle.lock` -> `$CODEX_HOME/.opencodex-write.lock`. + - Fail-closed attach checks: refuses if `$OPENCODEX_HOME/runtime-port.json`, `ocx.pid`, full-`ocx` `config.json`, or `$CODEX_HOME/.opencodex-journal.json` exists, if a foreign listener occupies `listen_port`, or if `config.toml` has an external `model_provider` (except `base_url`-less placeholder), unmarked user `openai_base_url` / `experimental_realtime_ws_base_url`, or non-`opencodex-catalog.json` `model_catalog_json`. + - Design B apply + `0600` `ChildJournalV1`: snapshots `preAttachConfig*`, `preAttachCatalog*`, and `preAttachModelsCacheExisted` / `preAttachModelsCacheBytesB64` / `latestModelsCacheSha256`; writes root `openai_base_url`, `experimental_realtime_ws_base_url`, `model_catalog_json`, optional `web_search = "disabled"` (saving `replacedUserWebSearchLine`), `[features]` `fast_mode` / `multi_agent_v2`, and `[agents]` subagent defaults; writes `$CODEX_HOME/opencodex-catalog.json` and refreshes `$CODEX_HOME/models_cache.json`. + - Two-tier 100% offline `detach`: Tier 1 exact byte restore when `untouchedByExternalWriter && currentSha256 == latestInjectedConfigSha256`; Tier 2 surgical value-matched strip surviving `#1798` comment stripping while preserving user edits; fails closed (`exit 4`) on corrupt managed spans or tampered `opencodex-catalog.json`; restores pre-attach `models_cache.json` (or unlinks an unchanged cache `ocx-child` created so stale Home slugs never linger); stops owned `serve` daemon and leaves zero running process. +6. `src/cli.rs`: + - Native & `ocx`-compatible verbs: `--version` (`opencodex 2.98.0`), `port` / `link port`, `config show --json`, `attach` / `connect --link --key-stdin --tunnel-port

--link-id `, `serve` / `restart` (rolls over `ocx-child serve` when attached; fails closed `exit 1` without spawning any daemon when detached so zero orphan daemon can ever start), `sync [--expect-link ] [--json]` (under `--json`, all human/diagnostic lines write strictly to `stderr` so `stdout` emits only the single `ocx.sync.fanout/1` JSON object; validates `--expect-link` under non-blocking lock with exit-5 `child_link_mismatch` / `pending_operation` stderr JSON before any network/disk I/O; intentionally starts `ocx-child serve` if absent on an attached VM or rolls it over when `daemonStartedAtMs < appliedAtMs` so `/healthz` `startedAt >= appliedAt`, and immediately POSTs `child-parity`), `detach` / `disconnect`, `status [--json]` / `connect status [--json]` / `link status [--json]`. + +### 1.3 NEW `native/ocx-child/tests/conformance.rs` & `tests/relay_and_lifecycle.rs` + +- `tests/conformance.rs`: executes all 6 vector arrays from `../../tests/fixtures/link/ocx-child-conformance.json`. +- `tests/relay_and_lifecycle.rs`: integration suite with a real mock Home `hub-link` HTTP/1.1 + SSE server verifying same-socket peer binding, single-use `nonce`, SSE streaming, smuggling/Upgrade rejection, caller credential stripping, `attach -> serve -> sync -> rollover + parity -> #1798 offline detach`, busy-lock/mismatch `--expect-link` exit `5`, and fail-closed attach/detach conflicts. + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **Same-socket binding**: Mock Home verifies preflight and data request share identical client `SocketAddr`. +2. **Spoofed Home listener proof**: Mock Home returns bad `x-opencodex-link-proof` -> `ocx-child` returns `503 link_relay_auth_failed` without sending `ocx_data_*`. +3. **`#1798` comment-stripped offline detach**: Strip all `#` comments while Home is stopped -> `ocx-child detach` strips proxy keys by value and preserves user keys. +4. **Busy-lock & mismatch `--expect-link` exit `5`**: Hold `lifecycle.lock` while invoking `sync --expect-link` -> observe exit `5` and `{"phase":"child_link_mismatch","reason":"pending_operation"}` on stderr. +5. **Fail-closed attach**: Plant `runtime-port.json` or external `model_provider` -> observe non-zero refusal with `config.toml` untouched. + +## 3. Phase Verifiers + +- `cargo fmt --manifest-path native/ocx-child/Cargo.toml --check` (exit `0`) +- `cargo clippy --locked --manifest-path native/ocx-child/Cargo.toml --all-targets -- -D warnings` (exit `0`) +- `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` (exit `0`) +- `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` (exit `0`, stripped binary size recorded) diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md b/devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md new file mode 100644 index 00000000000..95496d62c74 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md @@ -0,0 +1,125 @@ +# `wp3` — Home-Side Ephemeral Child Discovery, Enrollment, Sync Fan-Out, & E2E Integration + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp2` D at `072d491771`): `wp2` delivered `native/ocx-child` (878 KiB stripped release binary, 6/6 Rust conformance + live lifecycle suites passing). Revalidated `030_wp3_home_link_integration.md` against `072d491771`: all paths, hunks, `link-*` test-layout shard entries, and real-binary E2E steps match the compiled `ocx-child` contract. Architect consultation: covered by `ARCH-05` & `ARCH-07` (`ALIGNED` on `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp3` (depends on `wp2`; satisfies `c-4`, `c-6`) +- **Header dependencies**: + - Consumes `isDetachedEphemeralChildConfig` (`src/link/ephemeral-child.ts`), `POWERSHELL_ENCODED_MAX` (`src/link/remote-windows.ts:8`), `isChildHealth`, `remoteChildHttpArgv`, `REMOTE_CURL_PREFLIGHT` (`src/link/remote-probe.ts:20-40`), `classifySyncResult` (`src/cli/sync-fanout.ts:52`), and compiled `ocx-child` binary. +- **Scope**: + - **IN**: `src/link/ssh-argv.ts` (MODIFY), `src/link/remote-windows.ts` (MODIFY), `src/server/management/link-enrollment.ts` (MODIFY), `package.json` (MODIFY), `tests/clients/link-ephemeral-child-discovery.test.ts` (NEW), `tests/server/link-enrollment-ephemeral-child.test.ts` (NEW), `tests/clients/ocx-child-e2e.test.ts` (NEW), `scripts/test-layout/layout-clients.json` (MODIFY), `tests/fixtures/test-layout-expected.json` (MODIFY). + - **OUT**: `src/cli/index.ts` (untouched — 1999/2000 cliff), `src/link/store.ts` (untouched — `LinkRecord` stays backwards-compatible), `structure/**` & `docs-site/**` (`wp4`). + +## 1. Exact File Changes & Before/After Hunks + +### 1.1 MODIFY `src/link/ssh-argv.ts` + +Extend `REMOTE_OCX_CANDIDATES` so every full-`ocx` candidate is probed first, and only when all full-`ocx` candidates miss does discovery probe `ocx-child` in `$HOME/.local/bin/ocx-child`, `$HOME/.cache/opencodex/bin/ocx-child`, and `$PATH`: + +```diff + const REMOTE_OCX_CANDIDATES = `for candidate in "$HOME/.opencodex-desktop/bin/ocx" "/Applications/OpenCodex.app/Contents/MacOS/ocx" "/usr/bin/ocx"; do + try_ocx "$candidate" "$@" + done + remaining=$PATH + while :; do + directory=\${remaining%%:*} + try_ocx "\${directory:-.}/ocx" "$@" + case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac +-done`; ++done ++for candidate in "$HOME/.local/bin/ocx-child" "$HOME/.cache/opencodex/bin/ocx-child"; do ++ try_ocx "$candidate" "$@" ++done ++remaining=$PATH ++while :; do ++ directory=\${remaining%%:*} ++ try_ocx "\${directory:-.}/ocx-child" "$@" ++ case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac ++done`; +``` + +### 1.2 MODIFY `src/link/remote-windows.ts` + +Append `ocx-child.exe` fallback candidates after all `ocx.exe` candidates in `OCX_CANDIDATES`, keeping both `windowsOcxArgv` and `windowsPinnedSyncArgv` strictly below `POWERSHELL_ENCODED_MAX = 8000`: + +```diff + const OCX_CANDIDATES = String.raw`$u=$env:USERPROFILE;$c=@("$u\.opencodex-desktop\bin\ocx.exe","$u\.local\share\goat\opencodex\current\ocx.exe","$u\.local\bin\ocx.exe") + if($env:LOCALAPPDATA){$c+="$env:LOCALAPPDATA\Programs\OpenCodex\ocx.exe"} + if($env:ProgramFiles){$c+="$env:ProgramFiles\OpenCodex\ocx.exe"} + $c+=@((Get-Command ocx.exe -Type Application -EA 0).Source) ++$c+=@("$u\.local\bin\ocx-child.exe","$u\.cache\opencodex\bin\ocx-child.exe") ++if($env:LOCALAPPDATA){$c+="$env:LOCALAPPDATA\opencodex\bin\ocx-child.exe"} ++$c+=@((Get-Command ocx-child.exe -Type Application -EA 0).Source) + `; +``` + +### 1.3 MODIFY `src/server/management/link-enrollment.ts` + +In `apply()` (`src/server/management/link-enrollment.ts:133-158`), import `isDetachedEphemeralChildConfig` from `../../link/ephemeral-child`: when `isDetachedEphemeralChildConfig(childConfig)` is `true`, require `before.code !== 0` (refusing `409 child_restart_unsupported` if any foreign/existing listener occupies `childPort`) and tailor the `remoteMayBeConnected` failure `recovery` hint to `"Run ocx-child detach (or ocx disconnect) on the Child before retrying."` so operators are never told to run `ocx restart` after `disconnect`; otherwise keep the exact existing `isChildHealth(beforeHealth, "standalone", childPort)` gate and recovery string for full `ocx`: + +```diff ++import { isDetachedEphemeralChildConfig } from "../../link/ephemeral-child"; + ... ++ let ephemeralChild = false; + let remoteMayBeConnected = false, phase = "remote_port"; + const failed = async (response: Response): Promise => { + const compensation = record ? await compensateNewLink(ctx, state, record) : null; + const outcome = compensation ?? response; + if (!remoteMayBeConnected) return outcome; + const payload = await outcome.json() as Record; +- return Response.json({ ...payload, residual: true, recovery: "Run ocx disconnect and ocx restart on the Child before retrying." }, { status: outcome.status }); ++ const recovery = ephemeralChild ++ ? "Run ocx-child detach (or ocx disconnect) on the Child before retrying." ++ : "Run ocx disconnect and ocx restart on the Child before retrying."; ++ return Response.json({ ...payload, residual: true, recovery }, { status: outcome.status }); + }; + ... + if (remotePlatform === "posix" && (await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409); + const before = await run(remoteChildHttpArgv(childPort, "healthz", LINK_RELAY_VERIFY_MS, remotePlatform)); +- const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null; +- if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409); ++ ephemeralChild = isDetachedEphemeralChildConfig(childConfig); ++ if (ephemeralChild) { ++ if (before.code === 0) return fail("child_restart_unsupported", "The Child's target port is already in use; disconnect or free the port before linking.", 409); ++ } else { ++ const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null; ++ if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409); ++ } +``` + +Add `native/ocx-child` source artifacts to `"files"` and add scripts `build:ocx-child` and `test:ocx-child`: + +```diff + "native/remote-workspace-helper/Cargo.toml", + "native/remote-workspace-helper/Cargo.lock", + "native/remote-workspace-helper/src", ++ "native/ocx-child/Cargo.toml", ++ "native/ocx-child/Cargo.lock", ++ "native/ocx-child/src", + "LICENSE" + ... + "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", ++ "build:ocx-child": "cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml", ++ "test:ocx-child": "cargo test --locked --manifest-path native/ocx-child/Cargo.toml", +``` + +### 1.5 NEW Tests & Dual Test-Layout Registrations + +Register all three new test files in `scripts/test-layout/layout-clients.json` and `tests/fixtures/test-layout-expected.json`: + +1. `tests/clients/link-ephemeral-child-discovery.test.ts` (`"clients"`): POSIX & Windows candidate discovery precedence (`ocx` beats `ocx-child`; fallback selects `ocx-child`), Windows encoded PowerShell `< 8000` chars, and `OCX_PROTOCOL_COMPAT_VERSION` (`>= 2.93.0`) compatibility check via `classifyVersionProbe`. +2. `tests/server/link-enrollment-ephemeral-child.test.ts` (`"server"`): Home `probe -> confirm-host -> apply` and `DELETE /api/link/:id` across clean detached `ocx-child`, already-attached `ocx-child` (`409`), busy-port foreign listener (`409`), and full-`ocx` standalone Child (`202`). +3. `tests/clients/link-ocx-child-e2e.test.ts` (`"clients"` — `link-*` prefix per `seeds.json`): End-to-end integration driving the real compiled `ocx-child` Rust binary against a real in-process Home `hub-link` + management server (`RUSTUP_HOME` / `CARGO_HOME` preserved from `OCX_REAL_HOME` per `#7884`): enroll -> SSE inference relay with caller credential stripping -> Home `ocx sync` fan-out updating catalog & Home Codex settings -> daemon rollover (`startedAt >= appliedAt`) + immediate `child-parity` POST verified on Home `GET /api/link/status` -> Child `status --json` -> `--expect-link` mismatch exit `5` (`child-link-mismatch:other_link`) -> `#1798` comment-stripped `DELETE /api/link/:id` & offline `detach` -> byte-accurate restoration and zero running `ocx-child` process. + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **Foreign listener occupying detached `ocx-child` port**: Triggered in `link-enrollment-ephemeral-child.test.ts` -> observed `409 child_restart_unsupported` before `issueKey`. +2. **Full `ocx` precedence over `ocx-child`**: Triggered in `link-ephemeral-child-discovery.test.ts` -> observed `ocx` selected when runnable, `ocx-child` selected when `ocx` missing/broken. +3. **Post-sync runtime rollover & OCX-6 parity**: Triggered in `ocx-child-e2e.test.ts` -> observed `startedAt >= appliedAt` and Home `/api/link/status` `parity.appliedCodexSettingsDigest` matching Home's new digest. +4. **Wrong `--expect-link` fan-out skip**: Triggered in `ocx-child-e2e.test.ts` -> observed `classifySyncResult` returning `{ state: "skipped", reason: "child-link-mismatch:other_link" }`. + +## 3. Phase Verifiers + +- `bun test tests/clients/link-ephemeral-child-discovery.test.ts tests/server/link-enrollment-ephemeral-child.test.ts tests/clients/link-ocx-child-e2e.test.ts tests/server/link-enrollment.test.ts tests/clients/link-pinned-sync-argv.test.ts tests/clients/link-ssh-argv.test.ts tests/clients/link-ssh-argv-discovery.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/lab/core-link-boundary.test.ts tests/lab/core-lab-boundary.test.ts` (exit `0`) +- `bun run typecheck` (exit `0`) +- `bun run privacy:scan` (exit `0`) diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md b/devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md new file mode 100644 index 00000000000..f52661835f4 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md @@ -0,0 +1,52 @@ +# `wp4` — Source-of-Truth Documentation, Full Verification Matrix, & PR Publication + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp3` D at `a49f471b98`): `wp3` completed Home discovery, detached `ocx-child` enrollment preflight, `package.json` scripts, and the compiled-binary E2E suite (`104 pass, 0 fail`). Revalidated `040_wp4_docs_gates_and_pr.md` against `a49f471b98`: all referenced repository paths are tracked in git and `INV-LINK-CHILD-01` is present in `tests/clients/link-ocx-child-conformance.test.ts:1`. Architect consultation: covered by `ARCH-07` (`ALIGNED` on `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp4` (depends on `wp3`; satisfies `c-5`) +- **Header dependencies**: + - References implemented paths `native/ocx-child/Cargo.toml`, `native/ocx-child/src/lib.rs`, `src/link/ephemeral-child.ts`, `src/link/ssh-argv.ts`, `src/link/remote-windows.ts`, `src/server/management/link-enrollment.ts`, `tests/fixtures/link/ocx-child-conformance.json`, `tests/clients/link-ocx-child-conformance.test.ts`, and `tests/clients/link-ocx-child-e2e.test.ts`. +- **Scope**: + - **IN**: `structure/manifest.json` (MODIFY), `structure/remote-link.md` (MODIFY), `structure/overview.md` (MODIFY), `structure/INDEX.md` (REGENERATE via `bun run structure:index`), `docs-site/src/content/docs/guides/remote-link.md` (MODIFY), `docs-site/src/content/docs/reference/cli/lifecycle.md` (MODIFY), `devlog/_plan/261107_ocx_child_ephemeral_link/STATUS.md` (NEW/UPDATE), git push + GitHub PR targeting `dev` (unmerged). + - **OUT**: Merging the PR (`authority.merge = false`), force-pushing, manual `lane=all` CI dispatch. + +## 1. Exact File Changes + +### 1.1 MODIFY `structure/manifest.json`, `structure/remote-link.md`, `structure/overview.md`, & Regenerate `structure/INDEX.md` + +- In `structure/manifest.json` (`remote-link.md` entry): add `"native/ocx-child/"` and `"src/server/management/link-enrollment.ts"` to `documents`. +- In `structure/remote-link.md` (185/600 lines): add present-tense contract section `## Ephemeral Rust Child (ocx-child)` naming `native/ocx-child/Cargo.toml`, `native/ocx-child/src/lib.rs`, `src/link/ephemeral-child.ts`, `src/server/management/link-enrollment.ts`, `tests/fixtures/link/ocx-child-conformance.json`, `tests/clients/link-ocx-child-conformance.test.ts`, and `tests/clients/link-ocx-child-e2e.test.ts` (all staged in git before running `bun run structure:check`). +- In `structure/overview.md` (319/600 lines): register bound invariant `INV-LINK-CHILD-01` with `Enforced by \`tests/clients/link-ocx-child-conformance.test.ts\`` and include `// INV-LINK-CHILD-01` in `tests/clients/link-ocx-child-conformance.test.ts`. +- Run `bun run structure:index` and verify `bun run structure:check` exits `0`. + +### 1.2 MODIFY `docs-site/src/content/docs/guides/remote-link.md` & `reference/cli/lifecycle.md` + +- Document lightweight ephemeral Child hosts (`ocx-child`), candidate discovery fallback order, clean detached enrollment, `ocx sync` fan-out parity, and 100% offline `ocx-child detach` / `disconnect` with zero background daemon. +- Validate `cd docs-site && bun install --frozen-lockfile && bun run build` exits `0`. + +### 1.3 Full Verification Matrix, Push, & PR Publication + +1. Run fresh verification suite (serializing heavy Bun runs with `/usr/bin/lockf -k /tmp/ocx-heavy-test.lock`): + - `cargo fmt --manifest-path native/ocx-child/Cargo.toml --check` + - `cargo clippy --locked --manifest-path native/ocx-child/Cargo.toml --all-targets -- -D warnings` + - `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` + - `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` (record exact byte size) + - `bun run typecheck` + - `bun run structure:check` + - `bun run skill:surface:check` + - `bun run privacy:scan` + - Focused Bun suites + `bun run test:changed` + - `cd docs-site && bun install --frozen-lockfile && bun run build` +2. Push `feat/ocx-child-ephemeral-link` to `origin`. +3. Write PR body to a `mktemp` file filling every section of `.github/PULL_REQUEST_TEMPLATE.md` (`## Summary`, `## Security Review Summary`, `## Verification`, `## Checklist`) and open PR targeting `dev` via `gh pr create --base dev --head feat/ocx-child-ephemeral-link --title ... --body-file ...`. +4. Link PR immediately via `goat_call { path: "pull_request.link", args: { url } }`. +5. Inspect exact-head checks; if pending/running, arm `goat_call { path: "pull_request.watch", args: { url } }` and yield cleanly without sleep-polling; once green, run `cxc loop pr-record --session 01a1b16f-bd9a-7a92-b78e-0ed532a2a346 --pr `, call `goat_call { path: "pull_request.unwatch", args: { url } }`, and leave PR unmerged. + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **`structure:check` git-index verification**: Stage all new files before `bun run structure:check` and verify `INV-LINK-CHILD-01` binding passes. +2. **Privacy scan on push range**: Run `bun run privacy:scan` across all committed files and verify zero findings. + +## 3. Phase Verifiers + +- All 10 commands in Section 1.3 exit `0`. +- Exact-head GitHub PR checks succeed and `cxc loop pr-record` succeeds. diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md b/devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md new file mode 100644 index 00000000000..e9e695064f1 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md @@ -0,0 +1,38 @@ +# `wp5` — PR #8237 Codex Review Fixes (Write-Ahead Journal, Multiline TOML Tracking, & Feature Restoration) + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp4` D at `fec8761583`): PR #8237 opened against `dev` with 13 CI checks green (`gates`, `structure gate`, `docs site build`, `docker smoke`, `npm-global`, `enforce-target`, `hygiene`, etc.) and received 3 valid automated Codex review findings on `native/ocx-child/` (`PRRT_kwDOS-0Gi860orZz`, `PRRT_kwDOS-0Gi860orZ4`, `PRRT_kwDOS-0Gi860orZ7`). Architect consultation: preserves `ARCH-03` & `ARCH-06` (`000_roadmap.md` §3). + +- **Phase ID**: `wp5` (depends on `wp4`; satisfies `c-5`) +- **Header dependencies**: + - Updates `ChildJournalV1` & `apply_attach_or_sync` & `offline_detach` (`native/ocx-child/src/journal.rs`), `ChildInjectedLedger` & `mark_structural_lines` & `upsert_table_bool` & `strip_features_ledger` (`native/ocx-child/src/toml_surgeon.rs`), and adds shared conformance vectors in `tests/fixtures/link/ocx-child-conformance.json` + `tests/clients/link-ocx-child-conformance.test.ts` + `native/ocx-child/tests/conformance.rs`. +- **Scope**: + - **IN**: `native/ocx-child/src/journal.rs` (MODIFY), `native/ocx-child/src/toml_surgeon.rs` (MODIFY), `native/ocx-child/tests/conformance.rs` (MODIFY), `tests/fixtures/link/ocx-child-conformance.json` (MODIFY), `tests/clients/link-ocx-child-conformance.test.ts` (MODIFY), `devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md` (MODIFY). + - **OUT**: Merging PR #8237. + +## 1. Exact Fixes + +1. **Finding #1 (`journal.rs:296`, P1 — Write-ahead recovery journal before overwriting Codex files)**: + - Split `prepare_codex_models_cache(cache_path, catalog_models) -> Result<(Option>, String), String>` so the target `models_cache.json` bytes and SHA-256 digest are computed *before* mutating any file in `$CODEX_HOME`. + - Call `write_journal(&paths.journal_file, &journal)?` **first** (before writing `catalog_path`, `catalog_backup_file`, `cache_path`, and `config_path`). + - Preserve each file's last known-owned on-disk SHA-256 (`previous_injected_config_sha256`, `previous_catalog_sha256`, `previous_models_cache_sha256`) across consecutive interrupted syncs without adopting external edits as owned, and treat `pre_attach_config_sha256` / `previous_*_sha256` under `untouched_by_external_writer` as `ExactRestore` during `offline_detach` (removing Child-created `models_cache.json` even if a sync interrupted before the next cache write). +2. **Finding #2 (`toml_surgeon.rs:199`, P1 — Multiline TOML string/collection boundary tracking)**: + - Port `markStructuralLines` from `src/codex/toml-source-lines.ts:158-223` into `mark_structural_lines(lines: &[&str]) -> Vec` tracking `"""..."""` (with backslash escapes and 3–5 closing quotes), `'''...'''` (3–5 closing quotes), `[...]` depth, and `{...}` depth. + - Gate every structural scan (`root_end_index`, `detect_unowned_root_routing_conflict`, `external_codex_model_provider`, `insert_root_lines`, `strip_owned_root_urls`, `strip_owned_web_search`, `ensure_root_web_search`, `set_root_catalog_path`, `strip_opencodex_catalog_path`, `upsert_table_bool`, `apply_subagent_defaults`, `strip_subagent_defaults`, `strip_features_ledger`, `surgical_detach_config`, and blank-line collapsing) on `structural[i] == true` so `[Important]` or `openai_base_url = ...` inside multiline `developer_instructions` is untouched data. +3. **Finding #3 (`toml_surgeon.rs:709`, P2 — Restore overwritten user `[features]` values on surgical detach)**: + - Add `#[serde(default)] pub prior_user_fast_mode_line: Option` and `#[serde(default)] pub prior_user_multi_agent_v2_line: Option` to `ChildInjectedLedger`. + - When `upsert_table_bool` overwrites an existing `fast_mode` or `multi_agent_v2` line in `[features]`, return `Option` of the overwritten line; on first write (`prior_ledger` had `wrote_fast_mode == None`), store that line in `prior_user_fast_mode_line` (preserving it across subsequent `sync` calls). + - In `strip_features_ledger`, when the current line in `[features]` still matches `wrote_fast_mode` / `wrote_multi_agent_v2`, replace it with `prior_user_*_line` if `Some(...)` (and drop it only when `None`), preserving any post-attach user modification. + +## 2. Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- Add 2 new shared golden vectors to `tests/fixtures/link/ocx-child-conformance.json` (verified in both `bun test` and `cargo test`): + 1. `multiline_developer_instructions_bracket_line`: `developer_instructions = """\n[Important]\nKeep literal\n"""\n` -> attach inserts root keys *after* the closing `"""` (not inside the string!), and surgical detach after `#1798` comment strip restores cleanly. + 2. `restore_overwritten_user_features_on_surgical_detach`: pre-attach `[features]\nfast_mode = false\nmulti_agent_v2 = true\n` -> attach sets `fastMode: true, multiAgentMode: "v1"` -> user appends `[extra]\nk = 1\n` -> surgical detach restores `fast_mode = false` and `multi_agent_v2 = true` while keeping `[extra]`. +- Add `multiline_features_agents_and_blank_lines_surgical` to shared conformance vectors (`[features]`, `fast_mode = true`, repeated blank lines, and `[agents]` inside multiline string with `fastMode` and `subagentDefaults` active). +- Add deterministic `JournalWriteFault` (`AfterJournalWrite` and `AfterCatalogWrite`) consecutive-failed-sync tests in `native/ocx-child/tests/conformance.rs` verifying `ExactRestore` of `config.toml` and `pre_attach_catalog` plus removal of previous injected `models_cache.json`. + +## 3. Verifiers + +- `cargo fmt --manifest-path native/ocx-child/Cargo.toml --check` & `cargo clippy --locked --manifest-path native/ocx-child/Cargo.toml --all-targets -- -D warnings` +- `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` & `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` +- `bun test tests/clients/link-ocx-child-conformance.test.ts tests/clients/link-ocx-child-e2e.test.ts` & `bun run typecheck` & `bun run privacy:scan` diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index fed3b47ac71..ea8ec2c1c95 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -167,9 +167,9 @@ La section **OAuth reliability** indique si le stockage des identifiants est acc Sur un Home Remote Link, après la validation et l’enregistrement d’un catalogue à jour, `ocx sync` synchronise aussi les Enfants liés admissibles. `--local-only` désactive cette propagation. `--json` produit un seul document `ocx.sync.fanout/1` contenant le résultat du Home et l’état de chaque Enfant ; un Enfant ignoré indique la raison. Le code de sortie non nul du Home est renvoyé sans modification. Si le Home réussit, un Enfant connecté ignoré pendant la propagation, en échec ou en délai dépassé, ou un registre de liens illisible, entraîne le code de sortie 6. Les exclusions intentionnelles, comme les liens initiés par le client et `--local-only`, conservent leur comportement ; les Enfants connectés ignorés reçoivent une indication `hint`. En mode texte, le registre illisible produit un message sans chemin. Le code 7 est réservé à une mise à jour en cours. Si la synchronisation du Home lève une erreur en mode JSON, stdout conserve un seul document et stderr fournit un diagnostic assaini. -La propagation ne demande jamais le redémarrage de l’application Codex ni d’un app-server sur un Enfant. Pour cela, utilisez GOAT ou exécutez `ocx sync --restart-codex` sur l’Enfant. La synchronisation épinglée de l’Enfant peut néanmoins redémarrer son propre runtime client OpenCodex après l’application de réglages Home modifiés. Si le Home interrompt son processus SSH local après un délai dépassé, une synchronisation distante déjà lancée peut continuer sur l’Enfant. +La propagation ne demande jamais le redémarrage de l’application Codex ni d’un app-server sur un Enfant. Pour cela, utilisez GOAT, exécutez `ocx sync --restart-codex` sur un Enfant `ocx` complet, ou relancez séparément Codex Desktop et les processus `app-server` sur un hôte léger `ocx-child`. La synchronisation épinglée de l’Enfant peut néanmoins redémarrer son propre runtime client OpenCodex après l’application de réglages Home modifiés. Si le Home interrompt son processus SSH local après un délai dépassé, une synchronisation distante déjà lancée peut continuer sur l’Enfant. -> **Enfant Remote Link :** `ocx sync` applique aussi les réglages Codex du Home et, si le runtime client a démarré avant cette application, le redémarre même sans option de redémarrage. Si le redémarrage échoue, l'ancien runtime continue de servir et la commande se termine avec un code non nul. Les champs correspondants de `ocx status --json` et `ocx link status --json` sont décrits dans [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (en anglais). +> **Enfant Remote Link :** sur un enfant `ocx` complet, `ocx sync` applique aussi les réglages Codex du Home et redémarre le runtime client s'il a démarré avant cette application ; si ce redémarrage échoue, l'ancien runtime continue de servir et la commande quitte avec un code non nul. Sur un hôte léger `ocx-child`, `ocx-child sync` démarre `ocx-child serve` s'il est absent, le relance lorsque `runtimeStartedAt < lastApplyAt` et publie la parité au mieux hors mode `stale` (un échec de relance arrête l'ancien relais et quitte avec un code non nul sans redémarrer Codex Desktop ni `app-server`, qu'il faut relancer séparément). Les champs de `ocx status --json` (ou `ocx-child status --json`) sur l'enfant et `ocx link status --json` sur le Home sont décrits dans [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (en anglais). Un redémarrage explicitement demandé a également lieu après une modification de `config.toml` validée et enregistrée, même si les octets du catalogue et du cache restent identiques. Une opération sans changement de configuration, un refus, une validation préalable ou une écriture annulée ne le déclenche pas. Sans option de redémarrage, sync ne redémarre jamais Codex automatiquement. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 25aa0d8fd11..6715ea81b1d 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -195,9 +195,17 @@ On the Home, run `ocx link probe child-a --json` to inspect the offered SSH fing Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port and expose bound restart support. POSIX Children need curl; Windows uses a bounded loopback HTTP request through .NET. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows x64 Children use encoded PowerShell commands without consuming key stdin. +### Ephemeral Child machines (`ocx-child`) + +For disposable SSH devboxes, containers and lightweight cloud VMs where installing the full Bun OpenCodex runtime is unnecessary, place the standalone Rust `ocx-child` binary (`cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml`) in `~/.local/bin/ocx-child`, `~/.cache/opencodex/bin/ocx-child` (or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` on Windows) or on `PATH`. Before enrollment, Home probes every full `ocx` candidate first and falls back to `ocx-child` when no runnable full `ocx` is present; once `$OPENCODEX_HOME/child/state.json` exists, remote commands keep targeting `ocx-child` until it detaches. + +Unlike a full `ocx` Child, a clean detached `ocx-child` host needs no pre-running background daemon before `ocx link add`: Home refuses any HTTP 200 `/healthz` listener on the target port during preflight, pipes the link credential over SSH stdin (`ocx-child connect` verifies TCP bind availability and Home compensates if the port is occupied), starts `ocx-child serve`, and confirms an authenticated `/v1/catalog` relay. Home `ocx sync` fan-out (`sync --expect-link --json`) refreshes the Child's catalog and `models_cache.json` (retaining the last cached catalog as `stale` if Home is unreachable), ensures `ocx-child serve` is running at or after `lastApplyAt`, and posts OCX-6 parity on a best-effort basis when fresh (while `ocx-child serve` also reports periodically in the background). Running `ocx link revoke` on Home disconnects the Child, revokes its key, and removes the Home record; running offline `ocx-child detach` or `ocx-child disconnect` directly on the Child stops `ocx-child serve` with zero leftover daemon and restores `~/.codex` locally, so also run `ocx link revoke --link-id ` (or `ocx link revoke --link-id --force --yes` if the Child is unreachable) on Home to remove the Home link record and key. + +Keep `OPENCODEX_HOME` and `CODEX_HOME` pointed at the same directories throughout the connection. If detach reports a restore or shutdown error, keep the Child's recovery files and resolve the error before retrying. + ### Windows computers -A Windows x64 Child needs OpenSSH Server, Windows PowerShell and the standalone `ocx.exe`. Home recognizes Windows during the probe, records it with the link, and runs every Child command through PowerShell over the same pinned SSH connection, whether the SSH default shell is cmd or PowerShell. The link key still travels only on standard input. Closing the SSH connection ends the remote `ocx` command. If Windows PowerShell on the Child cannot load the helper that ties `ocx.exe` to the connection, for example under Constrained Language Mode, the command is refused and nothing runs. If Home cannot tell which platform the Child runs, it stops before issuing a link key. +A Windows x64 Child needs OpenSSH Server, Windows PowerShell and either standalone `ocx.exe` or lightweight `ocx-child.exe`. Home recognizes Windows during the probe, records it with the link, and runs every Child command through PowerShell over the same pinned SSH connection, whether the SSH default shell is cmd or PowerShell. The link key still travels only on standard input. Closing the SSH connection ends the selected remote Child command. If Windows PowerShell on the Child cannot load the helper that contains the selected executable in a kill-on-close Job, for example under Constrained Language Mode, the command is refused and nothing runs. If Home cannot tell which platform the Child runs, it stops before issuing a link key. Run `ocx link cleanup` on Home to inspect recorded Child platforms without changing them. Current Home releases disconnect Windows Children through PowerShell. If a remote disconnect fails, resolve the reported shell or installation error and retry; **Remove here only** remains an explicit recovery action that can leave the Child needing a local disconnect. diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 260999cd3d2..78139f06643 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -174,9 +174,9 @@ ocx status --json Remote Link の Home では、最新のカタログが正常にコミットされた後、`ocx sync` は対象となるリンク済み Child にも同期を伝播します。`--local-only` はこの伝播を無効にします。`--json` は Home の結果と各 Child の状態を含む単一の `ocx.sync.fanout/1` 文書を出力し、スキップされた Child の理由も示します。Home 自体の終了コードが 0 以外の場合は、そのコードを変更せずに返します。Home が成功した場合、接続された Child の同期スキップ、失敗、タイムアウト、またはリンクストアの読み取り不能は終了コード 6 となります。クライアント主導リンクや `--local-only` などの意図的なスキップは従来どおりです。スキップされた接続済み Child には対処方法を示す `hint` が付きます。リンクストアを読み取れない場合、テキスト表示ではパスを含まないエラーを出します。コード 7 は更新処理中のために予約されています。JSON モードで Home の同期が例外を送出した場合も、stdout は単一の結果文書を保ち、stderr に無害化された診断を出力します。 -伝播は Child の Codex アプリや app-server の再起動を要求しません。必要な場合は GOAT を使うか、その Child で `ocx sync --restart-codex` を実行してください。ただし、Child の固定先同期は、変更された Home 設定を適用した後、自身の OpenCodex クライアントランタイムを再起動することがあります。Home がタイムアウトしてローカルの SSH プロセスを停止しても、すでに開始されたリモート同期は Child 上で続くことがあります。 +伝播は Child の Codex アプリや app-server の再起動を要求しません。必要な場合は GOAT を使うか、フル `ocx` Child では `ocx sync --restart-codex` を実行してください(軽量 `ocx-child` Child では Codex デスクトップと `app-server` を別途再起動してください)。ただし、Child の固定先同期は、変更された Home 設定を適用した後、自身の OpenCodex クライアントランタイムを再起動することがあります。Home がタイムアウトしてローカルの SSH プロセスを停止しても、すでに開始されたリモート同期は Child 上で続くことがあります。 -> **Remote Link Child:** `ocx sync` は Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は、再起動フラグなしでもランタイムを再起動します。再起動に失敗した場合は以前のランタイムが動作し続け、コマンドは 0 以外で終了します。`ocx status --json` と `ocx link status --json` の関連フィールドは [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英語)を参照してください。 +> **Remote Link Child:** フル `ocx` Child では `ocx sync` が Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は再起動フラグなしでも再起動します。再起動に失敗しても以前のランタイムは動作し続け、コマンドは 0 以外で終了します。軽量 `ocx-child` ホストでは `ocx-child sync` が未起動時に `ocx-child serve` を起動し、`runtimeStartedAt < lastApplyAt` のときにローテーションして非 `stale` 同期時にベストエフォートで parity を報告します(ローテーション失敗時は旧リレー停止後に 0 以外で終了するため再実行で復旧し、Codex デスクトップや `app-server` の再起動は別途行ってください)。Child の `ocx status --json`(または `ocx-child status --json`)と Home の `ocx link status --json` の関連フィールドは [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英語)を参照してください。 再起動フラグを明示した場合、カタログとキャッシュのバイト列が変わらなくても、`config.toml` の変更が正常にコミットされれば再起動します。設定だけを扱う操作が変更なし、拒否、事前検証、ロールバックとなった場合は再起動しません。再起動フラグがなければ、sync が Codex を自動的に再起動することはありません。 diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index f116b27709b..bb7543e56e5 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -262,9 +262,9 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 Remote Link Home에서는 최신 카탈로그가 성공적으로 커밋된 뒤 `ocx sync`가 조건을 충족하는 연결된 Child에도 동기화를 전파합니다. `--local-only`는 이 전파를 끕니다. `--json`은 Home 결과와 각 Child의 상태를 담은 단일 `ocx.sync.fanout/1` 문서를 출력하며, 건너뛴 연결된 Child에는 이유와 조치 방법(`hint`)을 표시합니다. Home 자체의 종료 코드가 0이 아니면 그 코드를 그대로 반환합니다. Home이 성공한 경우 연결된 Child의 동기화를 건너뛰거나 Child 실패, 시간 초과 또는 링크 저장소를 읽을 수 없는 경우 종료 코드 6을 반환합니다. 클라이언트 주도 링크와 `--local-only` 등 의도적으로 건너뛰는 경우는 기존 종료 동작을 유지합니다. 링크 저장소를 읽을 수 없으면 텍스트 모드에서 경로가 없는 오류를 출력합니다. 코드 7은 업데이트 진행 중을 위해 예약되어 있습니다. JSON 모드에서 Home 동기화가 예외를 던져도 stdout에는 결과 문서 하나만 출력하고 stderr에는 안전하게 변환된 진단을 출력합니다. -전파는 Child의 Codex 앱이나 app-server 재시작을 요청하지 않습니다. 재시작이 필요하면 GOAT를 사용하거나 해당 Child에서 `ocx sync --restart-codex`를 실행하세요. 다만 Child의 고정 대상 동기화는 변경된 Home 설정을 적용한 뒤 자체 OpenCodex 클라이언트 런타임을 재시작할 수 있습니다. Home이 시간 초과로 로컬 SSH 프로세스를 중지하더라도 이미 시작된 원격 동기화는 Child에서 계속될 수 있습니다. +전파는 Child의 Codex 앱이나 app-server 재시작을 요청하지 않습니다. 재시작이 필요하면 GOAT를 사용하거나, 전체 `ocx` Child에서는 `ocx sync --restart-codex`를 실행하고 경량 `ocx-child` 호스트에서는 Codex 데스크톱 앱과 `app-server`를 별도로 재시작하세요. 다만 Child의 고정 대상 동기화는 변경된 Home 설정을 적용한 뒤 자체 OpenCodex 클라이언트 런타임을 재시작할 수 있습니다. Home이 시간 초과로 로컬 SSH 프로세스를 중지하더라도 이미 시작된 원격 동기화는 Child에서 계속될 수 있습니다. -> **Remote Link Child:** `ocx sync`는 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 런타임을 재시작합니다. 재시작이 실패하면 이전 런타임이 계속 서비스하고 명령은 0이 아닌 코드로 끝납니다. `ocx status --json`과 `ocx link status --json`의 관련 필드는 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(영문)를 참고하세요. +> **Remote Link Child:** 전체 `ocx` Child에서는 `ocx sync`가 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 재시작하고 실패하더라도 이전 런타임이 계속 서비스한 채 0이 아닌 코드로 끝납니다. 경량 `ocx-child` 호스트에서는 `ocx-child sync`가 릴레이가 없을 때 `ocx-child serve`를 시작하고 `runtimeStartedAt < lastApplyAt`이면 교체하며 `stale`이 아닌 동기화에서 최선을 다해 parity를 보고합니다(교체 실패 시 이전 릴레이가 멈춘 상태로 0이 아닌 코드로 끝나며, Codex 데스크톱 앱과 장기 실행 `app-server` 프로세스는 별도로 재시작해야 합니다). Child의 `ocx status --json`(또는 `ocx-child status --json`)과 Home의 `ocx link status --json` 필드는 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(영문)를 참고하세요. 재시작 플래그를 명시하면 카탈로그와 캐시 바이트가 같아도 `config.toml` 변경이 성공적으로 커밋된 뒤 재시작합니다. 설정만 다루는 작업이 변경 없음, 거부, 사전 검증 또는 롤백으로 끝나면 재시작하지 않습니다. 재시작 플래그가 없으면 sync는 Codex를 자동으로 재시작하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index bf4d73462ea..dcc9b2da8f6 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -606,8 +606,8 @@ for an update in flight. If the Home sync throws in JSON mode, stdout still contains one result document and stderr gives a sanitized failure message. -Fan-out never requests a Codex app or app-server restart on a Child. Use GOAT or run -`ocx sync --restart-codex` on that Child if a Codex restart is needed. The Child's pinned sync may +Fan-out never requests a Codex app or app-server restart on a Child. Use GOAT, run +`ocx sync --restart-codex` on a full `ocx` Child, or restart the Codex desktop app and long-lived `app-server` processes separately on a lightweight `ocx-child` Child if a Codex restart is needed. The Child's pinned sync may still restart its own OpenCodex client runtime after applying changed Home settings. If the Home times out and stops its local SSH process, a remote sync already running may continue on the Child. @@ -1363,6 +1363,8 @@ starts and then once a minute; the Home keeps reports in memory only, so after a fields read `null` until the next report. A Child whose runtime started before `lastApplyAt` is running an older runtime than its last apply. +Lightweight `ocx-child` Child hosts run `ocx-child sync [--expect-link ] [--json]`, `ocx-child status [--json]` (while `ocx link status --json` remains on the Home), and `ocx-child disconnect` / `ocx-child detach`: on an attached `ocx-child` host (which runs without a system service manager), a successful `ocx-child sync` starts `ocx-child serve` if no relay is running even when settings are unchanged, rolls it over when `runtimeStartedAt < lastApplyAt`, and posts a best-effort parity report on non-stale syncs. `ocx-child sync` accepts `--restart-codex` / `--restart-app-server-only` for flag compatibility without restarting Codex processes (restart the Codex desktop app and long-lived `app-server` processes separately if needed), and `ocx-child disconnect` / `ocx-child detach` restores `~/.codex` files offline and stops `ocx-child serve` with zero background daemon left running. Only one `ocx-child` install may manage a given `CODEX_HOME` at a time: lifecycle commands lock `CODEX_HOME/.ocx-child.lock` (kept permanently) and record the owning `OPENCODEX_HOME` in `CODEX_HOME/.ocx-child-owner.json` until `ocx-child detach` completes, so an `ocx-child attach` or `sync` from a different `OPENCODEX_HOME` sharing that `CODEX_HOME` exits nonzero without changing any file and names the owning home to detach first. + ## Remote Link Child attestations A peer that sends work through a Remote Link Child can check two more facts in the Child's @@ -1387,10 +1389,11 @@ empty after a Home restart. `GET /api/link/status` includes the field only when After `ocx sync` applies the Home's settings on a Child, it restarts the Child's client runtime when that runtime started before `lastApplyAt`, using the same restart as `ocx restart`. A runtime that -already started after the apply is left alone, nothing is started when no client runtime is -running, and a failed restart exits nonzero while the previous runtime keeps serving; run -`ocx sync` or `ocx restart` again to retry. Two applies on one Child never interleave: a second -`ocx sync` while one is applying fails fast and changes nothing. +already started after the apply is left alone, on a full `ocx` Child nothing is started when no client runtime is +running (unlike an attached `ocx-child`, which starts `ocx-child serve` when absent), and on a full `ocx` Child a failed restart exits nonzero while the previous runtime keeps serving (whereas on `ocx-child`, rollover stops the prior `ocx-child serve` before binding the replacement, so a failed rollover exits nonzero with no relay running until `ocx-child sync` or `ocx-child restart` retries). Two applies on one Child never interleave. On a full `ocx` Child, a second `ocx sync` while one is +applying fails fast and changes nothing. On `ocx-child`, a second `ocx-child sync --expect-link ` +(the form the Home's sync fan-out runs) exits `5` with reason `pending_operation` and changes +nothing, while a plain `ocx-child sync` waits for the first apply to finish and then runs. ## Forced link revoke and remote recovery diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 1f036df2741..e94e0f5218e 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -263,9 +263,9 @@ credential'ы и не выполняет repair. На Home Remote Link после успешной фиксации актуального каталога `ocx sync` также синхронизирует подходящие связанные дочерние машины. `--local-only` отключает эту рассылку. `--json` выводит один документ `ocx.sync.fanout/1` с результатом Home и состоянием каждой дочерней машины; для пропущенной машины указана причина. Ненулевой код выхода самого Home возвращается без изменений. Если Home завершился успешно, пропуск синхронизации подключённой дочерней машины, её сбой или тайм-аут либо недоступное хранилище связей дают код выхода 6. Преднамеренные пропуски, включая связи, созданные клиентом, и `--local-only`, сохраняют прежнее поведение; пропущенная подключённая машина получает подсказку `hint`. В текстовом режиме сообщение о недоступном хранилище не содержит путь. Код 7 зарезервирован для выполняющегося обновления. Если синхронизация Home выбрасывает исключение в режиме JSON, stdout по-прежнему содержит один документ результата, а stderr — безопасно отформатированную диагностику. -Рассылка никогда не запрашивает перезапуск приложения Codex или app-server на дочерней машине. Для этого используйте GOAT или выполните `ocx sync --restart-codex` на самой машине. При этом её синхронизация с закреплённым Home может перезапустить собственный клиентский рантайм OpenCodex после применения изменённых настроек Home. Если Home завершит локальный процесс SSH по тайм-ауту, уже начатая удалённая синхронизация может продолжиться на дочерней машине. +Рассылка никогда не запрашивает перезапуск приложения Codex или app-server на дочерней машине. Для этого используйте GOAT, выполните `ocx sync --restart-codex` на полном Child с `ocx` или перезапустите приложение Codex Desktop и процессы `app-server` отдельно на легковесном хосте `ocx-child`. При этом её синхронизация с закреплённым Home может перезапустить собственный клиентский рантайм OpenCodex после применения изменённых настроек Home. Если Home завершит локальный процесс SSH по тайм-ауту, уже начатая удалённая синхронизация может продолжиться на дочерней машине. -> **Дочерняя машина Remote Link:** `ocx sync` также применяет настройки Codex с Home и, если клиентский рантайм запущен раньше этого применения, перезапускает его даже без флага перезапуска. Если перезапуск не удался, прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. Соответствующие поля `ocx status --json` и `ocx link status --json` описаны в разделе [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (на английском). +> **Дочерняя машина Remote Link:** на полном Child с `ocx` команда `ocx sync` также применяет настройки Codex с Home и перезапускает клиентский рантайм, если он запущен раньше применения; при сбое перезапуска прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. На легковесном хосте `ocx-child` команда `ocx-child sync` запускает `ocx-child serve`, если ретранслятор отсутствует, перезапускает его при `runtimeStartedAt < lastApplyAt` и отправляет отчёт о паритете по принципу best-effort вне режима `stale` (при сбое смены прежний ретранслятор останавливается и команда завершается с ненулевым кодом, а приложение Codex Desktop и долгоживущие процессы `app-server` при необходимости перезапускаются отдельно). Поля `ocx status --json` (или `ocx-child status --json`) на Child и `ocx link status --json` на Home описаны в разделе [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (на английском). Явно запрошенный перезапуск также выполняется после успешного сохранения изменения `config.toml`, даже если байты каталога и кэша не изменились. Операция только с конфигурацией без изменений, отказ, предварительная проверка или отменённая запись не вызывают перезапуск. Без флага перезапуска sync никогда не перезапускает Codex автоматически. diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index bad059a7c0b..18c3b4a5ccd 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -260,9 +260,9 @@ Doctor asla kimlik bilgilerini değiştirmez veya onarımlar uygulamaz. Remote Link Home üzerinde güncel katalog başarıyla kaydedildikten sonra `ocx sync`, uygun bağlı Child makineleri de eşitler. `--local-only` bu dağıtımı kapatır. `--json`, Home sonucunu ve her Child durumunu içeren tek bir `ocx.sync.fanout/1` belgesi yazdırır; atlanan Child için neden de belirtilir. Home'un kendi sıfırdan farklı çıkış kodu değiştirilmeden döndürülür. Home başarılı olduğunda dağıtım sırasında bağlı bir Child atlanırsa, hata verir veya zaman aşımına uğrarsa ya da bağlantı deposu okunamazsa çıkış kodu 6 üretilir. İstemci tarafından başlatılan bağlantılar ve `--local-only` gibi kasıtlı atlamalar önceki davranışı korur; atlanan bağlı Child için `hint` alanında bir öneri verilir. Metin kipindeki okunamayan depo hatası dosya yolu içermez. Kod 7, sürmekte olan bir güncelleme için ayrılmıştır. JSON kipinde Home eşitlemesi hata fırlatırsa stdout yine tek sonuç belgesi içerir ve stderr güvenli biçimde gösterilen bir tanı verir. -Dağıtım, bir Child üzerinde Codex uygulamasının veya app-server'ın yeniden başlatılmasını asla istemez. Bunun için GOAT kullanın veya Child üzerinde `ocx sync --restart-codex` çalıştırın. Bununla birlikte Child'ın sabitlenmiş eşitlemesi, değişen Home ayarlarını uyguladıktan sonra kendi OpenCodex istemci çalışma zamanını yeniden başlatabilir. Home zaman aşımında yerel SSH işlemini durdursa bile başlamış bir uzak eşitleme Child üzerinde devam edebilir. +Dağıtım, bir Child üzerinde Codex uygulamasının veya app-server'ın yeniden başlatılmasını asla istemez. Bunun için GOAT kullanın, tam `ocx` Child üzerinde `ocx sync --restart-codex` çalıştırın veya hafif `ocx-child` makinelerinde Codex masaüstü uygulamasını ve `app-server` süreçlerini ayrıca yeniden başlatın. Bununla birlikte Child'ın sabitlenmiş eşitlemesi, değişen Home ayarlarını uyguladıktan sonra kendi OpenCodex istemci çalışma zamanını yeniden başlatabilir. Home zaman aşımında yerel SSH işlemini durdursa bile başlamış bir uzak eşitleme Child üzerinde devam edebilir. -> **Remote Link Child:** `ocx sync` Home'un Codex ayarlarını da uygular ve istemci çalışma zamanı bu uygulamadan önce başlamışsa, yeniden başlatma bayrağı olmadan da onu yeniden başlatır. Yeniden başlatma başarısız olursa önceki çalışma zamanı hizmet vermeye devam eder ve komut sıfırdan farklı bir kodla çıkar. `ocx status --json` ve `ocx link status --json` içindeki ilgili alanlar için [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (İngilizce) bölümüne bakın. +> **Remote Link Child:** Tam `ocx` Child makinelerinde `ocx sync` Home'un Codex ayarlarını da uygular ve istemci çalışma zamanı bu uygulamadan önce başlamışsa yeniden başlatır; yeniden başlatma başarısız olursa önceki çalışma zamanı hizmet vermeye devam eder ve komut sıfırdan farklı bir kodla çıkar. Hafif `ocx-child` makinelerinde `ocx-child sync`, röle çalışmıyorsa `ocx-child serve` sürecini başlatır, `runtimeStartedAt < lastApplyAt` olduğunda yeniler ve `stale` olmayan eşitlemelerde en iyi çabayla parite bildirir (yenileme başarısız olursa önceki röle durdurulmuş olarak sıfırdan farklı kodla çıkar; Codex masaüstü uygulaması ve uzun ömürlü `app-server` süreçleri gerekirse ayrıca yeniden başlatılmalıdır). Child üzerindeki `ocx status --json` (veya `ocx-child status --json`) ile Home üzerindeki `ocx link status --json` alanları için [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (İngilizce) bölümüne bakın. Açıkça istenen yeniden başlatma, katalog ve önbellek baytları değişmese bile `config.toml` değişikliği başarıyla kaydedildikten sonra da çalışır. Yalnızca yapılandırmayı işleyen bir işlem değişiklik yapmadığında, reddedildiğinde, ön kontrolde kaldığında veya yazma geri alındığında yeniden başlatma tetiklenmez. Yeniden başlatma bayrağı olmadan sync, Codex’i otomatik olarak yeniden başlatmaz. diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 20389b35f57..4ccd0267177 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -170,9 +170,9 @@ ocx status --json 在 Remote Link Home 上,成功提交最新目录后,`ocx sync` 也会向符合条件的已连接 Child 同步。`--local-only` 可关闭此传播。`--json` 输出一个 `ocx.sync.fanout/1` 文档,包含 Home 的结果和每个 Child 的状态;被跳过的 Child 会注明原因。Home 自身的非零退出码会原样返回。Home 成功时,若传播时跳过已连接的 Child,或 Child 失败、超时或链接存储不可读,命令以退出码 6 结束。客户端发起的链接和 `--local-only` 等有意跳过的情况保留原有行为;被跳过的已连接 Child 会附带操作建议 `hint`。文本模式下,链接存储不可读的错误不包含路径。退出码 7 保留给正在进行的更新。JSON 模式下如果 Home 同步抛出异常,stdout 仍只包含一个结果文档,stderr 则输出经过安全转义的诊断信息。 -传播不会请求重启 Child 上的 Codex 应用或 app-server。如需重启,请使用 GOAT 或在该 Child 上运行 `ocx sync --restart-codex`。不过,Child 的固定目标同步在应用已更改的 Home 设置后,仍可能重启它自己的 OpenCodex 客户端运行时。Home 超时并停止本地 SSH 进程后,已开始的远程同步仍可能在 Child 上继续。 +传播不会请求重启 Child 上的 Codex 应用或 app-server。如需重启,请使用 GOAT,在完整 `ocx` Child 上运行 `ocx sync --restart-codex`,或在轻量级 `ocx-child` 主机上单独重启 Codex 桌面应用与 `app-server` 进程。不过,Child 的固定目标同步在应用已更改的 Home 设置后,仍可能重启它自己的 OpenCodex 客户端运行时。Home 超时并停止本地 SSH 进程后,已开始的远程同步仍可能在 Child 上继续。 -> **Remote Link Child:** `ocx sync` 还会应用 Home 的 Codex 设置;如果客户端运行时在该次应用之前启动,即使没有重启参数也会重启运行时。重启失败时,原有运行时继续提供服务,命令以非零状态退出。`ocx status --json` 和 `ocx link status --json` 中的相关字段见 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 +> **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 还会应用 Home 的 Codex 设置并在客户端运行时早于该次应用启动时重启它;重启失败时原有运行时继续提供服务并以非零状态退出。在轻量级 `ocx-child` 主机上,`ocx-child sync` 会在中继未运行时启动 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 时轮换进程,并在非 `stale` 同步时尽力上报 parity(若轮换失败,旧中继已停止且命令以非零状态退出;Codex 桌面应用与长期运行的 `app-server` 进程如需刷新请单独重启)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)与 Home 上的 `ocx link status --json` 相关字段见 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 显式指定重启标志后,即使目录和缓存的字节未变,成功提交 `config.toml` 更改也会触发重启。仅处理配置的操作若没有更改、被拒绝、仅作预检或写入已回滚,则不会触发重启。未指定重启标志时,sync 不会自动重启 Codex。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 2b66dac5690..fd2929371a6 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -162,9 +162,9 @@ ocx status --json 在 Remote Link Home 上成功提交最新目錄後,`ocx sync` 也會向符合條件的已連結 Child 同步。`--local-only` 可停用這個傳播。`--json` 會輸出單一 `ocx.sync.fanout/1` 文件,包含 Home 的結果與各 Child 的狀態;遭略過的 Child 也會顯示原因。Home 自身的非零結束碼會原樣回傳。Home 成功時,若傳播時略過已連結的 Child,或 Child 失敗、逾時或連結儲存區無法讀取,指令以結束碼 6 結束。用戶端發起的連結和 `--local-only` 等刻意略過的情況保留原有行為;遭略過的已連結 Child 會附帶操作建議 `hint`。文字模式下,連結儲存區無法讀取的錯誤不包含路徑。結束碼 7 保留給進行中的更新。JSON 模式下若 Home 同步擲出例外,stdout 仍只包含一份結果文件,stderr 則輸出經安全轉義的診斷訊息。 -傳播不會要求重新啟動 Child 上的 Codex 應用程式或 app-server。若需要重新啟動,請使用 GOAT 或在該 Child 執行 `ocx sync --restart-codex`。不過,Child 的固定目標同步在套用變更後的 Home 設定時,仍可能重新啟動它自己的 OpenCodex 用戶端執行階段。Home 逾時並停止本機 SSH 程序後,已開始的遠端同步仍可能在 Child 上繼續。 +傳播不會要求重新啟動 Child 上的 Codex 應用程式或 app-server。若需要重新啟動,請使用 GOAT,在完整 `ocx` Child 執行 `ocx sync --restart-codex`,或在輕量級 `ocx-child` 主機上單獨重新啟動 Codex 桌面應用程式與 `app-server` 程序。不過,Child 的固定目標同步在套用變更後的 Home 設定時,仍可能重新啟動它自己的 OpenCodex 用戶端執行階段。Home 逾時並停止本機 SSH 程序後,已開始的遠端同步仍可能在 Child 上繼續。 -> **Remote Link Child:** `ocx sync` 也會套用 Home 的 Codex 設定;如果用戶端執行階段在該次套用之前啟動,即使沒有重新啟動旗標也會重新啟動執行階段。重新啟動失敗時,原有執行階段會繼續提供服務,指令以非零狀態結束。`ocx status --json` 與 `ocx link status --json` 的相關欄位請見 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 +> **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 也會套用 Home 的 Codex 設定並在用戶端執行階段早於該次套用啟動時重新啟動它;重新啟動失敗時原有執行階段會繼續提供服務並以非零狀態結束。在輕量級 `ocx-child` 主機上,`ocx-child sync` 會在中繼未執行時啟動 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 時輪替程序,並在非 `stale` 同步時盡力回報 parity(若輪替失敗,舊中繼已停止且指令以非零狀態結束;Codex 桌面應用程式與長期執行的 `app-server` 程序如需重新載入請單獨重新啟動)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)與 Home 上的 `ocx link status --json` 相關欄位請見 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 明確指定重新啟動旗標後,即使目錄和快取的位元組未變,成功提交 `config.toml` 變更也會觸發重新啟動。僅處理設定的操作若沒有變更、遭拒、只做預先檢查或寫入已回復,則不會觸發重新啟動。未指定重新啟動旗標時,sync 不會自動重新啟動 Codex。 diff --git a/native/ocx-child/Cargo.lock b/native/ocx-child/Cargo.lock new file mode 100644 index 00000000000..029c3e17089 --- /dev/null +++ b/native/ocx-child/Cargo.lock @@ -0,0 +1,215 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "opencodex-child" +version = "0.1.0" +dependencies = [ + "base64", + "hmac", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/ocx-child/Cargo.toml b/native/ocx-child/Cargo.toml new file mode 100644 index 00000000000..b315aba2b4e --- /dev/null +++ b/native/ocx-child/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "opencodex-child" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +publish = false + +[[bin]] +name = "ocx-child" +path = "src/main.rs" + +[lib] +name = "ocx_child" +path = "src/lib.rs" + +[dependencies] +base64 = "=0.22.1" +hmac = "=0.12.1" +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +sha2 = "=0.10.9" + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +strip = "symbols" + +[profile.release.build-override] +strip = false diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs new file mode 100644 index 00000000000..9a0a3e3f5af --- /dev/null +++ b/native/ocx-child/src/cli.rs @@ -0,0 +1,1827 @@ +use std::fs; +use std::io::{self, BufRead, Read}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::codex_home::{ + ClaimPolicy, CodexHomeError, CodexHomeGuard, OwnershipPhase, RestoreStepError, +}; +use crate::codex_settings::{ + HomeCodexSettings, HomeCodexSettingsDocument, HomeIdentity, codex_settings_digest, + parse_home_codex_settings_document, +}; +use crate::daemon::{ + ensure_port_available_for_attach, pick_ephemeral_link_port, probe_local_healthz, + start_or_rollover_daemon, stop_owned_daemon, +}; +use crate::journal::{ + DetachError, apply_attach_or_sync, offline_detach_deferred, remove_optional_file, + validate_existing_journal_paths, +}; +use crate::parity::LinkChildParityReport; +use crate::platform::{ + DirectoryIdentity, FileLock, LockAcquireError, SecretBytes, constant_time_eq, + directory_identity, ensure_private_dir, format_iso8601_utc, now_epoch_ms, random_hex, + reject_symlink, resolve_codex_home, resolve_opencodex_home, volatile_zero_bytes, + write_private_atomic, write_regular_atomic, +}; +use crate::relay::{ + RelayCredentials, UpstreamError, fetch_home_authenticated, post_child_parity_report, + serve_foreground, +}; +use crate::relay_auth::{is_valid_key_id, is_valid_link_id}; +use crate::routes::RelayAuthKind; +use crate::state::{ + AppliedSettingsRecord, CHILD_STATE_SCHEMA, ChildPaths, ChildStateV1, StateReadOutcome, + is_valid_daemon_generation, is_valid_link_token_format, read_child_state, + read_verified_link_key, write_child_state, write_link_key, +}; +use crate::{DEFAULT_LISTEN_PORT, OCX_PROTOCOL_COMPAT_VERSION}; + +const REMOTE_LINK_SYNC_MESSAGE: &str = "Remote Link Home catalog synchronized."; +const STALE_REMOTE_CATALOG_MESSAGE: &str = + "Home unavailable; retained and applied the last-known-good remote catalog (stale)."; +const MAX_STDIN_CREDENTIAL_BYTES: usize = 4096; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StdinLinkCredential { + api_key_id: String, + key: String, +} + +fn default_listen_port() -> u16 { + std::env::var("OCX_CHILD_LISTEN_PORT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&p| p >= 1024) + .unwrap_or(DEFAULT_LISTEN_PORT) +} + +fn current_paths() -> ChildPaths { + ChildPaths::new(resolve_opencodex_home(), resolve_codex_home()) +} + +pub fn run_cli(args: &[String]) -> u8 { + let Some(first) = args.first().map(String::as_str) else { + eprintln!( + "Usage: ocx-child " + ); + return 2; + }; + match first { + "--version" | "-V" | "version" => { + println!("opencodex {OCX_PROTOCOL_COMPAT_VERSION}"); + 0 + } + "--help" | "-h" | "help" => { + println!("opencodex {OCX_PROTOCOL_COMPAT_VERSION} (ocx-child ephemeral link client)"); + 0 + } + "port" => cmd_port(), + "link" => match args.get(1).map(String::as_str) { + Some("port") => cmd_port(), + Some("status") => cmd_link_status(&args[2..]), + _ => { + eprintln!("Usage: ocx-child link "); + 2 + } + }, + "config" => match ( + args.get(1).map(String::as_str), + args.get(2).map(String::as_str), + ) { + (Some("show"), Some("--json")) | (Some("show"), None) => cmd_config_show(), + _ => { + eprintln!("Usage: ocx-child config show --json"); + 2 + } + }, + "attach" => cmd_attach_or_connect(&args[1..], true), + "connect" => { + if args.get(1).map(String::as_str) == Some("status") { + cmd_connect_status(&args[2..]) + } else { + cmd_attach_or_connect(&args[1..], false) + } + } + "serve" => cmd_serve(&args[1..]), + "restart" => cmd_restart(), + "sync" => cmd_sync(&args[1..]), + "detach" | "disconnect" => cmd_detach(&args[1..]), + "status" => cmd_status(&args[1..]), + other => { + eprintln!("Unknown ocx-child subcommand: {other}"); + 2 + } + } +} + +fn cmd_port() -> u8 { + match pick_ephemeral_link_port() { + Ok(port) => { + println!("{{\"port\":{port}}}"); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } +} + +fn cmd_config_show() -> u8 { + let paths = current_paths(); + let (port, role) = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) if st.pending_operation.is_none() => (st.listen_port, "client"), + _ => (default_listen_port(), "standalone"), + }; + println!("{{\"port\":{port},\"runtimeRole\":\"{role}\",\"ephemeralChild\":true}}"); + 0 +} + +fn read_stdin_credential() -> Result<(String, SecretBytes), String> { + let mut raw = Vec::with_capacity(256); + io::stdin() + .take((MAX_STDIN_CREDENTIAL_BYTES + 1) as u64) + .read_to_end(&mut raw) + .map_err(|e| format!("failed reading credential from stdin: {e}"))?; + let secret_buf = SecretBytes::new(raw); + if secret_buf.as_slice().len() > MAX_STDIN_CREDENTIAL_BYTES { + return Err("link credential exceeds 4 KiB stdin limit".to_owned()); + } + let text = std::str::from_utf8(secret_buf.as_slice()) + .map_err(|_| "invalid link credential UTF-8".to_owned())? + .trim(); + let parsed: StdinLinkCredential = + serde_json::from_str(text).map_err(|_| "invalid link credential JSON".to_owned())?; + if !is_valid_key_id(&parsed.api_key_id) || !is_valid_link_token_format(&parsed.key) { + return Err("invalid link credential shape".to_owned()); + } + let key_bytes = SecretBytes::new(parsed.key.into_bytes()); + Ok((parsed.api_key_id, key_bytes)) +} + +struct SettingsFetchError { + retryable: bool, + message: String, +} + +impl std::fmt::Display for SettingsFetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +fn fetch_home_settings_doc( + tunnel_port: u16, + creds: &RelayCredentials, +) -> Result { + let (head, body, _) = fetch_home_authenticated( + tunnel_port, + creds, + "GET", + "/v1/link-management/codex-settings", + RelayAuthKind::Bearer, + None, + 64 * 1024, + ) + .map_err(|e| SettingsFetchError { + retryable: matches!(e, UpstreamError::HomeUnreachable(_)), + message: format!("codex-settings fetch failed: {e:?}"), + })?; + if head.status == 404 { + return Ok(HomeCodexSettingsDocument { + version: 1, + home: HomeIdentity { + name: "home".to_owned(), + version: OCX_PROTOCOL_COMPAT_VERSION.to_owned(), + }, + settings: HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: true, + subagent_defaults: None, + }, + }); + } + if head.status != 200 { + return Err(SettingsFetchError { + retryable: head.status >= 500, + message: format!("Home codex-settings returned HTTP {}", head.status), + }); + } + parse_home_codex_settings_document(&body).ok_or_else(|| SettingsFetchError { + retryable: false, + message: "Home codex-settings failed strict schema validation".to_owned(), + }) +} + +pub struct AttachRollbackSnapshot { + opencodex_home: String, + codex_home: String, + opencodex_home_identity: DirectoryIdentity, + codex_home_identity: DirectoryIdentity, + prior_state: Option, + prior_was_serving: bool, + journal: Option>, + config: Option>, + catalog: Option>, + catalog_backup: Option>, + models_cache: Option>, + key: Option, + state_bytes: Option>, +} + +const MAX_ROLLBACK_BYTES: u64 = 256 * 1024 * 1024; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AttachRollbackRecordRef<'a> { + schema: &'static str, + opencodex_home: String, + codex_home: String, + opencodex_home_identity: DirectoryIdentity, + codex_home_identity: DirectoryIdentity, + prior_state: &'a Option, + prior_was_serving: bool, + journal: Option<&'a [u8]>, + config: Option<&'a [u8]>, + catalog: Option<&'a [u8]>, + catalog_backup: Option<&'a [u8]>, + models_cache: Option<&'a [u8]>, + key: Option<&'a [u8]>, + state_bytes: Option<&'a [u8]>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AttachRollbackRecord { + schema: String, + opencodex_home: String, + codex_home: String, + opencodex_home_identity: DirectoryIdentity, + codex_home_identity: DirectoryIdentity, + prior_state: Option, + prior_was_serving: bool, + journal: Option>, + config: Option>, + catalog: Option>, + catalog_backup: Option>, + models_cache: Option>, + key: Option>, + state_bytes: Option>, +} + +impl Drop for AttachRollbackRecord { + fn drop(&mut self) { + for bytes in [ + &mut self.journal, + &mut self.config, + &mut self.catalog, + &mut self.catalog_backup, + &mut self.models_cache, + &mut self.key, + &mut self.state_bytes, + ] + .into_iter() + .flatten() + { + volatile_zero_bytes(bytes); + } + } +} + +fn rollback_pending(paths: &ChildPaths) -> Result { + match fs::symlink_metadata(&paths.rollback_file) { + Ok(_) => Ok(true), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(format!("cannot inspect rollback snapshot: {e}")), + } +} + +impl Drop for AttachRollbackSnapshot { + fn drop(&mut self) { + for bytes in [ + &mut self.journal, + &mut self.config, + &mut self.catalog, + &mut self.catalog_backup, + &mut self.models_cache, + &mut self.state_bytes, + ] + .into_iter() + .flatten() + { + volatile_zero_bytes(bytes); + } + } +} + +impl AttachRollbackSnapshot { + fn read_optional(path: &std::path::Path) -> Result>, String> { + match fs::read(path) { + Ok(b) => Ok(Some(b)), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("attach failed reading {}: {e}", path.display())), + } + } + + pub fn capture( + guard: &CodexHomeGuard, + prior_state: Option, + ) -> Result { + let paths = guard.paths(); + ensure_private_dir(&paths.child_dir)?; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(&paths.codex_home) + .map_err(|e| format!("cannot create Codex home for rollback: {e}"))?; + let (opencodex_home, codex_home) = Self::canonical_homes(paths)?; + let opencodex_home_identity = directory_identity(std::path::Path::new(&opencodex_home))?; + let codex_home_identity = directory_identity(std::path::Path::new(&codex_home))?; + let prior_was_serving = prior_state.as_ref().is_some_and(|st| { + probe_local_healthz(st.listen_port, Duration::from_millis(300)).is_some() + }); + let snapshot = Self { + opencodex_home, + codex_home, + opencodex_home_identity, + codex_home_identity, + prior_state, + prior_was_serving, + journal: Self::read_optional(&paths.journal_file)?, + config: Self::read_optional(&paths.codex_config_path())?, + catalog: Self::read_optional(&paths.codex_catalog_path())?, + catalog_backup: Self::read_optional(&paths.catalog_backup_file)?, + models_cache: Self::read_optional(&paths.codex_models_cache_path())?, + key: Self::read_optional(&paths.key_file)?.map(SecretBytes::new), + state_bytes: Self::read_optional(&paths.state_file)?, + }; + snapshot.validate_homes(paths)?; + snapshot.persist(paths)?; + Ok(snapshot) + } + + fn canonical_homes(paths: &ChildPaths) -> Result<(String, String), String> { + let ocx = fs::canonicalize(&paths.opencodex_home) + .map_err(|e| format!("cannot resolve OpenCodex home for rollback: {e}"))?; + let codex = fs::canonicalize(&paths.codex_home) + .map_err(|e| format!("cannot resolve Codex home for rollback: {e}"))?; + let ocx = ocx.to_str().ok_or("OpenCodex home is not UTF-8")?; + let codex = codex.to_str().ok_or("Codex home is not UTF-8")?; + Ok((ocx.to_owned(), codex.to_owned())) + } + + fn validate_homes(&self, paths: &ChildPaths) -> Result<(), String> { + let (ocx, codex) = Self::canonical_homes(paths)?; + if ocx != self.opencodex_home + || codex != self.codex_home + || directory_identity(std::path::Path::new(&ocx))? != self.opencodex_home_identity + || directory_identity(std::path::Path::new(&codex))? != self.codex_home_identity + { + return Err("rollback snapshot belongs to a different home".to_owned()); + } + Ok(()) + } + + fn persist(&self, paths: &ChildPaths) -> Result<(), String> { + ensure_private_dir(&paths.child_dir)?; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(&paths.codex_home) + .map_err(|e| format!("cannot create Codex home for rollback: {e}"))?; + reject_symlink(&paths.rollback_file)?; + match fs::symlink_metadata(&paths.rollback_file) { + Ok(_) => return Err("attach refused: unresolved rollback snapshot exists".to_owned()), + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("cannot inspect rollback snapshot: {e}")), + } + self.validate_homes(paths)?; + let record = AttachRollbackRecordRef { + schema: "ocx.child.attach-rollback/1", + opencodex_home_identity: self.opencodex_home_identity.clone(), + codex_home_identity: self.codex_home_identity.clone(), + opencodex_home: self.opencodex_home.clone(), + codex_home: self.codex_home.clone(), + prior_state: &self.prior_state, + prior_was_serving: self.prior_was_serving, + journal: self.journal.as_deref(), + config: self.config.as_deref(), + catalog: self.catalog.as_deref(), + catalog_backup: self.catalog_backup.as_deref(), + models_cache: self.models_cache.as_deref(), + key: self.key.as_ref().map(SecretBytes::as_slice), + state_bytes: self.state_bytes.as_deref(), + }; + let mut bytes = serde_json::to_vec(&record) + .map_err(|e| format!("cannot serialize rollback snapshot: {e}"))?; + if bytes.len() as u64 > MAX_ROLLBACK_BYTES { + volatile_zero_bytes(&mut bytes); + return Err("rollback snapshot exceeds size limit".to_owned()); + } + let result = write_private_atomic(&paths.rollback_file, &bytes); + volatile_zero_bytes(&mut bytes); + result + } + + fn recover(guard: &CodexHomeGuard) -> Result { + let paths = guard.paths(); + reject_symlink(&paths.rollback_file)?; + let file = match fs::File::open(&paths.rollback_file) { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(format!("cannot open rollback snapshot: {e}")), + }; + let mut bytes = Vec::new(); + file.take(MAX_ROLLBACK_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| format!("cannot read rollback snapshot: {e}"))?; + if bytes.len() as u64 > MAX_ROLLBACK_BYTES { + volatile_zero_bytes(&mut bytes); + return Err("rollback snapshot exceeds size limit".to_owned()); + } + let parsed = serde_json::from_slice::(&bytes); + volatile_zero_bytes(&mut bytes); + let mut record = parsed.map_err(|e| format!("invalid rollback snapshot: {e}"))?; + let (ocx, codex) = Self::canonical_homes(paths)?; + if record.schema != "ocx.child.attach-rollback/1" + || record.opencodex_home != ocx + || record.codex_home != codex + || record.opencodex_home_identity != directory_identity(std::path::Path::new(&ocx))? + || record.codex_home_identity != directory_identity(std::path::Path::new(&codex))? + { + return Err("rollback snapshot belongs to a different home".to_owned()); + } + match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(state) => stop_owned_daemon(&state)?, + StateReadOutcome::Absent => {} + StateReadOutcome::Invalid(_) => { + return Err("cannot recover rollback with invalid child state".to_owned()); + } + } + let snapshot = Self { + opencodex_home: record.opencodex_home.clone(), + codex_home: record.codex_home.clone(), + opencodex_home_identity: record.opencodex_home_identity.clone(), + codex_home_identity: record.codex_home_identity.clone(), + prior_state: record.prior_state.take(), + // A recovery requested by detach restores files without starting a daemon. + prior_was_serving: false, + journal: record.journal.take(), + config: record.config.take(), + catalog: record.catalog.take(), + catalog_backup: record.catalog_backup.take(), + models_cache: record.models_cache.take(), + key: record.key.take().map(SecretBytes::new), + state_bytes: record.state_bytes.take(), + }; + let _ = record.prior_was_serving; + snapshot.restore(guard)?; + Ok(true) + } + + /// Restore one file. An ordinary failure is collected in `errors` so the remaining files + /// are still attempted; a changed home stops restoration at once (`Err`). + fn restore_path( + guard: &CodexHomeGuard, + path: &std::path::Path, + bytes: Option<&[u8]>, + private: bool, + errors: &mut Vec, + ) -> Result<(), String> { + let result = guard.restore_step(|| match bytes { + Some(b) if private => write_private_atomic(path, b), + Some(b) => write_regular_atomic(path, b), + None => remove_optional_file(path), + }); + match result { + Ok(()) => Ok(()), + Err(RestoreStepError::Failed(e)) => { + errors.push(format!("restore {}: {e}", path.display())); + Ok(()) + } + Err(RestoreStepError::HomeChanged(e)) => Err(format!("attach rollback stopped: {e}")), + } + } + + pub fn restore(&self, guard: &CodexHomeGuard) -> Result<(), String> { + let paths = guard.paths(); + self.validate_homes(paths)?; + let mut errors = Vec::new(); + for (path, bytes) in [ + (paths.codex_config_path(), self.config.as_deref()), + (paths.codex_catalog_path(), self.catalog.as_deref()), + ( + paths.codex_models_cache_path(), + self.models_cache.as_deref(), + ), + ] { + Self::restore_path(guard, &path, bytes, false, &mut errors)?; + } + if errors.is_empty() { + for (path, bytes) in [ + (&paths.catalog_backup_file, self.catalog_backup.as_deref()), + ( + &paths.key_file, + self.key.as_ref().map(SecretBytes::as_slice), + ), + (&paths.state_file, self.state_bytes.as_deref()), + ] { + Self::restore_path(guard, path, bytes, true, &mut errors)?; + } + } + if errors.is_empty() { + Self::restore_path( + guard, + &paths.journal_file, + self.journal.as_deref(), + true, + &mut errors, + )?; + } + let mut restarted = None; + if let Some(mut prev) = self.prior_state.clone() + && self.prior_was_serving + && errors.is_empty() + && probe_local_healthz(prev.listen_port, Duration::from_millis(250)).is_none() + { + let state_key_consistent = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) + if st.link_id == prev.link_id + && st.token_fingerprint == prev.token_fingerprint => + { + read_verified_link_key(&paths.key_file, &st.token_fingerprint).is_ok() + } + _ => false, + }; + if !state_key_consistent { + errors.push("prior relay restart skipped: restored state.json and link-key are inconsistent".to_owned()); + } else { + // The state naming the new generation and PID is durable once this returns. + match start_or_rollover_daemon(paths, &mut prev, None) { + Ok(pending) => restarted = Some(pending), + Err(e) => errors.push(format!("restart prior daemon: {e}")), + } + } + } + if errors.is_empty() { + // The rollback record goes only after restoration verified both homes, and before a + // restarted prior relay is committed, so that relay never serves while recovery + // could still restore files underneath it. Dropping an uncommitted relay stops it. + guard + .restore_step(|| remove_optional_file(&paths.rollback_file)) + .map_err(RestoreStepError::into_message)?; + if let Some(pending) = restarted { + pending.commit().map_err(|e| { + format!("attach rolled back, but the prior relay did not restart: {e}") + })?; + } + Ok(()) + } else { + Err(format!("attach rollback incomplete: {}", errors.join("; "))) + } + } + + /// Restore after a failed attach; true when every file and record was restored. + fn restore_and_report(&self, guard: &CodexHomeGuard) -> bool { + match self.restore(guard) { + Ok(()) => true, + Err(rb_err) => { + eprintln!("{rb_err}"); + false + } + } + } +} + +fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> u8 { + let mut link_id: Option = None; + let mut tunnel_port: Option = None; + let mut listen_port: u16 = default_listen_port(); + let mut start_daemon = native_attach_default_start; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--link" | "--key-stdin" => i += 1, + "--start-daemon" => { + start_daemon = true; + i += 1; + } + "--no-daemon" => { + start_daemon = false; + i += 1; + } + "--link-id" => { + link_id = args.get(i + 1).cloned(); + i += 2; + } + "--tunnel-port" => { + tunnel_port = args.get(i + 1).and_then(|s| s.parse::().ok()); + i += 2; + } + "--listen-port" | "--port" => { + if let Some(p) = args.get(i + 1).and_then(|s| s.parse::().ok()) { + listen_port = p; + } else { + eprintln!("invalid --listen-port"); + return 2; + } + i += 2; + } + "--clients" => i += 2, + other => { + eprintln!("unrecognized attach/connect argument: {other}"); + return 2; + } + } + } + let Some(link_id) = link_id.filter(|id| is_valid_link_id(id)) else { + eprintln!("--link-id lnk_<16hex> is required"); + return 2; + }; + let Some(tunnel_port) = tunnel_port.filter(|&p| p >= 1024) else { + eprintln!("--tunnel-port <1024..65535> is required"); + return 2; + }; + let (api_key_id, raw_key) = match read_stdin_credential() { + Ok(v) => v, + Err(e) => { + eprintln!("{e}"); + return 2; + } + }; + + let local_paths = current_paths(); + let _lock = match FileLock::acquire_blocking(&local_paths.lock_file) { + Ok(l) => l, + Err(_) => { + eprintln!("attach failed: could not acquire child lifecycle lock"); + return 1; + } + }; + + if !matches!(rollback_pending(&local_paths), Ok(false)) { + eprintln!("attach refused: incomplete attach requires detach recovery"); + return 1; + } + let mut guard = match CodexHomeGuard::acquire(&local_paths, true, true, ClaimPolicy::Attach) { + Ok(guard) => guard, + Err(e) => { + eprintln!("attach refused: {}", e.message()); + return 1; + } + }; + crate::test_hooks::barrier("attach-protected"); + let request = AttachRequest { + link_id, + tunnel_port, + listen_port, + start_daemon, + api_key_id, + raw_key, + }; + match attach_locked(&mut guard, request) { + Ok(()) => 0, + Err(failure) => { + if failure.left_no_changes + && let Err(e) = guard.revert_claim() + { + eprintln!("attach could not release Codex home ownership: {e}"); + } + failure.code + } + } +} + +struct AttachRequest { + link_id: String, + tunnel_port: u16, + listen_port: u16, + start_daemon: bool, + api_key_id: String, + raw_key: SecretBytes, +} + +struct AttachFailure { + code: u8, + /// True when nothing was changed, or every change was rolled back and recorded as such. + left_no_changes: bool, +} + +fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<(), AttachFailure> { + let AttachRequest { + link_id, + tunnel_port, + listen_port, + start_daemon, + api_key_id, + raw_key, + } = request; + let paths = guard.paths().clone(); + let refuse = |code: u8| AttachFailure { + code, + left_no_changes: true, + }; + let prior_state = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) => Some(*st), + _ => None, + }; + let existing_pid = prior_state + .as_ref() + .filter(|prev| prev.listen_port == listen_port) + .and_then(|prev| prev.daemon_pid); + if let Err(e) = ensure_port_available_for_attach(listen_port, existing_pid) { + eprintln!("{e}"); + return Err(refuse(1)); + } + + let raw_key_str = match std::str::from_utf8(raw_key.as_slice()) { + Ok(s) => s, + Err(_) => { + eprintln!("invalid credential encoding"); + return Err(refuse(2)); + } + }; + let fp = crate::relay_auth::token_fingerprint_hex(raw_key_str); + let creds = RelayCredentials { + api_key_id: api_key_id.clone(), + link_id: link_id.clone(), + token_fingerprint: fp.clone(), + raw_token: SecretBytes::new(raw_key.as_slice().to_vec()), + }; + + // Probe Home /readyz and pull /v1/catalog + /v1/link-management/codex-settings + match fetch_home_authenticated( + tunnel_port, + &creds, + "GET", + "/readyz", + RelayAuthKind::ApiKeyHeader, + None, + 64 * 1024, + ) { + Ok((h, _, _)) if h.status == 200 => {} + Ok((h, _, _)) => { + eprintln!("Home /readyz returned HTTP {}", h.status); + return Err(refuse(1)); + } + Err(e) => { + eprintln!("Home /readyz unreachable over tunnel: {e:?}"); + return Err(refuse(1)); + } + } + + let catalog_bytes = match fetch_home_authenticated( + tunnel_port, + &creds, + "GET", + "/v1/catalog", + RelayAuthKind::Bearer, + None, + 4 * 1024 * 1024, + ) { + Ok((h, b, _)) if h.status == 200 => b, + Ok((h, _, _)) => { + eprintln!("Home /v1/catalog returned HTTP {}", h.status); + return Err(refuse(1)); + } + Err(e) => { + eprintln!("Home /v1/catalog failed: {e:?}"); + return Err(refuse(1)); + } + }; + + let settings_doc = match fetch_home_settings_doc(tunnel_port, &creds) { + Ok(d) => d, + Err(e) => { + eprintln!("{e}"); + return Err(refuse(1)); + } + }; + + let rollback = match AttachRollbackSnapshot::capture(guard, prior_state.clone()) { + Ok(r) => r, + Err(e) => { + eprintln!("{e}"); + // A persisted snapshot means changes may need recovery; keep ownership for detach. + let left_no_changes = matches!(rollback_pending(&paths), Ok(false)); + return Err(AttachFailure { + code: 1, + left_no_changes, + }); + } + }; + + let journal = match apply_attach_or_sync( + guard, + &link_id, + &api_key_id, + listen_port, + &catalog_bytes, + &settings_doc.settings, + ) { + Ok(j) => j, + Err(e) => { + let restored = rollback.restore_and_report(guard); + eprintln!("{e}"); + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); + } + }; + + if let Err(e) = write_link_key(&paths.key_file, raw_key_str) { + let restored = rollback.restore_and_report(guard); + eprintln!("{e}"); + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); + } + + let now_ms = now_epoch_ms(); + let now_iso = format_iso8601_utc(now_ms); + let digest = codex_settings_digest(&settings_doc.settings); + let shutdown_secret = match random_hex(24) { + Ok(s) => s, + Err(e) => { + let restored = rollback.restore_and_report(guard); + eprintln!("{e}"); + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); + } + }; + if let Some(prev) = &prior_state + && prev.listen_port != listen_port + && let Err(e) = stop_owned_daemon(prev) + { + let restored = rollback.restore_and_report(guard); + eprintln!( + "attach failed stopping previous relay on port {}: {e}", + prev.listen_port + ); + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); + } + let mut state = ChildStateV1 { + schema: CHILD_STATE_SCHEMA.to_owned(), + link_id: link_id.clone(), + api_key_id: api_key_id.clone(), + token_fingerprint: fp, + tunnel_port, + listen_port, + connected_at: now_iso.clone(), + catalog_fingerprint: journal.latest_catalog_sha256, + catalog_synced_at: now_iso.clone(), + daemon_pid: existing_pid, + daemon_started_at_ms: None, + shutdown_secret: shutdown_secret.into(), + parity_sequence: 0, + pending_operation: None, + daemon_generation: None, + applied_settings: Some(AppliedSettingsRecord { + link_id: link_id.clone(), + fetched_at: now_iso.clone(), + applied_at: now_iso, + applied_at_ms: now_ms, + digest: digest.clone(), + document: settings_doc, + }), + }; + if let Err(e) = write_child_state(&paths.state_file, &state) { + let restored = rollback.restore_and_report(guard); + eprintln!("{e}"); + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); + } + + let pending = if start_daemon { + crate::test_hooks::barrier("attach-before-daemon-start"); + match start_or_rollover_daemon(&paths, &mut state, Some(now_ms)) { + Ok(pending) => Some(pending), + Err(e) => { + let restored = rollback.restore_and_report(guard); + eprintln!("{e}"); + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); + } + } + } else { + None + }; + + if let Err(e) = guard.set_phase(OwnershipPhase::Attached) { + // Stop the uncommitted daemon before restoring files it could otherwise serve. + drop(pending); + let restored = rollback.restore_and_report(guard); + eprintln!("attach failed recording Codex home ownership: {e}"); + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); + } + if let Err(e) = remove_optional_file(&paths.rollback_file) { + drop(pending); + eprintln!("attach failed to clear rollback snapshot: {e}"); + return Err(AttachFailure { + code: 1, + left_no_changes: false, + }); + } + // The daemon may serve only now: state and ownership are durable and the rollback record + // is gone, so recovery can no longer restore files underneath it. + if let Some(pending) = pending { + match pending.commit() { + Ok((_, started_ms)) => { + state.parity_sequence += 1; + let _ = write_child_state(&paths.state_file, &state); + let report = LinkChildParityReport { + link_id, + last_apply_at: Some(now_ms), + runtime_started_at: started_ms, + applied_codex_settings_digest: Some(digest), + sequence: state.parity_sequence, + }; + let _ = post_child_parity_report(tunnel_port, &creds, &report); + } + Err(e) => { + eprintln!( + "attach completed but the client relay did not start: {e}; run `ocx-child restart`" + ); + return Err(AttachFailure { + code: 1, + left_no_changes: false, + }); + } + } + } + println!("Connected to http://127.0.0.1:{tunnel_port}."); + println!("Local Codex CLI: ready (ephemeral ocx-child Design B attached)."); + Ok(()) +} + +struct LifecycleStart { + generation: String, + secret: SecretBytes, +} + +/// Read the parent's start line: `ocx-child-start/1 \n`. +fn read_lifecycle_start() -> Result { + let mut line = Vec::with_capacity(128); + let read = io::stdin().lock().take(161).read_until(b'\n', &mut line); + let buffer = SecretBytes::new(line); + read.map_err(|e| format!("cannot read lifecycle start line: {e}"))?; + let text = std::str::from_utf8(buffer.as_slice()) + .ok() + .and_then(|t| t.strip_suffix('\n')) + .ok_or("invalid lifecycle start line")?; + let mut parts = text.split(' '); + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some("ocx-child-start/1"), Some(generation), Some(secret), None) + if is_valid_daemon_generation(generation) && secret.len() == 48 => + { + Ok(LifecycleStart { + generation: generation.to_owned(), + secret: SecretBytes::new(secret.as_bytes().to_vec()), + }) + } + _ => Err("invalid lifecycle start line".to_owned()), + } +} + +fn cmd_serve(args: &[String]) -> u8 { + let mut foreground = false; + let mut lifecycle_start = false; + let mut listen_port: Option = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--foreground" => { + foreground = true; + i += 1; + } + "--lifecycle-start" => { + lifecycle_start = true; + i += 1; + } + "--listen-port" | "--port" => { + listen_port = args.get(i + 1).and_then(|s| s.parse::().ok()); + i += 2; + } + _ => i += 1, + } + } + let paths = current_paths(); + if foreground { + let start = if lifecycle_start { + match read_lifecycle_start() { + Ok(start) => Some(start), + Err(e) => { + eprintln!("serve refused: {e}"); + return 1; + } + } + } else { + None + }; + // Watch for the commit from now on, so a parent that dies while this process waits + // on the start gate still ends it. + let committed = start + .as_ref() + .map(|s| crate::relay::spawn_commit_watcher(s.generation.clone())); + let state = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) if st.pending_operation.is_none() => st, + StateReadOutcome::Absent => { + eprintln!("serve refused: ocx-child is detached (no active link)"); + return 1; + } + _ => { + eprintln!("serve refused: child state is invalid or pending teardown"); + return 1; + } + }; + if !matches!(validate_existing_journal_paths(&paths), Ok(true)) { + eprintln!( + "serve refused: child recovery journal is missing or belongs to another home" + ); + return 1; + } + match rollback_pending(&paths) { + Ok(true) if start.is_none() => { + eprintln!("serve refused: incomplete attach requires lifecycle authorization"); + return 1; + } + Ok(_) => {} + Err(e) => { + eprintln!("serve refused: {e}"); + return 1; + } + } + if let Some(start) = &start + && !constant_time_eq(start.secret.as_slice(), state.shutdown_secret.as_bytes()) + { + eprintln!("serve refused: lifecycle authorization does not match child state"); + return 1; + } + if let Err(e) = read_verified_link_key(&paths.key_file, &state.token_fingerprint) { + eprintln!("serve refused: {e}"); + return 1; + } + let port = listen_port.unwrap_or(state.listen_port); + if port != state.listen_port { + eprintln!("serve refused: listen port does not match child state"); + return 1; + } + let served = match (&start, committed) { + (Some(start), Some(committed)) => { + crate::relay::serve_foreground_lifecycle(paths, port, &start.generation, committed) + } + _ => serve_foreground(paths, port), + }; + return match served { + Ok(()) => 0, + Err(e) => { + eprintln!("{e}"); + 1 + } + }; + } + cmd_restart() +} + +fn cmd_restart() -> u8 { + let paths = current_paths(); + let _lock = match FileLock::acquire_blocking(&paths.lock_file) { + Ok(l) => l, + Err(_) => { + eprintln!("restart failed: child lifecycle lock busy"); + return 1; + } + }; + if !matches!(rollback_pending(&paths), Ok(false)) { + eprintln!("restart refused: incomplete attach requires detach recovery"); + return 1; + } + let codex_guard = match CodexHomeGuard::acquire(&paths, true, false, ClaimPolicy::Continue) { + Ok(guard) => guard, + Err(e) => { + eprintln!("restart refused: {}", e.message()); + return 1; + } + }; + let paths = codex_guard.paths().clone(); + let mut state = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) if st.pending_operation.is_none() => *st, + StateReadOutcome::Absent => { + eprintln!("restart refused: ocx-child is detached (no active link)"); + return 1; + } + _ => { + eprintln!("restart refused: child state is invalid or pending teardown"); + return 1; + } + }; + if !matches!(validate_existing_journal_paths(&paths), Ok(true)) { + eprintln!("restart refused: child recovery journal is missing or belongs to another home"); + return 1; + } + let floor_ms = state.applied_settings.as_ref().map(|a| a.applied_at_ms); + let (pid, started_ms) = match start_or_rollover_daemon(&paths, &mut state, floor_ms) + .and_then(|pending| pending.commit()) + { + Ok(v) => v, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + if let Ok(raw_token) = read_verified_link_key(&paths.key_file, &state.token_fingerprint) { + let creds = RelayCredentials { + api_key_id: state.api_key_id.clone(), + link_id: state.link_id.clone(), + token_fingerprint: state.token_fingerprint.clone(), + raw_token, + }; + state.parity_sequence += 1; + let _ = write_child_state(&paths.state_file, &state); + let (last_apply_at, digest) = match &state.applied_settings { + Some(rec) => (Some(rec.applied_at_ms), Some(rec.digest.clone())), + None => (None, None), + }; + let report = LinkChildParityReport { + link_id: state.link_id.clone(), + last_apply_at, + runtime_started_at: started_ms, + applied_codex_settings_digest: digest, + sequence: state.parity_sequence, + }; + let _ = post_child_parity_report(state.tunnel_port, &creds, &report); + } + println!( + "ocx-child client relay active (PID {pid}, port {}).", + state.listen_port + ); + 0 +} + +fn emit_expectation_mismatch(phase: &str, reason: &str) -> u8 { + eprintln!( + "Sync refused ({phase}: {reason}); nothing was changed and local providers were not synced." + ); + eprintln!("{{\"phase\":\"{phase}\",\"reason\":\"{reason}\"}}"); + 5 +} + +fn cmd_sync(args: &[String]) -> u8 { + let mut json_mode = false; + let mut expect_link: Option = None; + let mut expect_hub_key: Option = None; + let mut expect_count = 0usize; + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if arg == "--json" { + json_mode = true; + i += 1; + } else if arg == "--local-only" + || arg == "--restart-codex" + || arg == "--restart-app-server-only" + { + i += 1; + } else if arg == "--expect-link" { + expect_count += 1; + expect_link = args.get(i + 1).cloned(); + i += 2; + } else if let Some(val) = arg.strip_prefix("--expect-link=") { + expect_count += 1; + expect_link = Some(val.to_owned()); + i += 1; + } else if arg == "--expect-hub-key" { + expect_count += 1; + expect_hub_key = args.get(i + 1).cloned(); + i += 2; + } else if let Some(val) = arg.strip_prefix("--expect-hub-key=") { + expect_count += 1; + expect_hub_key = Some(val.to_owned()); + i += 1; + } else { + eprintln!("unrecognized sync flag: {arg}"); + return 2; + } + } + if expect_count > 1 { + eprintln!("ocx sync accepts one --expect-link or --expect-hub-key, given once."); + return 2; + } + if let Some(id) = &expect_link + && !is_valid_link_id(id) + { + eprintln!( + "--expect-link needs a link id of the form lnk_ followed by 16 hex digits (see ocx connect status --json)." + ); + return 2; + } + if let Some(k) = &expect_hub_key + && (k.trim().is_empty() || k.trim() != k || k.len() > 256) + { + eprintln!( + "--expect-hub-key needs the connected API key id (see ocx connect status --json)." + ); + return 2; + } + + let paths = current_paths(); + let has_expectation = expect_link.is_some() || expect_hub_key.is_some(); + let _lock = if has_expectation { + match FileLock::try_acquire_nonblocking(&paths.lock_file) { + Ok(l) => l, + Err(LockAcquireError::Busy) => { + let phase = if expect_link.is_some() { + "child_link_mismatch" + } else { + "hub_client_mismatch" + }; + return emit_expectation_mismatch(phase, "pending_operation"); + } + Err(LockAcquireError::Io) => { + let phase = if expect_link.is_some() { + "child_link_mismatch" + } else { + "hub_client_mismatch" + }; + return emit_expectation_mismatch(phase, "invalid_state"); + } + } + } else { + match FileLock::acquire_blocking(&paths.lock_file) { + Ok(l) => l, + Err(_) => { + eprintln!("sync failed: child lifecycle lock unavailable"); + return 1; + } + } + }; + + let mut state = match read_child_state(&paths.state_file) { + StateReadOutcome::Absent => { + if expect_link.is_some() { + return emit_expectation_mismatch("child_link_mismatch", "disconnected"); + } + if expect_hub_key.is_some() { + return emit_expectation_mismatch("hub_client_mismatch", "disconnected"); + } + eprintln!("ocx-child is not attached to a Remote Link Home."); + return 1; + } + StateReadOutcome::Invalid(_) => { + if expect_link.is_some() { + return emit_expectation_mismatch("child_link_mismatch", "invalid_state"); + } + if expect_hub_key.is_some() { + return emit_expectation_mismatch("hub_client_mismatch", "invalid_state"); + } + eprintln!("ocx-child state is invalid."); + return 1; + } + StateReadOutcome::Valid(st) => *st, + }; + if !matches!(rollback_pending(&paths), Ok(false)) { + if expect_link.is_some() { + return emit_expectation_mismatch("child_link_mismatch", "invalid_state"); + } + if expect_hub_key.is_some() { + return emit_expectation_mismatch("hub_client_mismatch", "invalid_state"); + } + eprintln!("sync refused: incomplete attach requires detach recovery"); + return 1; + } + + if let Some(op) = &state.pending_operation { + let reason = if op == "disconnect" { + "disconnect_pending" + } else { + "pending_operation" + }; + if expect_link.is_some() { + return emit_expectation_mismatch("child_link_mismatch", reason); + } + if expect_hub_key.is_some() { + return emit_expectation_mismatch("hub_client_mismatch", reason); + } + eprintln!("sync refused: {reason}"); + return 1; + } + if expect_hub_key.is_some() { + return emit_expectation_mismatch("hub_client_mismatch", "link_client"); + } + if let Some(expected_id) = &expect_link + && state.link_id != *expected_id + { + return emit_expectation_mismatch("child_link_mismatch", "other_link"); + } + let codex_guard = + match CodexHomeGuard::acquire(&paths, !has_expectation, false, ClaimPolicy::Continue) { + Ok(guard) => guard, + Err(e) => { + if expect_link.is_some() { + let reason = if e == CodexHomeError::Busy { + "pending_operation" + } else { + "invalid_state" + }; + return emit_expectation_mismatch("child_link_mismatch", reason); + } + eprintln!("sync refused: {}", e.message()); + return 1; + } + }; + let paths = codex_guard.paths().clone(); + if !matches!(validate_existing_journal_paths(&paths), Ok(true)) { + if expect_link.is_some() { + return emit_expectation_mismatch("child_link_mismatch", "invalid_state"); + } + eprintln!("sync refused: child recovery journal is missing or belongs to another home"); + return 1; + } + + let raw_token = match read_verified_link_key(&paths.key_file, &state.token_fingerprint) { + Ok(t) => t, + Err(e) => { + if expect_link.is_some() { + return emit_expectation_mismatch("child_link_mismatch", "invalid_state"); + } + eprintln!("Connected sync failed without local fallback: {e}"); + return 1; + } + }; + let creds = RelayCredentials { + api_key_id: state.api_key_id.clone(), + link_id: state.link_id.clone(), + token_fingerprint: state.token_fingerprint.clone(), + raw_token, + }; + + let mut stale = false; + let catalog_bytes = match fetch_home_authenticated( + state.tunnel_port, + &creds, + "GET", + "/v1/catalog", + RelayAuthKind::Bearer, + None, + 4 * 1024 * 1024, + ) { + Ok((h, b, _)) if h.status == 200 => b, + result @ (Err(UpstreamError::HomeUnreachable(_)) | Ok((_, _, _))) + if matches!(&result, Err(UpstreamError::HomeUnreachable(_))) + || matches!(&result, Ok((h, _, _)) if h.status >= 500) => + { + match fs::read(&paths.catalog_backup_file) { + Ok(cached) => { + stale = true; + cached + } + Err(_) => { + eprintln!( + "Connected sync failed without local fallback: Home /v1/catalog unreachable" + ); + return 1; + } + } + } + Ok((head, _, _)) => { + eprintln!( + "Connected sync failed: Home /v1/catalog returned HTTP {}", + head.status + ); + return 1; + } + Err(e) => { + eprintln!("Connected sync failed: Home /v1/catalog: {e:?}"); + return 1; + } + }; + + let now_ms = now_epoch_ms(); + let now_iso = format_iso8601_utc(now_ms); + let settings_doc = match fetch_home_settings_doc(state.tunnel_port, &creds) { + Ok(doc) => doc, + Err(e) if stale && e.retryable && state.applied_settings.is_some() => { + state.applied_settings.as_ref().unwrap().document.clone() + } + Err(e) => { + eprintln!("Connected sync failed without local fallback: {e}"); + return 1; + } + }; + + let journal = match apply_attach_or_sync( + &codex_guard, + &state.link_id, + &state.api_key_id, + state.listen_port, + &catalog_bytes, + &settings_doc.settings, + ) { + Ok(j) => j, + Err(e) => { + eprintln!("Connected sync failed without local fallback: {e}"); + return 1; + } + }; + + let new_digest = codex_settings_digest(&settings_doc.settings); + let (applied_at_iso, applied_at_ms) = match &state.applied_settings { + Some(prev) if prev.link_id == state.link_id && prev.digest == new_digest => { + (prev.applied_at.clone(), prev.applied_at_ms) + } + _ => (now_iso.clone(), now_ms), + }; + state.catalog_fingerprint = journal.latest_catalog_sha256; + state.catalog_synced_at = now_iso.clone(); + state.applied_settings = Some(AppliedSettingsRecord { + link_id: state.link_id.clone(), + fetched_at: now_iso, + applied_at: applied_at_iso, + applied_at_ms, + digest: new_digest.clone(), + document: settings_doc, + }); + if let Err(e) = write_child_state(&paths.state_file, &state) { + eprintln!("Failed writing child state: {e}"); + return 1; + } + + // OCX-6 freshness: ensure live ocx-child serve started >= applied_at_ms and POST child-parity + let live = probe_local_healthz(state.listen_port, Duration::from_millis(500)); + let needs_rollover = match (&live, state.daemon_pid, state.daemon_started_at_ms) { + (Some(h), Some(pid), Some(started_ms)) + if h.service == "opencodex" && h.ephemeral_child == Some(true) && h.pid == pid => + { + started_ms < applied_at_ms + } + _ => true, + }; + let runtime_started_ms = if needs_rollover { + match start_or_rollover_daemon(&paths, &mut state, Some(applied_at_ms)) + .and_then(|pending| pending.commit()) + { + Ok((_, s_ms)) => s_ms, + Err(e) => { + eprintln!("The client runtime restart did not complete: {e}"); + return 1; + } + } + } else { + state.daemon_started_at_ms.unwrap_or(now_ms) + }; + + if !stale { + state.parity_sequence += 1; + let _ = write_child_state(&paths.state_file, &state); + let report = LinkChildParityReport { + link_id: state.link_id.clone(), + last_apply_at: Some(applied_at_ms), + runtime_started_at: runtime_started_ms, + applied_codex_settings_digest: Some(new_digest), + sequence: state.parity_sequence, + }; + let _ = post_child_parity_report(state.tunnel_port, &creds, &report); + } + + let home_state = if stale { "stale" } else { "synced" }; + let human_msg = if stale { + STALE_REMOTE_CATALOG_MESSAGE + } else { + REMOTE_LINK_SYNC_MESSAGE + }; + if json_mode { + eprintln!("{human_msg}"); + println!( + "{{\"schema\":\"ocx.sync.fanout/1\",\"home\":{{\"state\":\"{home_state}\",\"exit\":0}},\"fanout\":{{\"state\":\"not-applicable\",\"reason\":null}},\"children\":[]}}" + ); + } else { + println!("{human_msg}"); + } + 0 +} + +fn cmd_detach(args: &[String]) -> u8 { + let keep_catalog = args.iter().any(|a| a == "--keep-catalog"); + let json_mode = args.iter().any(|a| a == "--json"); + let local_paths = current_paths(); + let _lock = match FileLock::acquire_blocking(&local_paths.lock_file) { + Ok(l) => l, + Err(_) => { + eprintln!("detach failed: child lifecycle lock busy"); + return 1; + } + }; + let mut codex_guard = + match CodexHomeGuard::acquire(&local_paths, true, false, ClaimPolicy::Detach) { + Ok(guard) => guard, + Err(CodexHomeError::Missing) => { + return detach_without_codex_home(&local_paths, json_mode); + } + Err(e) => { + eprintln!("detach refused: {}", e.message()); + return 1; + } + }; + let paths = codex_guard.paths().clone(); + if let Err(e) = codex_guard.set_phase(OwnershipPhase::Detaching) { + eprintln!("detach failed recording Codex home ownership phase: {e}"); + return 1; + } + let _startup_gate = match FileLock::acquire_blocking(&paths.child_dir.join("daemon-start.lock")) + { + Ok(lock) => lock, + Err(_) => { + eprintln!("detach failed: daemon startup gate unavailable"); + return 1; + } + }; + if let Err(e) = AttachRollbackSnapshot::recover(&codex_guard) { + eprintln!("detach recovery failed: {e}"); + return 1; + } + let journal_exists = match validate_existing_journal_paths(&paths) { + Ok(exists) => exists, + Err(e) => { + eprintln!("detach refused: {e}"); + return 1; + } + }; + let state = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) if journal_exists => Some(st), + StateReadOutcome::Absent => None, + _ => { + eprintln!("detach refused: child state or recovery journal is invalid"); + return 1; + } + }; + if !journal_exists { + for path in [&paths.key_file, &paths.catalog_backup_file] { + match fs::symlink_metadata(path) { + Ok(_) => { + eprintln!("detach refused: orphaned child recovery material exists"); + return 1; + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + eprintln!("detach refused: cannot inspect child recovery material: {e}"); + return 1; + } + } + } + } + if let Some(mut st) = state { + st.pending_operation = Some("disconnect".to_owned()); + if let Err(e) = write_child_state(&paths.state_file, &st) { + eprintln!("detach refused: failed to record pending operation: {e}"); + return 1; + } + if let Err(e) = stop_owned_daemon(&st) { + eprintln!("{e}"); + return 1; + } + } + let outcome = match offline_detach_deferred(&codex_guard, keep_catalog) { + Ok(o) => o, + Err(DetachError::Conflict(msg)) => { + eprintln!("detach conflict: {msg}"); + return 4; + } + Err(DetachError::Io(msg)) => { + eprintln!("detach failed: {msg}"); + return 1; + } + }; + for path in [ + &paths.key_file, + &paths.state_file, + &paths.catalog_backup_file, + &paths.journal_file, + ] { + if let Err(e) = codex_guard.restore_step(|| remove_optional_file(path)) { + eprintln!("detach cleanup failed: {}", e.into_message()); + return 1; + } + } + // Ownership is released last, after cleanup is durable, so a crash before this point + // leaves a record that only this OpenCodex home's detach can resume. + if let Err(e) = codex_guard.release() { + eprintln!("detach cleanup failed releasing Codex home ownership: {e}"); + return 1; + } + print_detached(json_mode, outcome); + 0 +} + +fn print_detached(json_mode: bool, outcome: crate::journal::DetachOutcome) { + if json_mode { + println!("{{\"restored\":true,\"outcome\":\"{outcome:?}\",\"daemonRunning\":false}}"); + } else { + println!( + "Disconnected ephemeral Remote Link Child ({outcome:?}); zero background daemon running." + ); + } +} + +/// Detach when `CODEX_HOME` does not exist: succeed only if nothing needs restoring. +fn detach_without_codex_home(paths: &ChildPaths, json_mode: bool) -> u8 { + for path in [ + &paths.rollback_file, + &paths.journal_file, + &paths.state_file, + &paths.key_file, + &paths.catalog_backup_file, + ] { + match fs::symlink_metadata(path) { + Ok(_) => { + eprintln!( + "detach refused: Codex home does not exist; child recovery records were kept" + ); + return 1; + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + eprintln!("detach refused: cannot inspect child recovery material: {e}"); + return 1; + } + } + } + print_detached(json_mode, crate::journal::DetachOutcome::NotAttached); + 0 +} + +fn cmd_status(args: &[String]) -> u8 { + let wants_json = args.iter().any(|a| a == "--json"); + if args.iter().any(|a| a != "--json") { + eprintln!("Usage: ocx status [--json]"); + return 1; + } + let paths = current_paths(); + let state_opt = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) => Some(*st), + _ => None, + }; + let port = state_opt + .as_ref() + .map(|s| s.listen_port) + .unwrap_or_else(default_listen_port); + let live = probe_local_healthz(port, Duration::from_millis(500)); + let verified_live = live.filter(|h| { + h.service == "opencodex" + && h.role.as_deref() == Some("client") + && h.ephemeral_child == Some(true) + && h.port == port + && state_opt + .as_ref() + .and_then(|s| s.daemon_pid) + .is_none_or(|expected| expected == h.pid) + }); + let running = verified_live.is_some(); + let (digest, last_apply_at) = match state_opt.as_ref().and_then(|s| s.applied_settings.as_ref()) + { + Some(rec) => (Some(rec.digest.clone()), Some(rec.applied_at_ms)), + None => (None, None), + }; + let runtime_started_at = if running { + state_opt.as_ref().and_then(|s| s.daemon_started_at_ms) + } else { + None + }; + let payload = serde_json::json!({ + "schemaVersion": 1, + "ephemeralChild": true, + "runtimeRole": if state_opt.is_some() { "client" } else { "standalone" }, + "proxy": { + "running": running, + "pid": verified_live.as_ref().map(|h| h.pid), + "health": { + "ok": running, + "url": format!("http://127.0.0.1:{port}/healthz"), + } + }, + "listen": { "port": port, "hostname": "127.0.0.1" }, + "connection": match &state_opt { + Some(st) => serde_json::json!({ + "state": "connected", + "transport": "link", + "serverUrl": format!("http://127.0.0.1:{}", st.tunnel_port), + "apiKeyId": st.api_key_id, + "link": { "linkId": st.link_id, "tunnelPort": st.tunnel_port }, + "readiness": "ready", + }), + None => serde_json::json!({ "state": "disconnected" }), + }, + "appliedCodexSettingsDigest": digest, + "lastApplyAt": last_apply_at, + "runtimeStartedAt": runtime_started_at, + }); + if wants_json { + println!( + "{}", + serde_json::to_string_pretty(&payload).unwrap_or_default() + ); + } else { + println!( + "ocx-child: {} (port {port}, proxy {})", + if state_opt.is_some() { + "attached" + } else { + "detached" + }, + if running { "running" } else { "stopped" } + ); + } + 0 +} + +fn cmd_connect_status(args: &[String]) -> u8 { + let wants_json = args.iter().any(|a| a == "--json"); + let paths = current_paths(); + let payload = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) => serde_json::json!({ + "state": "connected", + "transport": "link", + "link": { "linkId": st.link_id, "tunnelPort": st.tunnel_port }, + "serverUrl": format!("http://127.0.0.1:{}", st.tunnel_port), + "apiKeyId": st.api_key_id, + "protocolVersion": 1, + "catalog": if paths.codex_catalog_path().exists() { "present" } else { "missing" }, + "token": if paths.key_file.exists() { "owned" } else { "missing" }, + "readiness": "ready", + }), + StateReadOutcome::Absent => serde_json::json!({ "state": "disconnected" }), + StateReadOutcome::Invalid(reason) => { + serde_json::json!({ "state": "invalid", "reason": reason }) + } + }; + if wants_json { + println!( + "{}", + serde_json::to_string_pretty(&payload).unwrap_or_default() + ); + } else { + println!( + "Connection: {}", + payload["state"].as_str().unwrap_or("unknown") + ); + } + 0 +} + +fn cmd_link_status(args: &[String]) -> u8 { + let wants_json = args.iter().any(|a| a == "--json"); + let paths = current_paths(); + let payload = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) => serde_json::json!({ + "role": "child", + "listener": { "state": "off", "port": null }, + "links": [], + "child": null, + "linkId": st.link_id, + "tunnelPort": st.tunnel_port, + }), + _ => serde_json::json!({ + "role": "standalone", + "listener": { "state": "off", "port": null }, + "links": [], + "child": null, + }), + }; + if wants_json { + println!( + "{}", + serde_json::to_string_pretty(&payload).unwrap_or_default() + ); + } else { + println!("Role: {}", payload["role"].as_str().unwrap_or("standalone")); + } + 0 +} diff --git a/native/ocx-child/src/codex_home.rs b/native/ocx-child/src/codex_home.rs new file mode 100644 index 00000000000..83c3628ab8a --- /dev/null +++ b/native/ocx-child/src/codex_home.rs @@ -0,0 +1,429 @@ +//! Serialization and ownership of the shared `CODEX_HOME`. +//! +//! The lifecycle lock and journal live under `OPENCODEX_HOME/child`, but `config.toml`, the +//! model catalog and `models_cache.json` live in `CODEX_HOME`, which two ocx-child installs +//! with different `OPENCODEX_HOME`s can share. Every command that writes those files first +//! takes a fixed lock file inside the canonical `CODEX_HOME` and checks a durable ownership +//! record there. The lock file is never deleted or replaced, so every process locks the same +//! inode. Ownership is never taken over by PID or timeout: a foreign, unreadable or corrupt +//! record refuses without mutation. +//! +//! Lock order everywhere: local lifecycle lock, then this lock, then the daemon start gate. +//! The guarantee covers ocx-child against ocx-child; a full OpenCodex install is detected +//! separately by `journal::assert_no_full_ocx_conflict`. + +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::journal::{remove_optional_file, validate_existing_journal_paths}; +use crate::platform::{ + DirectoryIdentity, FileLock, LockAcquireError, constant_time_eq, directory_identity, + random_hex, reject_symlink, write_private_atomic, write_regular_atomic, +}; +use crate::state::ChildPaths; + +pub const CODEX_HOME_LOCK_FILE: &str = ".ocx-child.lock"; +pub const CODEX_HOME_OWNER_FILE: &str = ".ocx-child-owner.json"; +pub const CODEX_HOME_OWNER_SCHEMA: &str = "ocx.child.codex-home-owner/1"; +const MAX_OWNER_RECORD_BYTES: u64 = 16 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OwnershipPhase { + Attaching, + Attached, + Detaching, + CleanupComplete, +} + +/// Durable ownership record stored in `CODEX_HOME`. Holds no credentials. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexHomeOwnerRecord { + pub schema: String, + pub owner_id: String, + pub opencodex_home: String, + pub opencodex_home_identity: DirectoryIdentity, + pub codex_home: String, + pub codex_home_identity: DirectoryIdentity, + pub phase: OwnershipPhase, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CodexHomeError { + /// Another process holds the lock (non-blocking acquisition only). + Busy, + /// `CODEX_HOME` does not exist and the caller did not ask to create it. + Missing, + /// The home is owned by a different OpenCodex home, or the record cannot be trusted. + Refused(String), + Io(String), +} + +impl CodexHomeError { + pub fn message(&self) -> String { + match self { + Self::Busy => "Codex home is locked by another ocx-child lifecycle command".to_owned(), + Self::Missing => "Codex home does not exist".to_owned(), + Self::Refused(msg) | Self::Io(msg) => msg.clone(), + } + } +} + +/// Failure of one guarded restoration step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestoreStepError { + /// A home no longer matches its captured identity; restoration must stop and keep every + /// recovery record. + HomeChanged(String), + /// The write or removal itself failed. + Failed(String), +} + +impl RestoreStepError { + pub fn into_message(self) -> String { + match self { + Self::HomeChanged(msg) | Self::Failed(msg) => msg, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimPolicy { + /// Claim an unowned home, or continue owning ours. Refuses while our detach is incomplete. + Attach, + /// Continue an attachment: ours in an attach phase, or adopt an unowned home whose journal + /// already belongs to this OpenCodex home. + Continue, + /// Restore and release: never claims an unowned home, refuses only a foreign owner. + Detach, +} + +enum OwnerStatus { + Absent, + Ours(CodexHomeOwnerRecord), + Foreign(String), +} + +/// Proof that this process holds the `CODEX_HOME` lock and that the home is unowned or owned +/// by this OpenCodex home. Journal application and restoration take it as a parameter. +pub struct CodexHomeGuard { + _lock: FileLock, + paths: ChildPaths, + opencodex_identity: DirectoryIdentity, + codex_identity: DirectoryIdentity, + owner: Option, + newly_claimed: bool, + phase_before_claim: Option, +} + +fn canonical_utf8(path: &Path, what: &str) -> Result<(PathBuf, String), CodexHomeError> { + let canonical = fs::canonicalize(path).map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + CodexHomeError::Missing + } else { + CodexHomeError::Io(format!("cannot resolve {what}: {e}")) + } + })?; + let text = canonical + .to_str() + .ok_or_else(|| CodexHomeError::Io(format!("{what} is not UTF-8")))? + .to_owned(); + Ok((canonical, text)) +} + +fn create_codex_home(path: &Path) -> Result<(), CodexHomeError> { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(path) + .map_err(|e| CodexHomeError::Io(format!("cannot create Codex home: {e}"))) +} + +impl CodexHomeGuard { + /// Lock `CODEX_HOME` and classify its owner, then apply `policy`. + pub fn acquire( + paths: &ChildPaths, + blocking: bool, + create_codex_home_if_missing: bool, + policy: ClaimPolicy, + ) -> Result { + if create_codex_home_if_missing { + create_codex_home(&paths.codex_home)?; + } + let (ocx, _) = canonical_utf8(&paths.opencodex_home, "OpenCodex home").map_err(|e| { + if e == CodexHomeError::Missing { + CodexHomeError::Io("OpenCodex home does not exist".to_owned()) + } else { + e + } + })?; + let (codex, _) = canonical_utf8(&paths.codex_home, "Codex home")?; + let opencodex_identity = directory_identity(&ocx).map_err(CodexHomeError::Io)?; + let codex_identity = directory_identity(&codex).map_err(CodexHomeError::Io)?; + let canonical = ChildPaths::new(ocx, codex); + let lock = FileLock::acquire_in_existing_dir(&canonical.codex_lock_path(), blocking) + .map_err(|e| match e { + LockAcquireError::Busy => CodexHomeError::Busy, + LockAcquireError::Io => { + CodexHomeError::Io("cannot open the Codex home lock file".to_owned()) + } + })?; + let mut guard = Self { + _lock: lock, + paths: canonical, + opencodex_identity, + codex_identity, + owner: None, + newly_claimed: false, + phase_before_claim: None, + }; + // The directory could have been replaced between resolution and locking. + guard.verify_homes().map_err(CodexHomeError::Refused)?; + guard.apply_policy(policy)?; + Ok(guard) + } + + /// Canonical paths of the locked homes; use these for every guarded file operation. + pub fn paths(&self) -> &ChildPaths { + &self.paths + } + + pub fn is_owner(&self) -> bool { + self.owner.is_some() + } + + /// True when this guard created the ownership record (first attach). + pub fn newly_claimed(&self) -> bool { + self.newly_claimed + } + + pub fn phase(&self) -> Option { + self.owner.as_ref().map(|r| r.phase) + } + + /// Confirm both homes are still the directories identified when the lock was taken. + pub fn verify_homes(&self) -> Result<(), String> { + let ocx = directory_identity(&self.paths.opencodex_home) + .map_err(|e| format!("OpenCodex home changed during the operation: {e}"))?; + let codex = directory_identity(&self.paths.codex_home) + .map_err(|e| format!("Codex home changed during the operation: {e}"))?; + if ocx != self.opencodex_identity || codex != self.codex_identity { + return Err( + "a home directory was replaced during the operation; recovery records were kept" + .to_owned(), + ); + } + Ok(()) + } + + /// Run one restoration write, rename or removal, re-verifying both home identities + /// immediately before and immediately after it. Paths must come from `paths()`. + /// + /// This narrows, but cannot close, the window between the check and the system call: a + /// process running as the same user can still swap a directory in between. Such a process + /// gains nothing from that, since it can already write these files directly. + pub fn restore_step( + &self, + op: impl FnOnce() -> Result, + ) -> Result { + crate::test_hooks::barrier("restore-before-write"); + self.verify_homes().map_err(RestoreStepError::HomeChanged)?; + let out = op().map_err(RestoreStepError::Failed); + crate::test_hooks::barrier("restore-after-write"); + self.verify_homes().map_err(RestoreStepError::HomeChanged)?; + out + } + + fn read_owner(&self) -> Result { + let record_path = self.paths.codex_owner_path(); + let corrupt = |detail: String| { + CodexHomeError::Refused(format!( + "Codex home ownership record {} is unreadable ({detail}); refusing to modify this \ + Codex home. Remove it only after confirming no ocx-child uses this Codex home.", + record_path.display() + )) + }; + reject_symlink(&record_path).map_err(&corrupt)?; + let file = match fs::File::open(&record_path) { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(OwnerStatus::Absent), + Err(e) => return Err(corrupt(e.to_string())), + }; + let mut bytes = Vec::new(); + file.take(MAX_OWNER_RECORD_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| corrupt(e.to_string()))?; + if bytes.len() as u64 > MAX_OWNER_RECORD_BYTES { + return Err(corrupt("record exceeds size limit".to_owned())); + } + let record: CodexHomeOwnerRecord = + serde_json::from_slice(&bytes).map_err(|e| corrupt(e.to_string()))?; + if record.schema != CODEX_HOME_OWNER_SCHEMA { + return Err(corrupt(format!("unsupported schema {}", record.schema))); + } + let ours = record.opencodex_home == path_text(&self.paths.opencodex_home) + && record.opencodex_home_identity == self.opencodex_identity + && record.codex_home == path_text(&self.paths.codex_home) + && record.codex_home_identity == self.codex_identity + && self + .local_owner_id() + .map_err(CodexHomeError::Io)? + .is_some_and(|id| constant_time_eq(id.as_bytes(), record.owner_id.as_bytes())); + if ours { + return Ok(OwnerStatus::Ours(record)); + } + Ok(OwnerStatus::Foreign(format!( + "Codex home {} is owned by another ocx-child (OPENCODEX_HOME={}); run \ + `ocx-child detach` with that OPENCODEX_HOME first. If that home no longer exists, \ + remove {} after confirming no ocx-child uses this Codex home.", + self.paths.codex_home.display(), + record.opencodex_home, + record_path.display() + ))) + } + + fn local_owner_id(&self) -> Result, String> { + reject_symlink(&self.paths.owner_id_file)?; + match fs::read(&self.paths.owner_id_file) { + Ok(bytes) => { + let text = String::from_utf8(bytes).unwrap_or_default(); + let id = text.trim(); + Ok( + (id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())) + .then(|| id.to_owned()), + ) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("cannot read local owner id: {e}")), + } + } + + fn apply_policy(&mut self, policy: ClaimPolicy) -> Result<(), CodexHomeError> { + match self.read_owner()? { + OwnerStatus::Foreign(msg) => Err(CodexHomeError::Refused(msg)), + OwnerStatus::Ours(record) => { + let phase = record.phase; + self.owner = Some(record); + match (policy, phase) { + (ClaimPolicy::Detach, _) => Ok(()), + (ClaimPolicy::Attach, OwnershipPhase::Detaching) + | (ClaimPolicy::Continue, OwnershipPhase::Detaching) + | (ClaimPolicy::Continue, OwnershipPhase::CleanupComplete) => { + Err(CodexHomeError::Refused( + "a previous ocx-child detach of this Codex home is incomplete; \ + run `ocx-child detach` again" + .to_owned(), + )) + } + (ClaimPolicy::Attach, _) => { + self.phase_before_claim = Some(phase); + self.set_phase(OwnershipPhase::Attaching) + .map_err(CodexHomeError::Io) + } + (ClaimPolicy::Continue, _) => Ok(()), + } + } + OwnerStatus::Absent => match policy { + ClaimPolicy::Detach => Ok(()), + ClaimPolicy::Attach => { + // A journal of ours that targets another Codex home must not gain a + // second ownership record; journal validation reports the conflict. + validate_existing_journal_paths(&self.paths) + .map_err(CodexHomeError::Refused)?; + self.claim(OwnershipPhase::Attaching) + } + ClaimPolicy::Continue => match validate_existing_journal_paths(&self.paths) { + Ok(true) => self.claim(OwnershipPhase::Attached), + Ok(false) => Ok(()), + Err(e) => Err(CodexHomeError::Refused(e)), + }, + }, + } + } + + fn claim(&mut self, phase: OwnershipPhase) -> Result<(), CodexHomeError> { + let owner_id = match self.local_owner_id().map_err(CodexHomeError::Io)? { + Some(id) => id, + None => { + let id = random_hex(16).map_err(CodexHomeError::Io)?; + write_private_atomic(&self.paths.owner_id_file, id.as_bytes()) + .map_err(CodexHomeError::Io)?; + id + } + }; + let record = CodexHomeOwnerRecord { + schema: CODEX_HOME_OWNER_SCHEMA.to_owned(), + owner_id, + opencodex_home: path_text(&self.paths.opencodex_home), + opencodex_home_identity: self.opencodex_identity.clone(), + codex_home: path_text(&self.paths.codex_home), + codex_home_identity: self.codex_identity.clone(), + phase, + }; + self.write_record(&record).map_err(CodexHomeError::Io)?; + self.owner = Some(record); + self.newly_claimed = true; + Ok(()) + } + + fn write_record(&self, record: &CodexHomeOwnerRecord) -> Result<(), String> { + self.verify_homes()?; + let mut bytes = serde_json::to_vec_pretty(record) + .map_err(|e| format!("cannot serialize ownership record: {e}"))?; + bytes.push(b'\n'); + write_regular_atomic(&self.paths.codex_owner_path(), &bytes) + } + + /// Durably record a new phase. A no-op when this guard does not own the home. + pub fn set_phase(&mut self, phase: OwnershipPhase) -> Result<(), String> { + let Some(mut record) = self.owner.clone() else { + return Ok(()); + }; + if record.phase == phase { + return Ok(()); + } + record.phase = phase; + self.write_record(&record)?; + self.owner = Some(record); + Ok(()) + } + + /// Undo this guard's claim after an attach failed without leaving changes behind: release + /// a record this guard created, or put back the phase an existing record had. + pub fn revert_claim(&mut self) -> Result<(), String> { + if self.newly_claimed { + return self.release(); + } + match self.phase_before_claim.take() { + Some(phase) => self.set_phase(phase), + None => Ok(()), + } + } + + /// Release ownership after cleanup: durably mark cleanup complete, then remove the record, + /// then the local owner id. A crash at any step leaves a record `detach` can resume from. + pub fn release(&mut self) -> Result<(), String> { + if self.owner.is_none() { + return Ok(()); + } + self.set_phase(OwnershipPhase::CleanupComplete)?; + self.verify_homes()?; + remove_optional_file(&self.paths.codex_owner_path())?; + self.owner = None; + self.newly_claimed = false; + remove_optional_file(&self.paths.owner_id_file) + } +} + +fn path_text(path: &Path) -> String { + path.to_str().unwrap_or_default().to_owned() +} diff --git a/native/ocx-child/src/codex_settings.rs b/native/ocx-child/src/codex_settings.rs new file mode 100644 index 00000000000..bf64adc775f --- /dev/null +++ b/native/ocx-child/src/codex_settings.rs @@ -0,0 +1,231 @@ +use serde::{Deserialize, Serialize}; + +use crate::platform::sha256_hex; + +pub const HOME_CODEX_SETTINGS_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SubagentDefaults { + pub model: String, + pub reasoning_effort: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HomeCodexSettings { + pub fast_mode: Option, + pub multi_agent_mode: Option, + pub web_search_sidecar_enabled: bool, + pub subagent_defaults: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HomeIdentity { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HomeCodexSettingsDocument { + pub version: u32, + pub home: HomeIdentity, + pub settings: HomeCodexSettings, +} + +fn is_ecmascript_whitespace(ch: char) -> bool { + matches!( + ch as u32, + 0x0009 | 0x000A | 0x000B | 0x000C | 0x000D | 0x0020 | 0x00A0 | 0x1680 | 0x2000 + ..=0x200A | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000 | 0xFEFF + ) +} + +pub fn sanitize_safe_label(value: &str, max_utf16_units: usize) -> Option { + let trimmed = value.trim_matches(is_ecmascript_whitespace); + let utf16_len = trimmed.encode_utf16().count(); + if trimmed.is_empty() || utf16_len > max_utf16_units { + return None; + } + for ch in trimmed.chars() { + let cp = ch as u32; + if cp < 0x20 + || cp == 0x7f + || (0x202a..=0x202e).contains(&cp) + || (0x2066..=0x2069).contains(&cp) + { + return None; + } + } + Some(trimmed.to_owned()) +} + +pub fn is_valid_reasoning_effort_token(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.len() > 32 { + return false; + } + if !bytes[0].is_ascii_lowercase() { + return false; + } + bytes[1..] + .iter() + .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') +} + +fn exact_keys(obj: &serde_json::Map, keys: &[&str]) -> bool { + obj.len() == keys.len() && keys.iter().all(|k| obj.contains_key(*k)) +} + +/// Strict parser matching `parseHomeCodexSettingsDocument` in `src/link/codex-settings.ts`. +pub fn parse_home_codex_settings_document(bytes: &[u8]) -> Option { + let root: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let root_obj = root.as_object()?; + if !exact_keys(root_obj, &["version", "home", "settings"]) { + return None; + } + let version = root_obj.get("version")?.as_u64()?; + if version != u64::from(HOME_CODEX_SETTINGS_VERSION) { + return None; + } + let home_obj = root_obj.get("home")?.as_object()?; + if !exact_keys(home_obj, &["name", "version"]) { + return None; + } + let name = sanitize_safe_label(home_obj.get("name")?.as_str()?, 255)?; + let home_version = sanitize_safe_label(home_obj.get("version")?.as_str()?, 64)?; + let s_obj = root_obj.get("settings")?.as_object()?; + if !exact_keys( + s_obj, + &[ + "fastMode", + "multiAgentMode", + "webSearchSidecarEnabled", + "subagentDefaults", + ], + ) { + return None; + } + let fast_mode = match s_obj.get("fastMode")? { + serde_json::Value::Null => None, + serde_json::Value::Bool(b) => Some(*b), + _ => return None, + }; + let multi_agent_mode = match s_obj.get("multiAgentMode")? { + serde_json::Value::Null => None, + serde_json::Value::String(m) if matches!(m.as_str(), "v1" | "default" | "v2") => { + Some(m.clone()) + } + _ => return None, + }; + let web_search_sidecar_enabled = s_obj.get("webSearchSidecarEnabled")?.as_bool()?; + let subagent_defaults = match s_obj.get("subagentDefaults")? { + serde_json::Value::Null => None, + serde_json::Value::Object(d_obj) => { + if !exact_keys(d_obj, &["model", "reasoningEffort"]) { + return None; + } + let model = sanitize_safe_label(d_obj.get("model")?.as_str()?, 256)?; + let reasoning_effort = match d_obj.get("reasoningEffort")? { + serde_json::Value::Null => None, + serde_json::Value::String(eff) if is_valid_reasoning_effort_token(eff) => { + Some(eff.clone()) + } + _ => return None, + }; + Some(SubagentDefaults { + model, + reasoning_effort, + }) + } + _ => return None, + }; + Some(HomeCodexSettingsDocument { + version: HOME_CODEX_SETTINGS_VERSION, + home: HomeIdentity { + name, + version: home_version, + }, + settings: HomeCodexSettings { + fast_mode, + multi_agent_mode, + web_search_sidecar_enabled, + subagent_defaults, + }, + }) +} + +impl HomeCodexSettings { + pub fn validate(&self) -> bool { + if let Some(mode) = &self.multi_agent_mode + && !matches!(mode.as_str(), "v1" | "default" | "v2") + { + return false; + } + if let Some(sub) = &self.subagent_defaults { + if sanitize_safe_label(&sub.model, 256).is_none() { + return false; + } + if let Some(eff) = &sub.reasoning_effort + && !is_valid_reasoning_effort_token(eff) + { + return false; + } + } + true + } +} + +impl HomeCodexSettingsDocument { + pub fn validate(&self) -> bool { + self.version == HOME_CODEX_SETTINGS_VERSION + && sanitize_safe_label(&self.home.name, 255).is_some() + && sanitize_safe_label(&self.home.version, 64).is_some() + && self.settings.validate() + } +} + +#[derive(Serialize)] +struct DigestEnvelope<'a> { + version: u32, + settings: DigestSettings<'a>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DigestSettings<'a> { + fast_mode: Option, + multi_agent_mode: &'a Option, + web_search_sidecar_enabled: bool, + subagent_defaults: Option>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DigestSubagent<'a> { + model: &'a str, + reasoning_effort: &'a Option, +} + +/// Produces the exact OCX-6 compact JSON string hashed by `codexSettingsDigest` in `src/link/codex-settings.ts`. +pub fn codex_settings_digest_payload(settings: &HomeCodexSettings) -> String { + let env = DigestEnvelope { + version: HOME_CODEX_SETTINGS_VERSION, + settings: DigestSettings { + fast_mode: settings.fast_mode, + multi_agent_mode: &settings.multi_agent_mode, + web_search_sidecar_enabled: settings.web_search_sidecar_enabled, + subagent_defaults: settings.subagent_defaults.as_ref().map(|d| DigestSubagent { + model: &d.model, + reasoning_effort: &d.reasoning_effort, + }), + }, + }; + serde_json::to_string(&env).expect("DigestEnvelope serialization is infallible") +} + +pub fn codex_settings_digest(settings: &HomeCodexSettings) -> String { + sha256_hex(codex_settings_digest_payload(settings).as_bytes()) +} diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs new file mode 100644 index 00000000000..2f29f2b2853 --- /dev/null +++ b/native/ocx-child/src/daemon.rs @@ -0,0 +1,363 @@ +use std::io::{BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::process::{Child, ChildStdin}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +use crate::http_wire::{read_bounded_body, read_response_head, response_framing}; +use crate::platform::{ + SecretBytes, constant_time_eq, now_epoch_ms, process_is_alive, random_hex, spawn_detached_serve, +}; +use crate::relay_auth::compute_child_shutdown_proof; +use crate::state::{ChildPaths, ChildStateV1, write_child_state}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalHealthz { + pub service: String, + pub role: Option, + pub ephemeral_child: Option, + pub pid: u32, + pub port: u16, + pub started_at: Option, + #[serde(default)] + pub generation_committed: Option, +} + +pub fn pick_ephemeral_link_port() -> Result { + let seed_hex = random_hex(2).unwrap_or_else(|_| "0000".to_owned()); + let offset = u16::from_str_radix(&seed_hex, 16).unwrap_or(0) % 10_000; + for step in 0..64u16 { + let candidate = 20_000 + ((offset.wrapping_add(step.wrapping_mul(137))) % 10_000); + if let Ok(listener) = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], candidate))) { + let p = listener.local_addr().map(|a| a.port()).unwrap_or(candidate); + drop(listener); + return Ok(p); + } + } + let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .map_err(|e| format!("failed to allocate loopback port: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("local_addr failed: {e}"))? + .port(); + Ok(port) +} + +pub fn probe_local_healthz(port: u16, timeout: Duration) -> Option { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let mut stream = TcpStream::connect_timeout(&addr, timeout).ok()?; + let _ = stream.set_read_timeout(Some(timeout)); + let _ = stream.set_write_timeout(Some(timeout)); + let req = + format!("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes()).ok()?; + stream.flush().ok()?; + let mut reader = BufReader::new(stream); + let head = read_response_head(&mut reader).ok()?; + if head.status != 200 { + return None; + } + let framing = response_framing(head.status, "GET", &head.headers).ok()?; + let body = read_bounded_body(&mut reader, framing, 64 * 1024).ok()?; + serde_json::from_slice(&body).ok() +} + +pub fn is_port_bound(port: u16) -> bool { + TcpStream::connect_timeout( + &SocketAddr::from(([127, 0, 0, 1], port)), + Duration::from_millis(150), + ) + .is_ok() +} + +pub fn ensure_port_available_for_attach(port: u16, owned_pid: Option) -> Result<(), String> { + if let Some(health) = probe_local_healthz(port, Duration::from_millis(500)) { + if health.service == "opencodex" + && health.ephemeral_child == Some(true) + && owned_pid == Some(health.pid) + { + return Ok(()); + } + return Err(format!( + "attach refused: 127.0.0.1:{port} is already served by foreign listener (pid {})", + health.pid + )); + } + if TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))).is_err() { + return Err(format!( + "attach refused: 127.0.0.1:{port} is already in use" + )); + } + Ok(()) +} + +fn send_attested_shutdown(port: u16, shutdown_secret: &str) -> bool { + let Ok(nonce) = random_hex(32) else { + return false; + }; + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); + let req = format!( + "POST /__ocx_child/shutdown HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nx-ocx-child-shutdown-challenge: {nonce}\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n" + ); + if stream.write_all(req.as_bytes()).is_err() || stream.flush().is_err() { + return false; + } + let Ok(read_stream) = stream.try_clone() else { + return false; + }; + let mut reader = BufReader::new(read_stream); + let Ok(head) = read_response_head(&mut reader) else { + return false; + }; + let proof_count = head + .headers + .iter() + .filter(|(k, _)| k.eq_ignore_ascii_case("x-ocx-child-shutdown-proof")) + .count(); + let nonce_count = head + .headers + .iter() + .filter(|(k, _)| k.eq_ignore_ascii_case("x-ocx-child-shutdown-nonce")) + .count(); + let Some(server_nonce) = head.header("x-ocx-child-shutdown-nonce") else { + return false; + }; + let binding = format!("{nonce}{server_nonce}"); + let Some(expected) = compute_child_shutdown_proof(shutdown_secret, "listener", &binding) else { + return false; + }; + let Some(caller) = compute_child_shutdown_proof(shutdown_secret, "caller", &binding) else { + return false; + }; + let lengths: Vec<&str> = head + .headers + .iter() + .filter(|(k, _)| k.eq_ignore_ascii_case("content-length")) + .map(|(_, v)| v.as_str()) + .collect(); + if head.status != 204 + || proof_count != 1 + || nonce_count != 1 + || !crate::relay_auth::is_valid_hex64(server_nonce) + || lengths != ["0"] + || head + .headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("transfer-encoding")) + || !head + .header("x-ocx-child-shutdown-proof") + .is_some_and(|proof| constant_time_eq(proof.as_bytes(), expected.as_bytes())) + { + return false; + } + let req = format!( + "POST /__ocx_child/shutdown HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nx-ocx-child-shutdown-proof: {caller}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + if stream.write_all(req.as_bytes()).is_err() || stream.flush().is_err() { + return false; + } + read_response_head(&mut reader).is_ok_and(|h| h.status == 200) +} + +pub fn stop_owned_daemon(state: &ChildStateV1) -> Result<(), String> { + let port = state.listen_port; + let live_health = probe_local_healthz(port, Duration::from_millis(500)); + let observed_pid = match &live_health { + Some(h) + if h.service == "opencodex" + && h.ephemeral_child == Some(true) + && h.port == port + && h.pid != 0 => + { + h.pid + } + None if !is_port_bound(port) + && state.daemon_pid.is_none_or(|pid| !process_is_alive(pid)) => + { + return Ok(()); + } + _ => return Err("cannot establish that the child daemon has stopped".to_owned()), + }; + + if !send_attested_shutdown(port, &state.shutdown_secret) { + return Err("child daemon did not accept the authenticated shutdown".to_owned()); + } + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + let port_down = !is_port_bound(port); + let pid_down = !process_is_alive(observed_pid); + if port_down && pid_down { + return Ok(()); + } + thread::sleep(Duration::from_millis(40)); + } + + Err(format!( + "child daemon on 127.0.0.1:{port} did not stop; refusing unverified termination" + )) +} + +/// A daemon that is ready but not yet committed. It answers only `/healthz` until +/// `commit` writes the commit line for its generation. Dropping it uncommitted kills the +/// child; if this process dies instead, the child sees EOF on stdin and exits without serving. +pub struct PendingDaemon { + child: Option, + stdin: Option, + generation: String, + pid: u32, + started_ms: u64, + port: u16, +} + +impl PendingDaemon { + pub fn pid(&self) -> u32 { + self.pid + } + + /// Commit the generation. Call only after the state naming this generation and PID is + /// durable (and, for attach, after the rollback record is gone). + pub fn commit(mut self) -> Result<(u32, u64), String> { + crate::test_hooks::barrier("daemon-before-commit"); + let mut stdin = self.stdin.take().ok_or("spawned child stdin unavailable")?; + stdin + .write_all( + format!( + "commit {} +", + self.generation + ) + .as_bytes(), + ) + .and_then(|()| stdin.flush()) + .map_err(|e| format!("failed to commit spawned child: {e}"))?; + drop(stdin); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(child) = self.child.as_mut() + && let Some(status) = child + .try_wait() + .map_err(|e| format!("failed to observe spawned child: {e}"))? + { + return Err(format!("ocx-child serve exited during commit: {status}")); + } + if let Some(h) = probe_local_healthz(self.port, Duration::from_millis(400)) + && h.pid == self.pid + && h.generation_committed == Some(true) + { + // Leave the committed child running; dropping a std Child does not kill it. + self.child = None; + return Ok((self.pid, self.started_ms)); + } + thread::sleep(Duration::from_millis(20)); + } + Err(format!( + "ocx-child serve on 127.0.0.1:{} did not confirm its commit", + self.port + )) + } +} + +impl Drop for PendingDaemon { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// Concatenate parts into a buffer allocated once at its final size and wiped on drop. +fn secret_line(parts: &[&[u8]]) -> SecretBytes { + let mut line = Vec::with_capacity(parts.iter().map(|p| p.len()).sum()); + for part in parts { + line.extend_from_slice(part); + } + SecretBytes::new(line) +} + +/// Stop the owned daemon and start a new generation. The new generation is recorded in +/// state before the spawn (the child's start gate requires it) and the PID after readiness; +/// the caller commits once its own durable work is done. +pub fn start_or_rollover_daemon( + paths: &ChildPaths, + state: &mut ChildStateV1, + min_started_at_ms: Option, +) -> Result { + stop_owned_daemon(state)?; + if let Some(floor_ms) = min_started_at_ms { + let now = now_epoch_ms(); + if now < floor_ms { + thread::sleep(Duration::from_millis((floor_ms - now).min(100))); + } + } + ensure_port_available_for_attach(state.listen_port, None)?; + let exe = std::env::current_exe() + .map_err(|e| format!("failed to resolve current executable: {e}"))?; + let generation = random_hex(16)?; + state.daemon_pid = None; + state.daemon_started_at_ms = None; + state.daemon_generation = Some(generation.clone()); + write_child_state(&paths.state_file, state)?; + let start_line = secret_line(&[ + b"ocx-child-start/1 ", + generation.as_bytes(), + b" ", + state.shutdown_secret.as_bytes(), + b"\n", + ]); + let spawned = spawn_detached_serve( + &exe, + &paths.opencodex_home, + &paths.codex_home, + state.listen_port, + start_line.as_slice(), + )?; + let spawned_pid = spawned.id(); + let mut pending = PendingDaemon { + child: Some(spawned), + stdin: None, + generation, + pid: spawned_pid, + started_ms: 0, + port: state.listen_port, + }; + pending.stdin = pending.child.as_mut().and_then(|c| c.stdin.take()); + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Some(child) = pending.child.as_mut() + && let Some(status) = child + .try_wait() + .map_err(|e| format!("failed to observe spawned child: {e}"))? + { + return Err(format!("ocx-child serve exited before readiness: {status}")); + } + if let Some(h) = probe_local_healthz(state.listen_port, Duration::from_millis(400)) + && h.service == "opencodex" + && h.role.as_deref() == Some("client") + && h.ephemeral_child == Some(true) + && h.pid == spawned_pid + { + let started_ms = now_epoch_ms().max(min_started_at_ms.unwrap_or(0)); + state.daemon_pid = Some(spawned_pid); + state.daemon_started_at_ms = Some(started_ms); + write_child_state(&paths.state_file, state) + .map_err(|e| format!("failed to record spawned child: {e}"))?; + pending.started_ms = started_ms; + return Ok(pending); + } + thread::sleep(Duration::from_millis(50)); + } + Err(format!( + "ocx-child serve failed to answer /healthz on 127.0.0.1:{}", + state.listen_port + )) +} diff --git a/native/ocx-child/src/http_wire.rs b/native/ocx-child/src/http_wire.rs new file mode 100644 index 00000000000..30cd7280e45 --- /dev/null +++ b/native/ocx-child/src/http_wire.rs @@ -0,0 +1,619 @@ +use std::collections::HashSet; +use std::io::{self, BufRead, Write}; + +pub const HUB_RELAY_HEADER_MAX_BYTES: usize = 64 * 1024; +pub const LINK_RELAY_BODY_MAX_BYTES: u64 = 256 * 1024 * 1024; +pub const HUB_RELAY_RESPONSE_BODY_MAX_BYTES: u64 = 16 * 1024 * 1024; + +pub const CALLER_CREDENTIAL_HEADERS: &[&str] = &[ + "authorization", + "api-key", + "x-api-key", + "x-goog-api-key", + "x-opencodex-api-key", + "chatgpt-account-id", + "cookie", +]; + +pub const HOP_BY_HOP_HEADERS: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +const REQUEST_OMITTED_HEADERS: &[&str] = &["content-length", "host"]; +const RESPONSE_OMITTED_HEADERS: &[&str] = &["content-encoding", "content-length"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FramingError { + Invalid, + Smuggling, + HeadersTooLarge, + Eof, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyFraming { + None, + ContentLength(u64), + Chunked, +} + +#[derive(Debug, Clone)] +pub struct HttpRequestHead { + pub method: String, + pub target: String, + pub path: String, + pub query: String, + pub headers: Vec<(String, String)>, +} + +#[derive(Debug, Clone)] +pub struct HttpResponseHead { + pub status: u16, + pub reason: String, + pub headers: Vec<(String, String)>, +} + +impl HttpRequestHead { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } +} + +impl HttpResponseHead { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } +} + +fn read_crlf_line_bounded( + reader: &mut R, + remaining: &mut usize, +) -> Result, FramingError> { + let mut buf = Vec::new(); + loop { + let available = reader.fill_buf().map_err(|_| FramingError::Invalid)?; + if available.is_empty() { + if buf.is_empty() { + return Ok(None); + } + return Err(FramingError::Invalid); + } + if let Some(pos) = available.iter().position(|&b| b == b'\n') { + let take = pos + 1; + if take > *remaining { + return Err(FramingError::HeadersTooLarge); + } + buf.extend_from_slice(&available[..take]); + reader.consume(take); + *remaining -= take; + break; + } + let len = available.len(); + if len > *remaining { + return Err(FramingError::HeadersTooLarge); + } + buf.extend_from_slice(available); + reader.consume(len); + *remaining -= len; + } + if buf.ends_with(b"\r\n") { + buf.truncate(buf.len() - 2); + } else if buf.ends_with(b"\n") { + buf.truncate(buf.len() - 1); + } + if buf.contains(&b'\r') || buf.contains(&b'\n') { + return Err(FramingError::Invalid); + } + String::from_utf8(buf) + .map(Some) + .map_err(|_| FramingError::Invalid) +} + +pub fn read_request_head(reader: &mut R) -> Result { + let mut budget = HUB_RELAY_HEADER_MAX_BYTES; + let Some(req_line) = read_crlf_line_bounded(reader, &mut budget)? else { + return Err(FramingError::Eof); + }; + let mut parts = req_line.split(' '); + let method = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or(FramingError::Invalid)?; + let target = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or(FramingError::Invalid)?; + let version = parts.next().ok_or(FramingError::Invalid)?; + if parts.next().is_some() + || (!version.eq_ignore_ascii_case("HTTP/1.1") && !version.eq_ignore_ascii_case("HTTP/1.0")) + { + return Err(FramingError::Invalid); + } + if !target.starts_with('/') { + return Err(FramingError::Invalid); + } + let (path, query) = match target.find('?') { + Some(idx) => (target[..idx].to_owned(), target[idx..].to_owned()), + None => (target.to_owned(), String::new()), + }; + let mut headers = Vec::new(); + let mut measured_header_bytes = 0usize; + loop { + let line = read_crlf_line_bounded(reader, &mut budget)?.ok_or(FramingError::Invalid)?; + if line.is_empty() { + break; + } + // Reject obs-fold leading whitespace + if line.starts_with(' ') || line.starts_with('\t') { + return Err(FramingError::Smuggling); + } + let (raw_name, raw_val) = line.split_once(':').ok_or(FramingError::Invalid)?; + if raw_name.is_empty() + || raw_name + .bytes() + .any(|b| b.is_ascii_whitespace() || !(0x21..0x7f).contains(&b)) + { + return Err(FramingError::Invalid); + } + let value = raw_val.trim().to_owned(); + measured_header_bytes = + measured_header_bytes.saturating_add(raw_name.len() + value.len() + 4); + if measured_header_bytes > HUB_RELAY_HEADER_MAX_BYTES { + return Err(FramingError::HeadersTooLarge); + } + headers.push((raw_name.to_owned(), value)); + } + Ok(HttpRequestHead { + method: method.to_owned(), + target: target.to_owned(), + path, + query, + headers, + }) +} + +pub fn read_response_head(reader: &mut R) -> Result { + let mut budget = HUB_RELAY_HEADER_MAX_BYTES; + let Some(status_line) = read_crlf_line_bounded(reader, &mut budget)? else { + return Err(FramingError::Eof); + }; + let mut parts = status_line.splitn(3, ' '); + let version = parts.next().ok_or(FramingError::Invalid)?; + if !version.starts_with("HTTP/1.") { + return Err(FramingError::Invalid); + } + let status_str = parts.next().ok_or(FramingError::Invalid)?; + let status: u16 = status_str.parse().map_err(|_| FramingError::Invalid)?; + let reason = parts.next().unwrap_or("").to_owned(); + let mut headers = Vec::new(); + let mut measured_header_bytes = 0usize; + loop { + let line = read_crlf_line_bounded(reader, &mut budget)?.ok_or(FramingError::Invalid)?; + if line.is_empty() { + break; + } + if line.starts_with(' ') || line.starts_with('\t') { + return Err(FramingError::Smuggling); + } + let (raw_name, raw_val) = line.split_once(':').ok_or(FramingError::Invalid)?; + if raw_name.is_empty() { + return Err(FramingError::Invalid); + } + let value = raw_val.trim().to_owned(); + measured_header_bytes = + measured_header_bytes.saturating_add(raw_name.len() + value.len() + 4); + if measured_header_bytes > HUB_RELAY_HEADER_MAX_BYTES { + return Err(FramingError::HeadersTooLarge); + } + headers.push((raw_name.to_owned(), value)); + } + Ok(HttpResponseHead { + status, + reason, + headers, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedRequestHeaders { + pub framing: BodyFraming, + pub sanitized: Vec<(String, String)>, +} + +/// Validates framing (`linkFramingHeaders` + `validateHubRelayRequestHeaders`) and strips +/// hop-by-hop, `Connection`-nominated, `host`/`content-length`, and `CALLER_CREDENTIAL_HEADERS`. +pub fn validate_and_sanitize_request_headers( + raw_headers: &[(String, String)], +) -> Result { + let mut measured_bytes = 0usize; + for (name, value) in raw_headers { + if name.is_empty() + || name.contains('\r') + || name.contains('\n') + || value.contains('\r') + || value.contains('\n') + { + return Err(FramingError::Invalid); + } + measured_bytes = measured_bytes.saturating_add(name.len() + value.len() + 4); + if measured_bytes > HUB_RELAY_HEADER_MAX_BYTES { + return Err(FramingError::HeadersTooLarge); + } + } + + let mut content_lengths: Vec<&str> = Vec::new(); + let mut transfer_encodings: Vec<&str> = Vec::new(); + let mut connection_named: HashSet = HashSet::new(); + + for (name, value) in raw_headers { + let lower = name.to_ascii_lowercase(); + match lower.as_str() { + "upgrade" => return Err(FramingError::Smuggling), + "content-length" => { + let trimmed = value.trim(); + if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) { + return Err(FramingError::Smuggling); + } + content_lengths.push(trimmed); + } + "transfer-encoding" => { + transfer_encodings.push(value.trim()); + } + "connection" => { + for token in value.split(',') { + let t = token.trim().to_ascii_lowercase(); + if !t.is_empty() { + connection_named.insert(t); + } + } + } + _ => {} + } + } + + if content_lengths.len() > 1 { + return Err(FramingError::Smuggling); + } + let framing = if !transfer_encodings.is_empty() { + if !content_lengths.is_empty() + || transfer_encodings.len() != 1 + || !transfer_encodings[0].eq_ignore_ascii_case("chunked") + { + return Err(FramingError::Smuggling); + } + BodyFraming::Chunked + } else if let Some(cl_str) = content_lengths.first() { + let len: u64 = cl_str.parse().map_err(|_| FramingError::Smuggling)?; + BodyFraming::ContentLength(len) + } else { + BodyFraming::None + }; + + let mut sanitized = Vec::new(); + for (name, value) in raw_headers { + let lower = name.to_ascii_lowercase(); + if HOP_BY_HOP_HEADERS.contains(&lower.as_str()) + || REQUEST_OMITTED_HEADERS.contains(&lower.as_str()) + || CALLER_CREDENTIAL_HEADERS.contains(&lower.as_str()) + || connection_named.contains(&lower) + { + continue; + } + sanitized.push((lower, value.clone())); + } + + Ok(ValidatedRequestHeaders { framing, sanitized }) +} + +pub fn sanitize_response_headers(raw_headers: &[(String, String)]) -> Vec<(String, String)> { + let mut connection_named: HashSet = HashSet::new(); + for (name, value) in raw_headers { + if name.eq_ignore_ascii_case("connection") { + for token in value.split(',') { + let t = token.trim().to_ascii_lowercase(); + if !t.is_empty() { + connection_named.insert(t); + } + } + } + } + let mut out = Vec::new(); + for (name, value) in raw_headers { + let lower = name.to_ascii_lowercase(); + if HOP_BY_HOP_HEADERS.contains(&lower.as_str()) + || RESPONSE_OMITTED_HEADERS.contains(&lower.as_str()) + || connection_named.contains(&lower) + { + continue; + } + out.push((name.clone(), value.clone())); + } + out +} + +pub fn is_sse_headers(headers: &[(String, String)]) -> bool { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("content-type")) + .and_then(|(_, v)| v.split(';').next()) + .is_some_and(|ct| ct.trim().eq_ignore_ascii_case("text/event-stream")) +} + +pub fn response_framing( + status: u16, + method: &str, + headers: &[(String, String)], +) -> Result { + if method.eq_ignore_ascii_case("HEAD") + || (100..200).contains(&status) + || status == 204 + || status == 304 + { + return Ok(BodyFraming::None); + } + let mut cl: Option = None; + let mut chunked = false; + for (name, value) in headers { + if name.eq_ignore_ascii_case("transfer-encoding") { + if value.to_ascii_lowercase().contains("chunked") { + chunked = true; + } else { + return Err(FramingError::Smuggling); + } + } else if name.eq_ignore_ascii_case("content-length") { + let trimmed = value.trim(); + if trimmed.is_empty() || !trimmed.bytes().all(|b| b.is_ascii_digit()) { + return Err(FramingError::Smuggling); + } + let parsed: u64 = trimmed.parse().map_err(|_| FramingError::Smuggling)?; + if cl.is_some() { + return Err(FramingError::Smuggling); + } + cl = Some(parsed); + } + } + if chunked { + Ok(BodyFraming::Chunked) + } else if let Some(len) = cl { + Ok(BodyFraming::ContentLength(len)) + } else { + Ok(BodyFraming::None) + } +} + +pub fn read_bounded_body( + reader: &mut R, + framing: BodyFraming, + max_bytes: u64, +) -> Result, io::Error> { + match framing { + BodyFraming::None => Ok(Vec::new()), + BodyFraming::ContentLength(len) => { + if len > max_bytes { + return Err(io::Error::new(io::ErrorKind::InvalidData, "body_too_large")); + } + let mut out = vec![0u8; len as usize]; + reader.read_exact(&mut out)?; + Ok(out) + } + BodyFraming::Chunked => { + let mut out = Vec::new(); + let mut meta_budget = HUB_RELAY_HEADER_MAX_BYTES; + loop { + let size_line = read_crlf_line_bounded(reader, &mut meta_budget) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "chunk metadata overflow or invalid", + ) + })? + .ok_or_else(|| { + io::Error::new(io::ErrorKind::UnexpectedEof, "unexpected chunk eof") + })?; + let hex_part = size_line.trim().split(';').next().unwrap_or("").trim(); + let chunk_len = u64::from_str_radix(hex_part, 16).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid chunk size") + })?; + if chunk_len == 0 { + // Consume trailing headers up to blank CRLF within bounded metadata budget + loop { + let trailer = read_crlf_line_bounded(reader, &mut meta_budget) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "chunk trailer overflow or invalid", + ) + })? + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + "unexpected trailer eof", + ) + })?; + if trailer.is_empty() { + break; + } + } + break; + } + if (out.len() as u64).saturating_add(chunk_len) > max_bytes { + return Err(io::Error::new(io::ErrorKind::InvalidData, "body_too_large")); + } + let prev = out.len(); + out.resize(prev + chunk_len as usize, 0); + reader.read_exact(&mut out[prev..])?; + let mut crlf = [0u8; 2]; + reader.read_exact(&mut crlf)?; + if &crlf != b"\r\n" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "missing chunk crlf", + )); + } + } + Ok(out) + } + } +} + +/// Streams chunked or fixed-length upstream body to `client` as HTTP/1.1 chunked transfer. +pub fn stream_upstream_to_chunked_client( + reader: &mut R, + client: &mut W, + framing: BodyFraming, + max_bytes: Option, +) -> io::Result<()> { + let mut total: u64 = 0; + match framing { + BodyFraming::None => { + // Read until EOF (connection-delimited stream) + let mut buf = [0u8; 16 * 1024]; + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + total = total.saturating_add(n as u64); + if let Some(cap) = max_bytes + && total > cap + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "body_too_large")); + } + write!(client, "{n:x}\r\n")?; + client.write_all(&buf[..n])?; + client.write_all(b"\r\n")?; + client.flush()?; + } + } + BodyFraming::ContentLength(mut remaining) => { + if let Some(cap) = max_bytes + && remaining > cap + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "body_too_large")); + } + let mut buf = [0u8; 16 * 1024]; + while remaining > 0 { + let want = (remaining as usize).min(buf.len()); + let n = reader.read(&mut buf[..want])?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "upstream truncated", + )); + } + remaining -= n as u64; + write!(client, "{n:x}\r\n")?; + client.write_all(&buf[..n])?; + client.write_all(b"\r\n")?; + client.flush()?; + } + } + BodyFraming::Chunked => { + let mut buf = [0u8; 16 * 1024]; + let mut meta_budget = HUB_RELAY_HEADER_MAX_BYTES; + loop { + let size_line = read_crlf_line_bounded(reader, &mut meta_budget) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "upstream chunk metadata overflow or invalid", + ) + })? + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + "upstream eof before terminating chunk", + ) + })?; + let hex_part = size_line.trim().split(';').next().unwrap_or("").trim(); + let mut chunk_rem = u64::from_str_radix(hex_part, 16).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid upstream chunk") + })?; + if chunk_rem == 0 { + loop { + let trailer = read_crlf_line_bounded(reader, &mut meta_budget) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "upstream trailer overflow or invalid", + ) + })? + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + "upstream eof in trailers", + ) + })?; + if trailer.is_empty() { + break; + } + } + break; + } + total = total.saturating_add(chunk_rem); + if let Some(cap) = max_bytes + && total > cap + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "body_too_large")); + } + while chunk_rem > 0 { + let want = (chunk_rem as usize).min(buf.len()); + let got = reader.read(&mut buf[..want])?; + if got == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated chunk", + )); + } + chunk_rem -= got as u64; + write!(client, "{got:x}\r\n")?; + client.write_all(&buf[..got])?; + client.write_all(b"\r\n")?; + client.flush()?; + } + let mut crlf = [0u8; 2]; + reader.read_exact(&mut crlf)?; + if &crlf != b"\r\n" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid upstream chunk crlf", + )); + } + } + } + } + client.write_all(b"0\r\n\r\n")?; + client.flush() +} + +pub fn write_json_response( + writer: &mut W, + status: u16, + reason: &str, + extra_headers: &[(&str, &str)], + body: &[u8], +) -> io::Result<()> { + write!(writer, "HTTP/1.1 {status} {reason}\r\n")?; + write!(writer, "Content-Type: application/json;charset=utf-8\r\n")?; + write!(writer, "Content-Length: {}\r\n", body.len())?; + write!(writer, "Connection: close\r\n")?; + for (k, v) in extra_headers { + write!(writer, "{k}: {v}\r\n")?; + } + writer.write_all(b"\r\n")?; + writer.write_all(body)?; + writer.flush() +} diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs new file mode 100644 index 00000000000..28b19f00d7d --- /dev/null +++ b/native/ocx-child/src/journal.rs @@ -0,0 +1,977 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::Path; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde::{Deserialize, Serialize}; + +use crate::codex_home::{CodexHomeGuard, RestoreStepError}; +use crate::codex_settings::HomeCodexSettings; +use crate::platform::{ + DirectoryIdentity, directory_identity, ensure_private_dir, reject_symlink, sha256_hex, + write_private_atomic, write_regular_atomic, +}; +use crate::state::ChildPaths; +use crate::toml_surgeon::{ + ChildInjectedLedger, apply_design_b_config, detect_unowned_root_routing_conflict, + surgical_detach_config, +}; + +pub const CHILD_JOURNAL_SCHEMA: &str = "ocx.child.journal/1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChildJournalV1 { + pub schema: String, + pub link_id: String, + pub api_key_id: String, + pub opencodex_home: String, + pub codex_home: String, + #[serde(default)] + pub opencodex_home_identity: Option, + #[serde(default)] + pub codex_home_identity: Option, + pub catalog_path: String, + pub pre_attach_config_existed: bool, + pub pre_attach_config_bytes_b64: Option, + pub pre_attach_config_sha256: Option, + pub first_injected_config_sha256: String, + pub latest_injected_config_sha256: String, + #[serde(default)] + pub previous_injected_config_sha256: Option, + pub untouched_by_external_writer: bool, + pub pre_attach_catalog_existed: bool, + pub pre_attach_catalog_bytes_b64: Option, + pub latest_catalog_sha256: String, + #[serde(default)] + pub previous_catalog_sha256: Option, + pub pre_attach_models_cache_existed: bool, + pub pre_attach_models_cache_bytes_b64: Option, + pub latest_models_cache_sha256: Option, + #[serde(default)] + pub previous_models_cache_sha256: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub retained_external_cache_entries: bool, + pub ledger: ChildInjectedLedger, +} + +/// Deterministic fault-injection points for write-ahead crash-recovery tests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JournalWriteFault { + AfterJournalWrite, + AfterCatalogWrite, + AfterCacheWrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetachOutcome { + ExactRestore, + SurgicalRestore, + NotAttached, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DetachError { + Conflict(String), + Io(String), +} + +pub fn read_journal(path: &Path) -> Result, String> { + reject_symlink(path)?; + let bytes = match fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("failed reading journal {}: {e}", path.display())), + }; + let parsed: ChildJournalV1 = + serde_json::from_slice(&bytes).map_err(|e| format!("corrupt child journal: {e}"))?; + if parsed.schema != CHILD_JOURNAL_SCHEMA { + return Err(format!("unsupported journal schema: {}", parsed.schema)); + } + Ok(Some(parsed)) +} + +pub fn write_journal(path: &Path, journal: &ChildJournalV1) -> Result<(), String> { + let mut bytes = serde_json::to_vec_pretty(journal) + .map_err(|e| format!("failed serializing journal: {e}"))?; + bytes.push(b'\n'); + write_private_atomic(path, &bytes) +} + +fn validate_journal_paths(paths: &ChildPaths, journal: &ChildJournalV1) -> Result<(), String> { + let current_ocx = fs::canonicalize(&paths.opencodex_home) + .map_err(|e| format!("cannot resolve current OpenCodex home: {e}"))?; + let current_codex = fs::canonicalize(&paths.codex_home) + .map_err(|e| format!("cannot resolve current Codex home: {e}"))?; + let catalog = Path::new(&journal.catalog_path); + let expected_ocx_identity = journal + .opencodex_home_identity + .as_ref() + .ok_or("child journal lacks OpenCodex directory identity")?; + let expected_codex_identity = journal + .codex_home_identity + .as_ref() + .ok_or("child journal lacks Codex directory identity")?; + if current_ocx != Path::new(&journal.opencodex_home) + || current_codex != Path::new(&journal.codex_home) + || catalog != current_codex.join("opencodex-catalog.json") + || &directory_identity(¤t_ocx)? != expected_ocx_identity + || &directory_identity(¤t_codex)? != expected_codex_identity + { + return Err("child journal belongs to a different home or catalog path".to_owned()); + } + Ok(()) +} + +/// Verify an existing journal before any lifecycle operation changes state or stops a daemon. +pub fn validate_existing_journal_paths(paths: &ChildPaths) -> Result { + match read_journal(&paths.journal_file)? { + Some(journal) => { + validate_journal_paths(paths, &journal)?; + Ok(true) + } + None => Ok(false), + } +} + +/// Fail-closed check ensuring full `ocx` does not own `$OPENCODEX_HOME` or `$CODEX_HOME`. +pub fn assert_no_full_ocx_conflict(paths: &ChildPaths) -> Result<(), String> { + for name in [ + "runtime-port.json", + "ocx.pid", + "service-api-token", + "client-connect-pending", + ] { + let candidate = paths.opencodex_home.join(name); + if candidate.exists() { + return Err(format!( + "attach refused: full OpenCodex runtime state exists at {}", + candidate.display() + )); + } + } + let cfg_json = paths.opencodex_home.join("config.json"); + if let Ok(raw) = fs::read(&cfg_json) { + let non_empty = serde_json::from_slice::(&raw) + .ok() + .and_then(|v| v.as_object().cloned()) + .is_some_and(|obj| !obj.is_empty()); + if non_empty { + return Err(format!( + "attach refused: full OpenCodex config.json exists at {}", + cfg_json.display() + )); + } + } + for jname in ["opencodex-journal.json", ".opencodex-journal.json"] { + let jpath = paths.codex_home.join(jname); + if jpath.exists() { + return Err(format!( + "attach refused: full OpenCodex Codex journal exists at {}", + jpath.display() + )); + } + } + Ok(()) +} + +pub fn validate_catalog_json(bytes: &[u8]) -> Result, String> { + if bytes.len() > 4 * 1024 * 1024 { + return Err("downloaded catalog exceeds 4 MiB safety bound".to_owned()); + } + let val: serde_json::Value = + serde_json::from_slice(bytes).map_err(|e| format!("invalid catalog JSON: {e}"))?; + let models = val + .get("models") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "catalog JSON missing models array".to_owned())?; + if models.len() > 2000 { + return Err("catalog models array exceeds 2000 entries".to_owned()); + } + let mut seen = HashSet::with_capacity(models.len()); + for item in models { + let slug = item + .get("slug") + .and_then(serde_json::Value::as_str) + .filter(|s| !s.trim().is_empty() && s.bytes().all(|b| (0x21..=0x7e).contains(&b))) + .ok_or_else(|| "catalog contains missing or invalid model slug".to_owned())?; + if !seen.insert(slug.to_owned()) { + return Err(format!("catalog contains duplicate slug: {slug}")); + } + } + Ok(models.clone()) +} + +/// Prepares updated `$CODEX_HOME/models_cache.json` bytes (if changed) and SHA-256 without writing yet. +pub fn prepare_codex_models_cache( + cache_path: &Path, + catalog_models: &[serde_json::Value], +) -> Result<(Option>, String), String> { + reject_symlink(cache_path)?; + let existing_bytes = match fs::read(cache_path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("failed reading models_cache.json: {e}")), + }; + render_codex_models_cache(existing_bytes.as_deref(), catalog_models, false) +} + +fn render_codex_models_cache( + existing_bytes: Option<&[u8]>, + catalog_models: &[serde_json::Value], + retain_unmarked: bool, +) -> Result<(Option>, String), String> { + let catalog_slugs: HashSet<&str> = catalog_models + .iter() + .filter_map(|m| m.get("slug").and_then(serde_json::Value::as_str)) + .collect(); + let mut observed_native = Vec::new(); + if let Some(raw) = existing_bytes + && let Ok(val) = serde_json::from_slice::(raw) + && let Some(arr) = val.get("models").and_then(serde_json::Value::as_array) + { + for entry in arr { + let is_obs = entry + .get("opencodex_account_observed_native") + .and_then(serde_json::Value::as_bool) + == Some(true); + let slug = entry + .get("slug") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if retain_unmarked && slug.is_empty() { + return Err( + "client_models_cache_conflict: local model is missing a slug".to_owned(), + ); + } + if (is_obs || retain_unmarked) && !slug.is_empty() && !catalog_slugs.contains(slug) { + observed_native.push(entry.clone()); + } + } + } + let mut combined = Vec::with_capacity(catalog_models.len() + observed_native.len()); + combined.extend_from_slice(catalog_models); + combined.extend(observed_native); + let wrapper = serde_json::json!({ + "fetched_at": "2000-01-01T00:00:00Z", + "client_version": "0.0.0", + "models": combined, + }); + let mut rendered = serde_json::to_vec_pretty(&wrapper) + .map_err(|e| format!("failed serializing models_cache.json: {e}"))?; + rendered.push(b'\n'); + let digest = sha256_hex(&rendered); + let to_write = (existing_bytes != Some(rendered.as_slice())).then_some(rendered); + Ok((to_write, digest)) +} + +pub fn refresh_codex_models_cache( + cache_path: &Path, + catalog_models: &[serde_json::Value], +) -> Result { + let (to_write, digest) = prepare_codex_models_cache(cache_path, catalog_models)?; + if let Some(rendered) = to_write { + write_regular_atomic(cache_path, &rendered)?; + } + Ok(digest) +} + +pub fn apply_attach_or_sync( + guard: &CodexHomeGuard, + link_id: &str, + api_key_id: &str, + listen_port: u16, + catalog_bytes: &[u8], + settings: &HomeCodexSettings, +) -> Result { + apply_attach_or_sync_with_fault( + guard, + link_id, + api_key_id, + listen_port, + catalog_bytes, + settings, + None, + ) +} + +pub fn apply_attach_or_sync_with_fault( + guard: &CodexHomeGuard, + link_id: &str, + api_key_id: &str, + listen_port: u16, + catalog_bytes: &[u8], + settings: &HomeCodexSettings, + fault: Option, +) -> Result { + if !guard.is_owner() { + return Err("attach refused: this OpenCodex home does not own the Codex home".to_owned()); + } + let paths = guard.paths(); + assert_no_full_ocx_conflict(paths)?; + let catalog_models = validate_catalog_json(catalog_bytes)?; + let config_path = paths.codex_config_path(); + let catalog_path = paths.codex_catalog_path(); + let cache_path = paths.codex_models_cache_path(); + reject_symlink(&config_path)?; + reject_symlink(&catalog_path)?; + reject_symlink(&cache_path)?; + + let existing_journal = read_journal(&paths.journal_file)?; + if let Some(journal) = &existing_journal { + validate_journal_paths(paths, journal)?; + } + let current_cfg_bytes = match fs::read(&config_path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("failed reading {}: {e}", config_path.display())), + }; + let current_cfg_text = match ¤t_cfg_bytes { + Some(b) => std::str::from_utf8(b) + .map_err(|_| "config.toml is not valid UTF-8".to_owned())? + .to_owned(), + None => String::new(), + }; + + if existing_journal.is_none() && !current_cfg_text.is_empty() { + detect_unowned_root_routing_conflict(¤t_cfg_text)?; + } + + ensure_private_dir(&paths.child_dir)?; + fs::create_dir_all(&paths.codex_home) + .map_err(|e| format!("failed to create Codex home: {e}"))?; + let canonical_ocx = fs::canonicalize(&paths.opencodex_home) + .map_err(|e| format!("cannot resolve OpenCodex home: {e}"))?; + let canonical_codex = fs::canonicalize(&paths.codex_home) + .map_err(|e| format!("cannot resolve Codex home: {e}"))?; + let catalog_path_str = canonical_codex + .join("opencodex-catalog.json") + .to_str() + .ok_or("canonical catalog path is not UTF-8")? + .to_owned(); + let (next_cfg_text, next_ledger) = apply_design_b_config( + ¤t_cfg_text, + listen_port, + &catalog_path_str, + settings, + existing_journal.as_ref().map(|j| &j.ledger), + )?; + let next_cfg_sha = sha256_hex(next_cfg_text.as_bytes()); + let next_cat_sha = sha256_hex(catalog_bytes); + + let (pre_cfg_existed, pre_cfg_b64, pre_cfg_sha, first_inj_sha, prev_inj_sha, untouched) = + if let Some(prev) = &existing_journal { + let cur_sha = current_cfg_bytes.as_ref().map(|b| sha256_hex(b)); + let cur_matches_owned = cur_sha.as_deref() + == Some(prev.latest_injected_config_sha256.as_str()) + || (cur_sha.is_some() && cur_sha == prev.previous_injected_config_sha256); + let still_untouched = prev.untouched_by_external_writer + && (cur_matches_owned || cur_sha == prev.pre_attach_config_sha256); + let carried_prev_cfg = if cur_matches_owned { + cur_sha + } else { + prev.previous_injected_config_sha256 + .clone() + .or_else(|| Some(prev.latest_injected_config_sha256.clone())) + }; + ( + prev.pre_attach_config_existed, + prev.pre_attach_config_bytes_b64.clone(), + prev.pre_attach_config_sha256.clone(), + prev.first_injected_config_sha256.clone(), + carried_prev_cfg, + still_untouched, + ) + } else { + let b64 = current_cfg_bytes.as_ref().map(|b| BASE64.encode(b)); + let sha = current_cfg_bytes.as_ref().map(|b| sha256_hex(b)); + ( + current_cfg_bytes.is_some(), + b64, + sha, + next_cfg_sha.clone(), + None, + true, + ) + }; + + let (pre_cat_existed, pre_cat_b64, prev_cat_sha) = if let Some(prev) = &existing_journal { + let mut carried_prev_cat = prev + .previous_catalog_sha256 + .clone() + .or_else(|| Some(prev.latest_catalog_sha256.clone())); + if catalog_path.exists() { + let existing_cat = fs::read(&catalog_path) + .map_err(|e| format!("failed reading {}: {e}", catalog_path.display()))?; + let existing_cat_sha = sha256_hex(&existing_cat); + let matches_owned_injected = existing_cat_sha == prev.latest_catalog_sha256 + || Some(existing_cat_sha.as_str()) == prev.previous_catalog_sha256.as_deref(); + let matches_pre_attach = prev + .pre_attach_catalog_bytes_b64 + .as_ref() + .and_then(|b64| BASE64.decode(b64).ok()) + .is_some_and(|p| p == existing_cat); + if !matches_owned_injected && !matches_pre_attach { + return Err( + "client_catalog_ownership_changed: opencodex-catalog.json was modified externally" + .to_owned(), + ); + } + if matches_owned_injected { + carried_prev_cat = Some(existing_cat_sha); + } + } + ( + prev.pre_attach_catalog_existed, + prev.pre_attach_catalog_bytes_b64.clone(), + carried_prev_cat, + ) + } else { + match fs::read(&catalog_path) { + Ok(b) => (true, Some(BASE64.encode(&b)), None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (false, None, None), + Err(e) => return Err(format!("failed reading {}: {e}", catalog_path.display())), + } + }; + + let (mut pre_cache_existed, mut pre_cache_b64, prev_cache_sha) = + if let Some(prev) = &existing_journal { + let carried_prev_cache = match fs::read(&cache_path) { + Ok(existing_cache) => { + let existing_cache_sha = sha256_hex(&existing_cache); + let matches_owned = Some(existing_cache_sha.as_str()) + == prev.latest_models_cache_sha256.as_deref() + || Some(existing_cache_sha.as_str()) + == prev.previous_models_cache_sha256.as_deref(); + if matches_owned { + Some(existing_cache_sha) + } else { + prev.previous_models_cache_sha256 + .clone() + .or_else(|| prev.latest_models_cache_sha256.clone()) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => prev + .previous_models_cache_sha256 + .clone() + .or_else(|| prev.latest_models_cache_sha256.clone()), + Err(e) => return Err(format!("failed reading {}: {e}", cache_path.display())), + }; + ( + prev.pre_attach_models_cache_existed, + prev.pre_attach_models_cache_bytes_b64.clone(), + carried_prev_cache, + ) + } else { + match fs::read(&cache_path) { + Ok(b) => (true, Some(BASE64.encode(&b)), None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (false, None, None), + Err(e) => return Err(format!("failed reading {}: {e}", cache_path.display())), + } + }; + + let mut retained_external_cache_entries = existing_journal + .as_ref() + .is_some_and(|j| j.retained_external_cache_entries); + let (prepared_cache, latest_cache_sha) = if let Some(prev) = &existing_journal { + let current = match fs::read(&cache_path) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("failed reading models_cache.json: {e}")), + }; + let externally_modified = current.as_ref().is_some_and(|bytes| { + let hash = sha256_hex(bytes); + Some(hash.as_str()) != prev.latest_models_cache_sha256.as_deref() + && Some(hash.as_str()) != prev.previous_models_cache_sha256.as_deref() + }); + if current.is_some() && (externally_modified || retained_external_cache_entries) { + let original = match ( + &prev.pre_attach_models_cache_bytes_b64, + prev.pre_attach_models_cache_existed, + ) { + (Some(encoded), true) => Some( + BASE64 + .decode(encoded) + .map_err(|e| format!("bad models_cache snapshot: {e}"))?, + ), + (None, false) => None, + _ => return Err("missing pre-attach models_cache snapshot".to_owned()), + }; + let current = current + .as_deref() + .ok_or("models_cache disappeared during sync")?; + // `reconciled` is the detach snapshot: injected slugs carry their pre-attach + // versions there. The live cache below lets the new catalog win every shared slug, + // exactly as first attach does, and keeps only entries whose slug it does not carry. + let reconciled = reconcile_modified_models_cache( + current, + original.as_deref(), + &paths.catalog_backup_file, + )?; + let (rendered, hash) = + render_codex_models_cache(Some(&reconciled), &catalog_models, true)?; + pre_cache_existed = true; + pre_cache_b64 = Some(BASE64.encode(&reconciled)); + retained_external_cache_entries = true; + (rendered.or(Some(reconciled)), hash) + } else { + prepare_codex_models_cache(&cache_path, &catalog_models)? + } + } else { + prepare_codex_models_cache(&cache_path, &catalog_models)? + }; + // Until the cache write lands, the cache may still hold the previous catalog's entries, so + // the backup that tells reconciliation which slugs the Child injected names both catalogs. + let interim_backup = match &existing_journal { + Some(_) => interim_catalog_backup(&paths.catalog_backup_file, &catalog_models)?, + None => None, + }; + + let journal = ChildJournalV1 { + schema: CHILD_JOURNAL_SCHEMA.to_owned(), + link_id: link_id.to_owned(), + api_key_id: api_key_id.to_owned(), + opencodex_home: canonical_ocx + .to_str() + .ok_or("canonical OpenCodex home is not UTF-8")? + .to_owned(), + codex_home: canonical_codex + .to_str() + .ok_or("canonical Codex home is not UTF-8")? + .to_owned(), + opencodex_home_identity: Some(directory_identity(&canonical_ocx)?), + codex_home_identity: Some(directory_identity(&canonical_codex)?), + catalog_path: catalog_path_str, + pre_attach_config_existed: pre_cfg_existed, + pre_attach_config_bytes_b64: pre_cfg_b64, + pre_attach_config_sha256: pre_cfg_sha, + first_injected_config_sha256: first_inj_sha, + latest_injected_config_sha256: next_cfg_sha, + previous_injected_config_sha256: prev_inj_sha, + untouched_by_external_writer: untouched, + pre_attach_catalog_existed: pre_cat_existed, + pre_attach_catalog_bytes_b64: pre_cat_b64, + latest_catalog_sha256: next_cat_sha, + previous_catalog_sha256: prev_cat_sha, + pre_attach_models_cache_existed: pre_cache_existed, + pre_attach_models_cache_bytes_b64: pre_cache_b64, + latest_models_cache_sha256: Some(latest_cache_sha), + previous_models_cache_sha256: prev_cache_sha, + retained_external_cache_entries, + ledger: next_ledger, + }; + // Persist recovery journal BEFORE mutating any file in $CODEX_HOME (write-ahead durability). + write_journal(&paths.journal_file, &journal)?; + if fault == Some(JournalWriteFault::AfterJournalWrite) { + return Err("injected fault: AfterJournalWrite".to_owned()); + } + write_regular_atomic(&catalog_path, catalog_bytes)?; + write_private_atomic( + &paths.catalog_backup_file, + interim_backup.as_deref().unwrap_or(catalog_bytes), + )?; + if fault == Some(JournalWriteFault::AfterCatalogWrite) { + return Err("injected fault: AfterCatalogWrite".to_owned()); + } + if let Some(cache_bytes) = prepared_cache { + write_regular_atomic(&cache_path, &cache_bytes)?; + } + if interim_backup.is_some() { + write_private_atomic(&paths.catalog_backup_file, catalog_bytes)?; + } + if fault == Some(JournalWriteFault::AfterCacheWrite) { + return Err("injected fault: AfterCacheWrite".to_owned()); + } + write_regular_atomic(&config_path, next_cfg_text.as_bytes())?; + Ok(journal) +} + +/// Catalog backup naming the slugs of both the new catalog and the last one the Child injected, +/// or `None` when there is no previous backup to merge. +fn interim_catalog_backup( + backup_path: &Path, + catalog_models: &[serde_json::Value], +) -> Result>, String> { + reject_symlink(backup_path)?; + let previous = match fs::read(backup_path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("failed reading catalog backup: {e}")), + }; + let previous: serde_json::Value = serde_json::from_slice(&previous).map_err(|e| { + format!("client_models_cache_reconcile_failed: invalid catalog backup JSON: {e}") + })?; + let previous_models = previous + .get("models") + .and_then(serde_json::Value::as_array) + .ok_or("client_models_cache_reconcile_failed: catalog backup missing models array")?; + let new_slugs: HashSet<&str> = catalog_models + .iter() + .filter_map(|m| m.get("slug").and_then(serde_json::Value::as_str)) + .collect(); + let mut merged = catalog_models.to_vec(); + merged.extend( + previous_models + .iter() + .filter(|m| { + m.get("slug") + .and_then(serde_json::Value::as_str) + .is_some_and(|slug| !new_slugs.contains(slug)) + }) + .cloned(), + ); + serde_json::to_vec(&serde_json::json!({ "models": merged })) + .map(Some) + .map_err(|e| format!("failed serializing catalog backup: {e}")) +} + +/// Performs 100% offline two-tier restoration of `config.toml`, `opencodex-catalog.json`, and `models_cache.json`. +pub fn offline_detach( + guard: &CodexHomeGuard, + keep_catalog: bool, +) -> Result { + offline_detach_inner(guard, keep_catalog, false) +} + +/// Restore client files while retaining recovery records until lifecycle cleanup succeeds. +pub fn offline_detach_deferred( + guard: &CodexHomeGuard, + keep_catalog: bool, +) -> Result { + offline_detach_inner(guard, keep_catalog, true) +} + +pub fn remove_optional_file(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => { + #[cfg(unix)] + { + let parent = path.parent().ok_or("removed file has no parent")?; + crate::platform::sync_parent_directory(parent)?; + } + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file"); + Err(format!("failed removing {name}: {e}")) + } + } +} + +fn offline_detach_inner( + guard: &CodexHomeGuard, + keep_catalog: bool, + defer_cleanup: bool, +) -> Result { + let paths = guard.paths(); + let Some(mut journal) = read_journal(&paths.journal_file).map_err(DetachError::Io)? else { + return Ok(DetachOutcome::NotAttached); + }; + validate_journal_paths(paths, &journal).map_err(DetachError::Conflict)?; + let config_path = paths.codex_config_path(); + let catalog_path = Path::new(&journal.catalog_path); + let cache_path = paths.codex_models_cache_path(); + reject_symlink(&config_path).map_err(DetachError::Io)?; + reject_symlink(catalog_path).map_err(DetachError::Io)?; + reject_symlink(&cache_path).map_err(DetachError::Io)?; + + let prior_catalog = + if !keep_catalog && journal.pre_attach_catalog_existed { + let encoded = journal + .pre_attach_catalog_bytes_b64 + .as_ref() + .ok_or_else(|| { + DetachError::Conflict("missing pre-attach catalog snapshot".to_owned()) + })?; + Some(BASE64.decode(encoded).map_err(|e| { + DetachError::Conflict(format!("bad pre-attach catalog snapshot: {e}")) + })?) + } else { + None + }; + + if !keep_catalog && catalog_path.exists() { + let cat_bytes = fs::read(catalog_path) + .map_err(|e| DetachError::Io(format!("failed reading catalog: {e}")))?; + let cur_cat_sha = sha256_hex(&cat_bytes); + let matches_known_injected = cur_cat_sha == journal.latest_catalog_sha256 + || Some(cur_cat_sha.as_str()) == journal.previous_catalog_sha256.as_deref(); + if !matches_known_injected { + let matches_prior = prior_catalog + .as_ref() + .is_some_and(|prior| *prior == cat_bytes); + if !matches_prior { + return Err(DetachError::Conflict( + "client_catalog_ownership_changed: opencodex-catalog.json was modified externally".to_owned(), + )); + } + } + } + + enum CacheDetachPlan { + Noop, + Remove, + Write(Vec), + } + + let cache_plan = if cache_path.exists() { + let cur_cache = fs::read(&cache_path) + .map_err(|e| DetachError::Io(format!("failed reading models_cache.json: {e}")))?; + let cur_cache_sha = sha256_hex(&cur_cache); + let cache_untouched = Some(cur_cache_sha.as_str()) + == journal.latest_models_cache_sha256.as_deref() + || Some(cur_cache_sha.as_str()) == journal.previous_models_cache_sha256.as_deref(); + if journal.pre_attach_models_cache_existed { + let prior_cache = journal + .pre_attach_models_cache_bytes_b64 + .as_ref() + .ok_or_else(|| { + DetachError::Conflict("missing pre-attach models_cache snapshot".to_owned()) + }) + .and_then(|b64| { + BASE64.decode(b64).map_err(|e| { + DetachError::Conflict(format!("bad models_cache snapshot: {e}")) + }) + })?; + if cache_untouched || cur_cache == prior_cache { + if cur_cache != prior_cache { + CacheDetachPlan::Write(prior_cache) + } else { + CacheDetachPlan::Noop + } + } else { + let reconciled = reconcile_modified_models_cache( + &cur_cache, + Some(&prior_cache), + &paths.catalog_backup_file, + ) + .map_err(DetachError::Conflict)?; + CacheDetachPlan::Write(reconciled) + } + } else if cache_untouched { + CacheDetachPlan::Remove + } else { + let reconciled = + reconcile_modified_models_cache(&cur_cache, None, &paths.catalog_backup_file) + .map_err(DetachError::Conflict)?; + CacheDetachPlan::Write(reconciled) + } + } else { + CacheDetachPlan::Noop + }; + + let cur_cfg_bytes = match fs::read(&config_path) { + Ok(b) => Some(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(DetachError::Io(format!("failed reading config.toml: {e}"))), + }; + + let outcome = match cur_cfg_bytes { + None if !journal.pre_attach_config_existed && journal.untouched_by_external_writer => { + DetachOutcome::ExactRestore + } + None => DetachOutcome::SurgicalRestore, + Some(bytes) => { + let cur_sha = sha256_hex(&bytes); + let matches_untouched_sha = cur_sha == journal.latest_injected_config_sha256 + || Some(cur_sha.as_str()) == journal.pre_attach_config_sha256.as_deref() + || Some(cur_sha.as_str()) == journal.previous_injected_config_sha256.as_deref(); + if journal.untouched_by_external_writer && matches_untouched_sha { + if journal.pre_attach_config_existed { + let orig = journal + .pre_attach_config_bytes_b64 + .as_ref() + .ok_or_else(|| { + DetachError::Conflict("missing pre-attach config snapshot".to_owned()) + }) + .and_then(|b64| { + BASE64.decode(b64).map_err(|e| { + DetachError::Conflict(format!("bad snapshot base64: {e}")) + }) + })?; + guard + .restore_step(|| write_regular_atomic(&config_path, &orig)) + .map_err(step_err)?; + } else { + guard + .restore_step(|| remove_optional_file(&config_path)) + .map_err(step_err)?; + } + DetachOutcome::ExactRestore + } else { + let text = std::str::from_utf8(&bytes) + .map_err(|_| DetachError::Conflict("config.toml is not UTF-8".to_owned()))?; + let stripped = + surgical_detach_config(text, &journal.ledger).map_err(DetachError::Conflict)?; + match stripped { + Some(updated) => { + guard + .restore_step(|| write_regular_atomic(&config_path, updated.as_bytes())) + .map_err(step_err)?; + } + None => { + guard + .restore_step(|| remove_optional_file(&config_path)) + .map_err(step_err)?; + } + } + DetachOutcome::SurgicalRestore + } + } + }; + + if !keep_catalog { + if journal.pre_attach_catalog_existed { + if let Some(prior_bytes) = &prior_catalog { + guard + .restore_step(|| write_regular_atomic(catalog_path, prior_bytes)) + .map_err(step_err)?; + } + } else { + guard + .restore_step(|| remove_optional_file(catalog_path)) + .map_err(step_err)?; + } + } + + match cache_plan { + CacheDetachPlan::Noop => {} + CacheDetachPlan::Remove => { + guard + .restore_step(|| remove_optional_file(&cache_path)) + .map_err(step_err)?; + } + CacheDetachPlan::Write(reconciled) => { + guard + .restore_step(|| write_regular_atomic(&cache_path, &reconciled)) + .map_err(step_err)?; + } + } + + // A retry after cleanup starts must recognize the restored cache without + // relying on the catalog backup, which cleanup may already have removed. + match fs::read(&cache_path) { + Ok(restored) => { + journal.pre_attach_models_cache_existed = true; + journal.pre_attach_models_cache_bytes_b64 = Some(BASE64.encode(&restored)); + journal.latest_models_cache_sha256 = Some(sha256_hex(&restored)); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + journal.pre_attach_models_cache_existed = false; + journal.pre_attach_models_cache_bytes_b64 = None; + journal.latest_models_cache_sha256 = None; + } + Err(e) => { + return Err(DetachError::Io(format!( + "failed reading restored cache: {e}" + ))); + } + } + journal.previous_models_cache_sha256 = None; + guard + .restore_step(|| write_journal(&paths.journal_file, &journal)) + .map_err(step_err)?; + + if !defer_cleanup { + guard + .restore_step(|| remove_optional_file(&paths.catalog_backup_file)) + .map_err(step_err)?; + guard + .restore_step(|| remove_optional_file(&paths.journal_file)) + .map_err(step_err)?; + } + Ok(outcome) +} + +fn step_err(e: RestoreStepError) -> DetachError { + DetachError::Io(e.into_message()) +} + +/// Rebuilds the pre-attach view of a `models_cache.json` that changed externally while attached. +/// +/// Every slug of the last catalog the Child injected is Child-owned while attached, whatever its +/// current content: Codex rewrites the cache and may re-serialize those entries. Such an entry is +/// replaced by its pre-attach version, or dropped when the pre-attach cache did not have the slug. +/// Entries with any other slug were added outside the Child and are kept as they are. +fn reconcile_modified_models_cache( + cur_cache_bytes: &[u8], + prior_cache_bytes: Option<&[u8]>, + last_child_catalog_path: &Path, +) -> Result, String> { + let mut cur_val: serde_json::Value = serde_json::from_slice(cur_cache_bytes).map_err(|e| { + format!("client_models_cache_reconcile_failed: invalid models_cache.json: {e}") + })?; + let cur_models = cur_val + .get("models") + .and_then(serde_json::Value::as_array) + .cloned() + .ok_or_else(|| { + "client_models_cache_reconcile_failed: models_cache.json missing models array" + .to_owned() + })?; + let backup_bytes = fs::read(last_child_catalog_path).map_err(|e| { + format!("client_models_cache_reconcile_failed: missing catalog backup: {e}") + })?; + let backup_val: serde_json::Value = serde_json::from_slice(&backup_bytes).map_err(|e| { + format!("client_models_cache_reconcile_failed: invalid catalog backup JSON: {e}") + })?; + let backup_models = backup_val + .get("models") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + "client_models_cache_reconcile_failed: catalog backup missing models array".to_owned() + })?; + let child_slugs: HashSet<&str> = backup_models + .iter() + .filter_map(|m| m.get("slug").and_then(serde_json::Value::as_str)) + .collect(); + let prior_by_slug: HashMap = match prior_cache_bytes { + Some(raw) => { + let pv: serde_json::Value = serde_json::from_slice(raw).map_err(|e| { + format!( + "client_models_cache_reconcile_failed: invalid prior models_cache snapshot: {e}" + ) + })?; + pv.get("models") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + "client_models_cache_reconcile_failed: prior models_cache missing models array" + .to_owned() + })? + .iter() + .filter_map(|m| { + m.get("slug") + .and_then(serde_json::Value::as_str) + .map(|s| (s.to_owned(), m.clone())) + }) + .collect() + } + None => HashMap::new(), + }; + let kept: Vec = cur_models + .into_iter() + .filter_map(|entry| { + let slug = entry + .get("slug") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if child_slugs.contains(slug) { + prior_by_slug.get(slug).cloned() + } else { + Some(entry) + } + }) + .collect(); + if let Some(obj) = cur_val.as_object_mut() { + obj.insert("models".to_owned(), serde_json::Value::Array(kept)); + } + let mut out = serde_json::to_vec_pretty(&cur_val) + .map_err(|e| format!("client_models_cache_reconcile_failed: {e}"))?; + out.push(b'\n'); + Ok(out) +} diff --git a/native/ocx-child/src/lib.rs b/native/ocx-child/src/lib.rs new file mode 100644 index 00000000000..fc3c10ef223 --- /dev/null +++ b/native/ocx-child/src/lib.rs @@ -0,0 +1,22 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +pub mod cli; +pub mod codex_home; +pub mod codex_settings; +pub mod daemon; +pub mod http_wire; +pub mod journal; +pub mod parity; +pub mod platform; +pub mod relay; +pub mod relay_auth; +pub mod routes; +pub mod state; +pub mod test_hooks; +pub mod toml_surgeon; + +/// OpenCodex wire/CLI protocol version reported by `ocx-child --version` and peer headers. +/// Kept >= 2.93.0 so Home enrollment (`>= 2.66.0`) and `ocx sync` fan-out (`>= 2.93.0`) accept it, +/// while decoupled from the crate's own `0.1.0` Cargo version. +pub const OCX_PROTOCOL_COMPAT_VERSION: &str = "2.98.0"; +pub const DEFAULT_LISTEN_PORT: u16 = 10100; diff --git a/native/ocx-child/src/main.rs b/native/ocx-child/src/main.rs new file mode 100644 index 00000000000..53ae1004069 --- /dev/null +++ b/native/ocx-child/src/main.rs @@ -0,0 +1,9 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let code = ocx_child::cli::run_cli(&args); + ExitCode::from(code) +} diff --git a/native/ocx-child/src/parity.rs b/native/ocx-child/src/parity.rs new file mode 100644 index 00000000000..3d5be5ad6a1 --- /dev/null +++ b/native/ocx-child/src/parity.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; + +use crate::relay_auth::{is_valid_hex64, is_valid_link_id}; + +pub const LINK_CHILD_PARITY_MAX_BYTES: usize = 1024; +const MIN_EPOCH_MS: u64 = 1_000_000_000_000; +const MAX_EPOCH_MS: u64 = 9_999_999_999_999; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LinkChildParityReport { + pub link_id: String, + pub last_apply_at: Option, + pub runtime_started_at: u64, + pub applied_codex_settings_digest: Option, + pub sequence: u64, +} + +impl LinkChildParityReport { + pub fn validate(&self) -> bool { + if !is_valid_link_id(&self.link_id) { + return false; + } + if !(MIN_EPOCH_MS..=MAX_EPOCH_MS).contains(&self.runtime_started_at) { + return false; + } + if let Some(ts) = self.last_apply_at + && !(MIN_EPOCH_MS..=MAX_EPOCH_MS).contains(&ts) + { + return false; + } + if let Some(digest) = &self.applied_codex_settings_digest + && !is_valid_hex64(digest) + { + return false; + } + if self.last_apply_at.is_none() != self.applied_codex_settings_digest.is_none() { + return false; + } + self.sequence <= 9_007_199_254_740_991 // Number.MAX_SAFE_INTEGER + } + + pub fn to_bounded_json(&self) -> Result, String> { + if !self.validate() { + return Err("invalid child parity report".to_owned()); + } + let bytes = serde_json::to_vec(self) + .map_err(|e| format!("failed serializing child parity report: {e}"))?; + if bytes.len() > LINK_CHILD_PARITY_MAX_BYTES { + return Err("child parity report exceeds 1024 bytes".to_owned()); + } + Ok(bytes) + } +} + +fn json_safe_nonneg_int(val: &serde_json::Value) -> Option { + const MAX_SAFE: u64 = 9_007_199_254_740_991; + if let Some(u) = val.as_u64() { + return (u <= MAX_SAFE).then_some(u); + } + if let Some(f) = val.as_f64() + && f.is_finite() + && f >= 0.0 + && f <= (MAX_SAFE as f64) + && f.fract() == 0.0 + { + return Some(f as u64); + } + None +} + +/// Strict exact-key parser matching `parseLinkChildParityReport` in `src/link/child-parity.ts`. +pub fn parse_link_child_parity_report(raw_json: &str) -> Option { + if raw_json.len() > LINK_CHILD_PARITY_MAX_BYTES { + return None; + } + let val: serde_json::Value = serde_json::from_str(raw_json).ok()?; + let obj = val.as_object()?; + if obj.len() != 5 { + return None; + } + let link_id = obj.get("linkId")?.as_str()?.to_owned(); + let last_apply_at = match obj.get("lastApplyAt")? { + serde_json::Value::Null => None, + other => Some(json_safe_nonneg_int(other)?), + }; + let runtime_started_at = json_safe_nonneg_int(obj.get("runtimeStartedAt")?)?; + let applied_codex_settings_digest = match obj.get("appliedCodexSettingsDigest")? { + serde_json::Value::Null => None, + serde_json::Value::String(s) => Some(s.clone()), + _ => return None, + }; + let sequence = json_safe_nonneg_int(obj.get("sequence")?)?; + let parsed = LinkChildParityReport { + link_id, + last_apply_at, + runtime_started_at, + applied_codex_settings_digest, + sequence, + }; + parsed.validate().then_some(parsed) +} diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs new file mode 100644 index 00000000000..7e7e1d1f888 --- /dev/null +++ b/native/ocx-child/src/platform.rs @@ -0,0 +1,1245 @@ +use std::fs::{self, File, OpenOptions}; +#[cfg(unix)] +use std::io::Read; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{Ordering, compiler_fence}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DirectoryIdentity { + pub volume: u64, + pub file_id: [u8; 16], +} + +pub fn directory_identity(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let directory = File::open(path).map_err(|e| format!("cannot open directory: {e}"))?; + let metadata = directory + .metadata() + .map_err(|e| format!("cannot inspect directory: {e}"))?; + if !metadata.is_dir() { + return Err("expected a directory".to_owned()); + } + let mut file_id = [0u8; 16]; + file_id[..8].copy_from_slice(&metadata.ino().to_le_bytes()); + Ok(DirectoryIdentity { + volume: metadata.dev(), + file_id, + }) + } + #[cfg(windows)] + { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use std::os::windows::io::AsRawHandle; + #[repr(C)] + struct FileIdInfo { + volume: u64, + file_id: [u8; 16], + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandleEx( + file: *mut std::ffi::c_void, + class: u32, + info: *mut std::ffi::c_void, + size: u32, + ) -> i32; + } + let directory = OpenOptions::new() + .read(true) + .custom_flags(0x02000000 | 0x00200000) + .open(path) + .map_err(|e| format!("cannot open directory: {e}"))?; + let metadata = directory + .metadata() + .map_err(|e| format!("cannot inspect directory: {e}"))?; + if !metadata.is_dir() || metadata.file_attributes() & 0x400 != 0 { + return Err("expected a directory".to_owned()); + } + let mut info = FileIdInfo { + volume: 0, + file_id: [0; 16], + }; + // SAFETY: The open directory handle and output buffer remain valid for the call. + if unsafe { + GetFileInformationByHandleEx( + directory.as_raw_handle(), + 18, + (&mut info as *mut FileIdInfo).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(format!( + "cannot inspect directory identity: {}", + std::io::Error::last_os_error() + )); + } + Ok(DirectoryIdentity { + volume: info.volume, + file_id: info.file_id, + }) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + Err("directory identity is unavailable on this platform".to_owned()) + } +} + +/// Zeroes sensitive heap bytes on drop using volatile writes so the compiler cannot elide scrubbing. +pub struct SecretBytes(Vec); + +impl SecretBytes { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl Drop for SecretBytes { + fn drop(&mut self) { + volatile_zero_bytes(&mut self.0); + } +} + +/// A secret string that is wiped on drop, so every copy of it (a clone, or a state read that is +/// dropped) is scrubbed. Serialized as a plain string; `Debug` never prints it. +#[derive(Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct SecretString(String); + +impl SecretString { + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for SecretString { + fn from(value: String) -> Self { + Self(value) + } +} + +impl std::ops::Deref for SecretString { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for SecretString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecretString()") + } +} + +impl Drop for SecretString { + fn drop(&mut self) { + // SAFETY: Zero bytes are valid UTF-8, so the string stays valid while it is wiped. + volatile_zero_bytes(unsafe { self.0.as_bytes_mut() }); + } +} + +pub fn volatile_zero_bytes(buf: &mut [u8]) { + for byte in buf.iter_mut() { + // SAFETY: `byte` is a valid mutable reference to an initialized `u8` inside `buf`. + unsafe { std::ptr::write_volatile(byte, 0) }; + } + compiler_fence(Ordering::SeqCst); +} + +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (&x, &y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +pub fn sha256_bytes(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +pub fn sha256_hex(data: &[u8]) -> String { + hex_encode(&sha256_bytes(data)) +} + +pub fn hex_encode(bytes: &[u8]) -> String { + const LUT: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(LUT[(b >> 4) as usize] as char); + out.push(LUT[(b & 0x0f) as usize] as char); + } + out +} + +pub fn hex_decode_lower(input: &str) -> Option> { + if !input.len().is_multiple_of(2) { + return None; + } + let mut out = Vec::with_capacity(input.len() / 2); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let hi = hex_nibble_lower(bytes[i])?; + let lo = hex_nibble_lower(bytes[i + 1])?; + out.push((hi << 4) | lo); + i += 2; + } + Some(out) +} + +fn hex_nibble_lower(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + _ => None, + } +} + +pub fn now_epoch_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(1_760_000_000_000) +} + +/// Formats epoch milliseconds as ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`). +pub fn format_iso8601_utc(epoch_ms: u64) -> String { + let total_secs = (epoch_ms / 1000) as i64; + let millis = epoch_ms % 1000; + let days = total_secs.div_euclid(86_400); + let secs_of_day = total_secs.rem_euclid(86_400) as u32; + let hour = secs_of_day / 3600; + let minute = (secs_of_day % 3600) / 60; + let second = secs_of_day % 60; + // Howard Hinnant civil_from_days algorithm + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u32; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + format!("{year:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + +#[cfg(unix)] +unsafe extern "C" { + fn flock(fd: i32, operation: i32) -> i32; + fn setsid() -> i32; + fn kill(pid: i32, sig: i32) -> i32; +} + +#[cfg(windows)] +#[repr(C)] +struct Overlapped { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + event: *mut std::ffi::c_void, +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn LockFileEx( + file: *mut std::ffi::c_void, + flags: u32, + reserved: u32, + bytes_low: u32, + bytes_high: u32, + overlapped: *mut Overlapped, + ) -> i32; + fn UnlockFileEx( + file: *mut std::ffi::c_void, + reserved: u32, + bytes_low: u32, + bytes_high: u32, + overlapped: *mut Overlapped, + ) -> i32; + fn OpenProcess( + desired_access: u32, + inherit_handle: i32, + process_id: u32, + ) -> *mut std::ffi::c_void; + fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32; + fn CloseHandle(object: *mut std::ffi::c_void) -> i32; +} + +#[cfg(windows)] +#[link(name = "bcrypt")] +unsafe extern "system" { + fn BCryptGenRandom( + algorithm: *mut std::ffi::c_void, + buffer: *mut u8, + bytes: u32, + flags: u32, + ) -> i32; +} + +pub fn fill_random_bytes(dest: &mut [u8]) -> Result<(), String> { + #[cfg(unix)] + { + let mut f = + File::open("/dev/urandom").map_err(|e| format!("failed to open /dev/urandom: {e}"))?; + f.read_exact(dest) + .map_err(|e| format!("failed to read /dev/urandom: {e}"))?; + Ok(()) + } + #[cfg(windows)] + { + const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 0x0000_0002; + // SAFETY: `dest` is a valid mutable slice of `dest.len()` bytes. + let status = unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + dest.as_mut_ptr(), + dest.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if status != 0 { + return Err(format!( + "BCryptGenRandom failed with NTSTATUS 0x{status:08x}" + )); + } + Ok(()) + } +} + +pub fn random_hex(byte_len: usize) -> Result { + let mut buf = vec![0u8; byte_len]; + fill_random_bytes(&mut buf)?; + Ok(hex_encode(&buf)) +} + +/// `O_NOFOLLOW` for the open(2) flags of this target, from the libc crate's definitions. +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly" +))] +const O_NOFOLLOW: i32 = 0x0100; +#[cfg(all( + any(target_os = "linux", target_os = "android"), + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "powerpc", + target_arch = "powerpc64", + target_arch = "m68k" + ) +))] +const O_NOFOLLOW: i32 = 0x8000; +#[cfg(any( + all( + any(target_os = "linux", target_os = "android"), + not(any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "powerpc", + target_arch = "powerpc64", + target_arch = "m68k" + )) + ), + target_os = "solaris", + target_os = "illumos" +))] +const O_NOFOLLOW: i32 = 0x2_0000; + +pub fn reject_symlink(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + Err(format!("refusing symlinked path: {}", path.display())) + } + #[cfg(windows)] + Ok(meta) => { + use std::os::windows::fs::MetadataExt; + if meta.file_attributes() & 0x400 != 0 { + return Err(format!("refusing reparse point: {}", path.display())); + } + Ok(()) + } + #[cfg(not(windows))] + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("failed to inspect {}: {e}", path.display())), + } +} + +#[cfg(windows)] +mod windows_private_acl { + use std::ffi::c_void; + use std::fs::File; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::FromRawHandle; + use std::path::Path; + use std::ptr; + + type Ptr = *mut c_void; + const ACL_REVISION: u32 = 2; + const FULL_CONTROL: u32 = 0x1f01ff; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const SE_FILE_OBJECT: u32 = 1; + const DACL_SECURITY_INFORMATION: u32 = 4; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x80000000; + + #[repr(C)] + struct Acl { + revision: u8, + reserved: u8, + size: u16, + count: u16, + reserved2: u16, + } + + #[repr(C)] + struct AceHeader { + kind: u8, + flags: u8, + size: u16, + } + + #[repr(C)] + struct SecurityDescriptor { + revision: u8, + reserved: u8, + control: u16, + owner: Ptr, + group: Ptr, + sacl: Ptr, + dacl: Ptr, + } + + #[repr(C)] + struct SecurityAttributes { + length: u32, + descriptor: Ptr, + inherit_handle: i32, + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: Ptr, access: u32, token: *mut Ptr) -> i32; + fn GetTokenInformation( + token: Ptr, + class: u32, + info: Ptr, + size: u32, + needed: *mut u32, + ) -> i32; + fn CreateWellKnownSid(kind: u32, domain: Ptr, sid: Ptr, size: *mut u32) -> i32; + fn InitializeAcl(acl: *mut Acl, size: u32, revision: u32) -> i32; + fn AddAccessAllowedAceEx( + acl: *mut Acl, + revision: u32, + flags: u32, + mask: u32, + sid: Ptr, + ) -> i32; + fn SetNamedSecurityInfoW( + name: *mut u16, + kind: u32, + info: u32, + owner: Ptr, + group: Ptr, + dacl: *mut Acl, + sacl: *mut Acl, + ) -> u32; + fn GetNamedSecurityInfoW( + name: *const u16, + kind: u32, + info: u32, + owner: *mut Ptr, + group: *mut Ptr, + dacl: *mut *mut Acl, + sacl: *mut *mut Acl, + descriptor: *mut Ptr, + ) -> u32; + fn GetSecurityDescriptorControl( + descriptor: Ptr, + control: *mut u16, + revision: *mut u32, + ) -> i32; + fn GetSecurityDescriptorDacl( + descriptor: Ptr, + present: *mut i32, + dacl: *mut *mut Acl, + defaulted: *mut i32, + ) -> i32; + fn EqualSid(a: Ptr, b: Ptr) -> i32; + fn IsValidSid(sid: Ptr) -> i32; + fn IsValidAcl(acl: *mut Acl) -> i32; + fn GetAce(acl: *mut Acl, index: u32, ace: *mut Ptr) -> i32; + fn GetLengthSid(sid: Ptr) -> u32; + fn InitializeSecurityDescriptor(descriptor: Ptr, revision: u32) -> i32; + fn SetSecurityDescriptorDacl( + descriptor: Ptr, + present: i32, + dacl: *mut Acl, + defaulted: i32, + ) -> i32; + fn SetSecurityDescriptorControl(descriptor: Ptr, mask: u16, control: u16) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Ptr; + fn CloseHandle(handle: Ptr) -> i32; + fn LocalFree(memory: Ptr) -> Ptr; + fn CreateFileW( + name: *const u16, + access: u32, + share: u32, + attributes: *mut SecurityAttributes, + disposition: u32, + flags: u32, + template: Ptr, + ) -> Ptr; + } + + struct Local(Ptr); + impl Drop for Local { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: `self.0` was allocated by `GetNamedSecurityInfoW`. + unsafe { + LocalFree(self.0); + } + } + } + } + + struct Token(Ptr); + impl Drop for Token { + fn drop(&mut self) { + // SAFETY: `self.0` is an open process token handle. + unsafe { + CloseHandle(self.0); + } + } + } + + fn current_user_sid() -> Result, String> { + let mut token = ptr::null_mut(); + // SAFETY: Queries current process token for TOKEN_QUERY (0x8). + if unsafe { OpenProcessToken(GetCurrentProcess(), 0x8, &mut token) } == 0 { + return Err("private-acl-unsafe: OpenProcessToken failed".to_owned()); + } + let token = Token(token); + let mut needed = 0u32; + // SAFETY: First call sizes TokenUser (1). + unsafe { + GetTokenInformation(token.0, 1, ptr::null_mut(), 0, &mut needed); + } + if needed < std::mem::size_of::() as u32 || needed > 65536 { + return Err("private-acl-unsafe: invalid token user size".to_owned()); + } + let mut buf = vec![0usize; (needed as usize).div_ceil(std::mem::size_of::())]; + // SAFETY: `buf` has `>= needed` bytes aligned to `usize`. + if unsafe { GetTokenInformation(token.0, 1, buf.as_mut_ptr().cast(), needed, &mut needed) } + == 0 + { + return Err("private-acl-unsafe: GetTokenInformation failed".to_owned()); + } + // SAFETY: `buf[0]` holds the `PSID` pointer inside `TOKEN_USER`. + if unsafe { IsValidSid(buf[0] as Ptr) } == 0 { + return Err("private-acl-unsafe: invalid user SID".to_owned()); + } + Ok(buf) + } + + fn wide_nul(path: &Path) -> Result, String> { + let out: Vec = path.as_os_str().encode_wide().collect(); + if out.contains(&0) { + return Err("private-acl-unsafe: interior NUL".to_owned()); + } + Ok(out.into_iter().chain(Some(0)).collect()) + } + + fn well_known_sid(kind: u32) -> Result<[u32; 17], String> { + let mut sid = [0u32; 17]; + let mut size = std::mem::size_of_val(&sid) as u32; + // SAFETY: `sid` is 68 bytes (SECURITY_MAX_SID_SIZE), DWORD-aligned. + if unsafe { CreateWellKnownSid(kind, ptr::null_mut(), sid.as_mut_ptr().cast(), &mut size) } + == 0 + { + return Err("private-acl-unsafe: CreateWellKnownSid failed".to_owned()); + } + Ok(sid) + } + + fn private_acl( + directory: bool, + ) -> Result<(Vec, [u32; 17], [u32; 17], Vec), String> { + let user = current_user_sid()?; + let mut system = well_known_sid(22)?; + let mut admins = well_known_sid(26)?; + let sids: Vec = if directory { + vec![ + user[0] as Ptr, + system.as_mut_ptr().cast(), + admins.as_mut_ptr().cast(), + ] + } else { + vec![user[0] as Ptr] + }; + // SAFETY: All 3 SIDs were validated by `IsValidSid` / `CreateWellKnownSid`. + let acl_bytes = std::mem::size_of::() as u32 + + sids + .iter() + .map(|&s| 8 + unsafe { GetLengthSid(s) }) + .sum::(); + let mut acl_buf = vec![0u32; (acl_bytes as usize).div_ceil(std::mem::size_of::())]; + let acl_ptr = acl_buf.as_mut_ptr().cast::(); + let flags = if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }; + // SAFETY: `acl_buf` has `>= acl_bytes` DWORD-aligned capacity. + if unsafe { InitializeAcl(acl_ptr, acl_bytes, ACL_REVISION) } == 0 { + return Err("private-acl-unsafe: InitializeAcl failed".to_owned()); + } + for &sid in &sids { + // SAFETY: `acl_ptr` initialized with exact room for `sids`. + if unsafe { AddAccessAllowedAceEx(acl_ptr, ACL_REVISION, flags, FULL_CONTROL, sid) } + == 0 + { + return Err("private-acl-unsafe: AddAccessAllowedAceEx failed".to_owned()); + } + } + Ok((user, system, admins, acl_buf)) + } + + /// Verify, without changing it, that an existing file carries the protected owner-only DACL. + pub fn verify_private_file(path: &Path) -> Result<(), String> { + let (user, mut system, mut admins, _acl) = private_acl(false)?; + verify(path, false, &user, &mut system, &mut admins) + } + + pub fn harden_and_verify(path: &Path, directory: bool) -> Result<(), String> { + let (user, mut system, mut admins, mut acl_buf) = private_acl(directory)?; + let acl_ptr = acl_buf.as_mut_ptr().cast::(); + let mut wname = wide_nul(path)?; + // SAFETY: Applies protected DACL to `wname`. + if unsafe { + SetNamedSecurityInfoW( + wname.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + acl_ptr, + ptr::null_mut(), + ) + } != 0 + { + return Err(format!( + "private-acl-unsafe: SetNamedSecurityInfoW failed on {}", + path.display() + )); + } + verify(path, directory, &user, &mut system, &mut admins) + } + + /// Create an exclusive file with its protected DACL installed by the kernel at creation. + pub fn create_private_file(path: &Path) -> Result { + let (user, mut system, mut admins, mut acl_buf) = private_acl(false)?; + let mut descriptor = SecurityDescriptor { + revision: 0, + reserved: 0, + control: 0, + owner: ptr::null_mut(), + group: ptr::null_mut(), + sacl: ptr::null_mut(), + dacl: ptr::null_mut(), + }; + let descriptor_ptr = (&mut descriptor as *mut SecurityDescriptor).cast::(); + // SAFETY: The descriptor and ACL are initialized, aligned and live through CreateFileW. + if unsafe { InitializeSecurityDescriptor(descriptor_ptr, 1) } == 0 + || unsafe { + SetSecurityDescriptorDacl(descriptor_ptr, 1, acl_buf.as_mut_ptr().cast(), 0) + } == 0 + || unsafe { SetSecurityDescriptorControl(descriptor_ptr, 0x1000, 0x1000) } == 0 + { + return Err("private-acl-unsafe: security descriptor initialization failed".to_owned()); + } + let mut attributes = SecurityAttributes { + length: std::mem::size_of::() as u32, + descriptor: descriptor_ptr, + inherit_handle: 0, + }; + let wname = wide_nul(path)?; + // SAFETY: The path and security attributes stay live for the call; CREATE_NEW cannot replace a file. + let handle = unsafe { + CreateFileW( + wname.as_ptr(), + 0x40000000, + 0x00000007, + &mut attributes, + 1, + 0x00000080, + ptr::null_mut(), + ) + }; + if handle == (-1isize as Ptr) { + return Err(format!( + "private-acl-unsafe: create failed: {}", + std::io::Error::last_os_error() + )); + } + // SAFETY: CreateFileW returned a newly owned file handle. + let file = unsafe { File::from_raw_handle(handle) }; + verify(path, false, &user, &mut system, &mut admins)?; + Ok(file) + } + + fn verify( + path: &Path, + root: bool, + user: &[usize], + system: &mut [u32; 17], + admins: &mut [u32; 17], + ) -> Result<(), String> { + let wname = wide_nul(path)?; + let mut owner = ptr::null_mut(); + let mut dacl = ptr::null_mut(); + let mut desc = ptr::null_mut(); + // SAFETY: Reads owner + DACL into LocalAlloc'd security descriptor. + let rc = unsafe { + GetNamedSecurityInfoW( + wname.as_ptr(), + SE_FILE_OBJECT, + 1 | DACL_SECURITY_INFORMATION, + &mut owner, + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut desc, + ) + }; + let desc = Local(desc); + if rc != 0 || desc.0.is_null() || owner.is_null() || dacl.is_null() { + return Err("private-acl-unsafe: GetNamedSecurityInfoW failed".to_owned()); + } + let mut control = 0u16; + let mut rev = 0u32; + let mut present = 0i32; + let mut defaulted = 0i32; + // SAFETY: Inspects valid security descriptor `desc.0`. + let ok = unsafe { + GetSecurityDescriptorControl(desc.0, &mut control, &mut rev) != 0 + && GetSecurityDescriptorDacl(desc.0, &mut present, &mut dacl, &mut defaulted) != 0 + && present != 0 + && !dacl.is_null() + && IsValidAcl(dacl) != 0 + && IsValidSid(owner) != 0 + && (EqualSid(owner, user[0] as Ptr) != 0 + || EqualSid(owner, admins.as_mut_ptr().cast()) != 0) + && control & 0x1000 != 0 + }; + if !ok { + return Err("private-acl-unsafe: DACL header/owner verification failed".to_owned()); + } + let mut user_fc = false; + // SAFETY: `dacl` was validated by `IsValidAcl`. + let ace_count = unsafe { (*dacl).count } as u32; + for i in 0..ace_count { + let mut ace = ptr::null_mut(); + // SAFETY: `i < ace_count`. + if unsafe { GetAce(dacl, i, &mut ace) } == 0 || ace.is_null() { + return Err("private-acl-unsafe: GetAce failed".to_owned()); + } + // SAFETY: `ace` points to a valid ACE inside `dacl`. + let hdr = unsafe { &*ace.cast::() }; + if hdr.kind != 0 || hdr.size < 16 || (root && hdr.flags & 0x10 != 0) { + return Err("private-acl-unsafe: unexpected ACE kind/flags".to_owned()); + } + // SAFETY: ACCESS_ALLOWED_ACE has mask at +4 and SID at +8. + let mask = unsafe { *ace.cast::().add(4).cast::() }; + let sid = unsafe { ace.cast::().add(8).cast::() }; + // SAFETY: Bounds-checks SID inside `hdr.size`. + if unsafe { IsValidSid(sid) } == 0 || unsafe { GetLengthSid(sid) } + 8 > hdr.size as u32 + { + return Err("private-acl-unsafe: invalid ACE SID".to_owned()); + } + // SAFETY: Compares valid SIDs. + let is_user = unsafe { EqualSid(sid, user[0] as Ptr) } != 0; + let is_sys = unsafe { EqualSid(sid, system.as_mut_ptr().cast()) } != 0; + let is_adm = unsafe { EqualSid(sid, admins.as_mut_ptr().cast()) } != 0; + if !is_user && !is_sys && !is_adm { + return Err("private-acl-unsafe: foreign ACE present".to_owned()); + } + user_fc |= is_user + && hdr.flags & 0x8 == 0 + && (mask & FULL_CONTROL == FULL_CONTROL || mask & 0x10000000 != 0); + } + if !user_fc { + return Err("private-acl-unsafe: missing current-user FullControl ACE".to_owned()); + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::fs; + use std::io::Write; + use std::process::Command; + + #[test] + fn regular_replacement_is_private_under_a_permissive_parent() { + struct Scratch(std::path::PathBuf); + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let root = std::env::temp_dir().join(format!( + "ocx-child-acl-{}-{}", + std::process::id(), + crate::platform::random_hex(6).unwrap() + )); + fs::create_dir(&root).unwrap(); + let _scratch = Scratch(root.clone()); + let grant = Command::new("icacls") + .arg(&root) + .args(["/grant", "*S-1-1-0:(OI)(CI)F"]) + .output() + .unwrap(); + assert!( + grant.status.success(), + "could not make fixture parent permissive" + ); + let config = root.join("config.toml"); + fs::write(&config, b"original").unwrap(); + harden_and_verify(&config, false).unwrap(); + + let temp = root.join("new-private.tmp"); + let mut created = create_private_file(&temp).unwrap(); + let user = current_user_sid().unwrap(); + let mut system = well_known_sid(22).unwrap(); + let mut admins = well_known_sid(26).unwrap(); + verify(&temp, false, &user, &mut system, &mut admins).unwrap(); + created.write_all(b"fixture").unwrap(); + drop(created); + + crate::platform::write_regular_atomic(&config, b"replacement").unwrap(); + verify(&config, false, &user, &mut system, &mut admins).unwrap(); + assert_eq!(fs::read(config).unwrap(), b"replacement"); + } + } +} + +pub fn ensure_private_dir(dir: &Path) -> Result<(), String> { + reject_symlink(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + if !dir.exists() { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder + .create(dir) + .map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + } + reject_symlink(dir)?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("failed to chmod 0700 {}: {e}", dir.display()))?; + } + #[cfg(windows)] + { + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + reject_symlink(dir)?; + windows_private_acl::harden_and_verify(dir, true)?; + } + #[cfg(not(any(unix, windows)))] + { + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + reject_symlink(dir)?; + } + Ok(()) +} + +#[cfg(unix)] +pub(crate) fn sync_parent_directory(parent: &Path) -> Result<(), String> { + let directory = + File::open(parent).map_err(|e| format!("failed to open parent directory: {e}"))?; + directory + .sync_all() + .map_err(|e| format!("failed to sync parent directory: {e}")) +} + +pub fn write_private_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("path has no parent: {}", path.display()))?; + ensure_private_dir(parent)?; + reject_symlink(path)?; + let nonce = random_hex(8)?; + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("state"); + let tmp = parent.join(format!(".{file_name}.tmp.{}.{nonce}", std::process::id())); + #[cfg(windows)] + let mut f = windows_private_acl::create_private_file(&tmp)?; + #[cfg(not(windows))] + let mut f = { + let mut opts = OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + opts.open(&tmp) + .map_err(|e| format!("failed to create temp file {}: {e}", tmp.display()))? + }; + if let Err(e) = f.write_all(bytes).and_then(|()| f.sync_all()) { + let _ = fs::remove_file(&tmp); + return Err(format!("failed writing {}: {e}", tmp.display())); + } + drop(f); + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(format!( + "failed renaming {} -> {}: {e}", + tmp.display(), + path.display() + )); + } + #[cfg(unix)] + sync_parent_directory(parent)?; + Ok(()) +} + +pub fn write_regular_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {e}", parent.display()))?; + reject_symlink(path)?; + #[cfg(unix)] + let preserved_mode = fs::metadata(path).ok().map(|m| { + use std::os::unix::fs::PermissionsExt; + m.permissions().mode() & 0o777 + }); + let nonce = random_hex(8)?; + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); + let tmp = parent.join(format!( + ".{file_name}.ocx-child.tmp.{}.{nonce}", + std::process::id() + )); + #[cfg(windows)] + let mut f = windows_private_acl::create_private_file(&tmp)?; + #[cfg(not(windows))] + let mut f = { + let mut opts = OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(preserved_mode.unwrap_or(0o600)); + } + opts.open(&tmp) + .map_err(|e| format!("failed to create temp file {}: {e}", tmp.display()))? + }; + if let Err(e) = f.write_all(bytes).and_then(|()| f.sync_all()) { + let _ = fs::remove_file(&tmp); + return Err(format!("failed writing {}: {e}", tmp.display())); + } + drop(f); + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(format!( + "failed renaming {} -> {}: {e}", + tmp.display(), + path.display() + )); + } + #[cfg(unix)] + sync_parent_directory(parent)?; + Ok(()) +} + +pub struct FileLock { + file: File, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockAcquireError { + Busy, + Io, +} + +impl FileLock { + pub fn try_acquire_nonblocking(path: &Path) -> Result { + Self::acquire_inner(path, false, true) + } + + pub fn acquire_blocking(path: &Path) -> Result { + Self::acquire_inner(path, true, true) + } + + /// Lock a file inside a directory the caller does not own (such as `CODEX_HOME`), leaving + /// the directory's permissions untouched. The lock file itself is created private. + pub fn acquire_in_existing_dir(path: &Path, blocking: bool) -> Result { + Self::acquire_inner(path, blocking, false) + } + + fn acquire_inner( + path: &Path, + blocking: bool, + harden_parent: bool, + ) -> Result { + let parent = path.parent().ok_or(LockAcquireError::Io)?; + if harden_parent { + ensure_private_dir(parent).map_err(|_| LockAcquireError::Io)?; + } else if !fs::symlink_metadata(parent).is_ok_and(|m| m.is_dir()) { + return Err(LockAcquireError::Io); + } + reject_symlink(path).map_err(|_| LockAcquireError::Io)?; + crate::test_hooks::barrier("lock-before-open"); + #[cfg(windows)] + let existed = path.exists(); + let mut opts = OpenOptions::new(); + opts.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600).custom_flags(O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + opts.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = opts.open(path).map_err(|_| LockAcquireError::Io)?; + // The open did not follow a link; refuse anything that is not a plain file anyway. + let metadata = file.metadata().map_err(|_| LockAcquireError::Io)?; + if !metadata.is_file() { + return Err(LockAcquireError::Io); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(LockAcquireError::Io); + } + let private = if existed { + windows_private_acl::verify_private_file(path) + } else { + windows_private_acl::harden_and_verify(path, false) + }; + private.map_err(|_| LockAcquireError::Io)?; + } + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + const LOCK_EX: i32 = 2; + const LOCK_NB: i32 = 4; + let op = if blocking { LOCK_EX } else { LOCK_EX | LOCK_NB }; + // SAFETY: `file` owns a valid open descriptor. + let rc = unsafe { flock(file.as_raw_fd(), op) }; + if rc != 0 { + return Err(LockAcquireError::Busy); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + const LOCKFILE_FAIL_IMMEDIATELY: u32 = 1; + const LOCKFILE_EXCLUSIVE_LOCK: u32 = 2; + let flags = if blocking { + LOCKFILE_EXCLUSIVE_LOCK + } else { + LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY + }; + let mut ov = Overlapped { + internal: 0, + internal_high: 0, + offset: 0, + offset_high: 0, + event: std::ptr::null_mut(), + }; + // SAFETY: `file` owns a valid open Win32 file handle and `ov` is stack-valid. + let ok = unsafe { LockFileEx(file.as_raw_handle(), flags, 0, 1, 0, &mut ov) }; + if ok == 0 { + return Err(LockAcquireError::Busy); + } + } + Ok(Self { file }) + } +} + +impl Drop for FileLock { + fn drop(&mut self) { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + const LOCK_UN: i32 = 8; + // SAFETY: `self.file` is still open until Drop returns. + let _ = unsafe { flock(self.file.as_raw_fd(), LOCK_UN) }; + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + let mut ov = Overlapped { + internal: 0, + internal_high: 0, + offset: 0, + offset_high: 0, + event: std::ptr::null_mut(), + }; + // SAFETY: `self.file` is still open until Drop returns. + let _ = unsafe { UnlockFileEx(self.file.as_raw_handle(), 0, 1, 0, &mut ov) }; + } + } +} + +/// Spawn `serve --foreground --lifecycle-start` and write `start_line` to its stdin, which +/// stays open: the caller later writes the commit line or drops it to make the child exit. +pub fn spawn_detached_serve( + exe: &Path, + opencodex_home: &Path, + codex_home: &Path, + listen_port: u16, + start_line: &[u8], +) -> Result { + let mut cmd = Command::new(exe); + cmd.arg("serve") + .arg("--foreground") + .arg("--lifecycle-start") + .arg("--listen-port") + .arg(listen_port.to_string()) + .env("OPENCODEX_HOME", opencodex_home) + .env("CODEX_HOME", codex_home) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::test_hooks::strip_barrier_env(&mut cmd); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // SAFETY: `setsid()` is async-signal-safe in the forked child before exec. + unsafe { + cmd.pre_exec(|| { + let _ = setsid(); + Ok(()) + }); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; + cmd.creation_flags(CREATE_BREAKAWAY_FROM_JOB | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to spawn detached ocx-child serve: {e}"))?; + let write_result = match child.stdin.as_mut() { + Some(stdin) => stdin.write_all(start_line).and_then(|()| stdin.flush()), + None => Err(std::io::Error::other("missing child stdin")), + }; + if let Err(e) = write_result { + let cleanup = child.kill().and_then(|()| child.wait().map(|_| ())); + return Err(format!( + "failed to authorize spawned child: {e}; cleanup: {cleanup:?}" + )); + } + Ok(child) +} + +pub fn process_is_alive(pid: u32) -> bool { + if pid == 0 { + return false; + } + #[cfg(unix)] + { + // SAFETY: `kill(pid, 0)` sends no signal and probes process existence. + let rc = unsafe { kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(1) // EPERM + } + #[cfg(windows)] + { + const SYNCHRONIZE: u32 = 0x0010_0000; + const WAIT_TIMEOUT: u32 = 258; + // SAFETY: Opens a temporary SYNCHRONIZE handle for `pid` and closes it immediately. + unsafe { + let h = OpenProcess(SYNCHRONIZE, 0, pid); + if h.is_null() { + return false; + } + let state = WaitForSingleObject(h, 0); + CloseHandle(h); + state == WAIT_TIMEOUT + } + } +} + +pub fn resolve_user_home() -> PathBuf { + if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) { + return PathBuf::from(h); + } + if let Some(h) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) { + return PathBuf::from(h); + } + PathBuf::from(".") +} + +pub fn resolve_opencodex_home() -> PathBuf { + if let Some(custom) = std::env::var_os("OPENCODEX_HOME").filter(|v| !v.is_empty()) { + return PathBuf::from(custom); + } + resolve_user_home().join(".opencodex") +} + +pub fn resolve_codex_home() -> PathBuf { + if let Some(custom) = std::env::var_os("CODEX_HOME").filter(|v| !v.is_empty()) { + return PathBuf::from(custom); + } + resolve_user_home().join(".codex") +} diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs new file mode 100644 index 00000000000..90c57113c2e --- /dev/null +++ b/native/ocx-child/src/relay.rs @@ -0,0 +1,1288 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::OCX_PROTOCOL_COMPAT_VERSION; +use crate::http_wire::{ + BodyFraming, FramingError, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, HttpRequestHead, + HttpResponseHead, LINK_RELAY_BODY_MAX_BYTES, is_sse_headers, read_bounded_body, + read_request_head, read_response_head, response_framing, sanitize_response_headers, + stream_upstream_to_chunked_client, validate_and_sanitize_request_headers, write_json_response, +}; +use crate::journal::validate_existing_journal_paths; +use crate::parity::LinkChildParityReport; +use crate::platform::{ + FileLock, SecretBytes, constant_time_eq, format_iso8601_utc, now_epoch_ms, random_hex, +}; +use crate::relay_auth::{ + LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_VERSION, LINK_RELAY_CONNECTION_HEADER, + LINK_RELAY_CONNECTION_PROOF_HEADER, LINK_RELAY_PROBE_HEADER, LINK_RELAY_PROOF_HEADER, + LINK_RELAY_SESSION_HEADER, compute_child_shutdown_proof, compute_connection_proof, + create_relay_challenge, is_valid_connection_generation, relay_proof_matches, +}; +use crate::routes::{RelayAuthKind, RouteDecision, classify_child_ingress}; +use crate::state::{ChildPaths, StateReadOutcome, read_child_state, read_verified_link_key}; + +const AUTH_TIMEOUT: Duration = Duration::from_secs(5); +/// Whole-request bound for one-shot Home calls made by lifecycle commands. +const FETCH_DEADLINE: Duration = Duration::from_secs(60); +/// Whole-request bound for a parity report; per-read timeouts alone never end a trickle. +const PARITY_DEADLINE: Duration = Duration::from_secs(10); +const FETCH_READ_TIMEOUT: Duration = Duration::from_secs(15); +/// Per-read and per-write timeout for a control connection handled while every worker is busy. +const CONTROL_TIMEOUT: Duration = Duration::from_secs(1); +/// Deadline for every request head a control connection reads, so a sender that trickles +/// bytes inside `CONTROL_TIMEOUT` still releases its control slot. +const CONTROL_DEADLINE: Duration = Duration::from_secs(2); +/// Connect bound for a request that shutdown can cancel: a socket can be registered for +/// cancellation only once connected, so this bounds how long shutdown can wait on a connect. +const CANCELLABLE_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); +const HEADER_AND_SSE_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_CONCURRENT_WORKERS: usize = 32; +const MAX_CONTROL_CONNECTIONS: usize = 256; +const PEER_FEATURES: &str = "relay-auth/2,link-management-relay,link-peer-report/1"; + +#[derive(Debug)] +pub enum UpstreamError { + HomeUnreachable(String), + RelayAuthFailed(String), + ResponseHeadersTooLarge, + ResponseBodyTooLarge, + BadUpstreamResponse(String), +} + +pub struct RelayCredentials { + pub api_key_id: String, + pub link_id: String, + pub token_fingerprint: String, + pub raw_token: SecretBytes, +} + +/// Upstream socket reader that enforces an optional whole-request deadline: each read is +/// bounded by the time left, so a peer trickling bytes cannot extend the request. +pub struct DeadlineStream { + stream: TcpStream, + deadline: Option, + per_read: Duration, +} + +impl DeadlineStream { + fn new(stream: TcpStream, deadline: Option, per_read: Duration) -> Self { + Self { + stream, + deadline, + per_read, + } + } + + pub fn get_ref(&self) -> &TcpStream { + &self.stream + } +} + +impl Read for DeadlineStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(deadline) = self.deadline { + let now = Instant::now(); + if now >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "request deadline exceeded", + )); + } + let wait = (deadline - now) + .min(self.per_read) + .max(Duration::from_millis(1)); + self.stream.set_read_timeout(Some(wait))?; + } + self.stream.read(buf) + } +} + +/// Holds the socket of an in-flight request so another thread can abort it. Registration +/// and cancellation share one mutex, so a request either sees the cancellation before it +/// registers or is registered when cancellation shuts its socket down. +#[derive(Default)] +pub struct CancelSlot { + inner: Mutex<(bool, Option)>, +} + +impl CancelSlot { + fn register(&self, stream: &TcpStream) -> Result<(), UpstreamError> { + let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + if guard.0 { + return Err(UpstreamError::HomeUnreachable( + "request cancelled by shutdown".to_owned(), + )); + } + guard.1 = + Some(stream.try_clone().map_err(|e| { + UpstreamError::HomeUnreachable(format!("socket clone failed: {e}")) + })?); + Ok(()) + } + + fn is_cancelled(&self) -> bool { + self.inner.lock().unwrap_or_else(|e| e.into_inner()).0 + } + + fn clear(&self) { + self.inner.lock().unwrap_or_else(|e| e.into_inner()).1 = None; + } + + /// Abort the registered request, if any, and refuse every later registration. + pub fn cancel(&self) { + let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.0 = true; + if let Some(stream) = guard.1.take() { + let _ = stream.shutdown(Shutdown::Both); + } + } +} + +/// Bounds for one upstream request. +#[derive(Clone, Copy)] +pub struct RequestLimits<'a> { + pub deadline: Option, + pub cancel: Option<&'a CancelSlot>, +} + +impl RequestLimits<'_> { + fn remaining(&self, cap: Duration) -> Result { + if self.cancel.is_some_and(CancelSlot::is_cancelled) { + return Err(UpstreamError::HomeUnreachable( + "request cancelled by shutdown".to_owned(), + )); + } + match self.deadline { + None => Ok(cap), + Some(deadline) => { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + Err(UpstreamError::HomeUnreachable( + "request deadline exceeded".to_owned(), + )) + } else { + Ok(left.min(cap)) + } + } + } + } +} + +pub struct AuthenticatedUpstream { + pub reader: BufReader, + pub writer: TcpStream, + pub nonce: String, + pub connection_generation: Option, +} + +fn url_encode_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// Opens ONE `TcpStream` to `127.0.0.1:`, runs the v2 HMAC challenge/proof, +/// and returns that exact same `TcpStream` ready to transmit one bound data request. +pub fn open_authenticated_upstream( + tunnel_port: u16, + creds: &RelayCredentials, + probe_only: bool, +) -> Result { + open_authenticated_upstream_with( + tunnel_port, + creds, + probe_only, + RequestLimits { + deadline: None, + cancel: None, + }, + ) +} + +fn open_authenticated_upstream_with( + tunnel_port: u16, + creds: &RelayCredentials, + probe_only: bool, + limits: RequestLimits<'_>, +) -> Result { + let addr = SocketAddr::from(([127, 0, 0, 1], tunnel_port)); + let connect_cap = if limits.cancel.is_some() { + CANCELLABLE_CONNECT_TIMEOUT + } else { + AUTH_TIMEOUT + }; + let stream = TcpStream::connect_timeout(&addr, limits.remaining(connect_cap)?) + .map_err(|e| UpstreamError::HomeUnreachable(format!("tunnel connect failed: {e}")))?; + if let Some(cancel) = limits.cancel { + cancel.register(&stream)?; + } + let _ = stream.set_nodelay(true); + let _ = stream.set_read_timeout(Some(AUTH_TIMEOUT)); + let _ = stream.set_write_timeout(Some(limits.remaining(AUTH_TIMEOUT)?)); + let mut writer = stream + .try_clone() + .map_err(|e| UpstreamError::HomeUnreachable(format!("socket clone failed: {e}")))?; + let mut reader = BufReader::new(DeadlineStream::new(stream, limits.deadline, AUTH_TIMEOUT)); + + let challenge = + create_relay_challenge(&creds.token_fingerprint, &creds.api_key_id, &creds.link_id) + .map_err(UpstreamError::RelayAuthFailed)?; + + let target = format!( + "{LINK_RELAY_AUTH_PATH}?version={LINK_RELAY_AUTH_VERSION}&key={}&link={}&nonce={}&proof={}", + url_encode_component(&creds.api_key_id), + url_encode_component(&creds.link_id), + challenge.nonce, + challenge.caller_proof, + ); + let conn_hdr = if probe_only { "close" } else { "keep-alive" }; + let mut preflight = format!( + "GET {target} HTTP/1.1\r\nHost: 127.0.0.1:{tunnel_port}\r\nAccept-Encoding: identity\r\nConnection: {conn_hdr}\r\nx-opencodex-link-peer-version: {OCX_PROTOCOL_COMPAT_VERSION}\r\nx-opencodex-link-peer-features: {PEER_FEATURES}\r\n" + ); + if probe_only { + preflight.push_str(&format!("{LINK_RELAY_PROBE_HEADER}: 1\r\n")); + } + preflight.push_str("\r\n"); + writer + .write_all(preflight.as_bytes()) + .and_then(|()| writer.flush()) + .map_err(|e| UpstreamError::HomeUnreachable(format!("preflight write failed: {e}")))?; + + let resp = read_response_head(&mut reader).map_err(|e| match e { + FramingError::Eof => { + UpstreamError::HomeUnreachable("tunnel closed during preflight".to_owned()) + } + FramingError::HeadersTooLarge => UpstreamError::ResponseHeadersTooLarge, + _ => UpstreamError::RelayAuthFailed("malformed preflight response".to_owned()), + })?; + if resp.status != 204 { + return Err(UpstreamError::RelayAuthFailed(format!( + "preflight returned status {}", + resp.status + ))); + } + if let Some(cl) = resp.header("content-length") + && cl.trim() != "0" + { + return Err(UpstreamError::RelayAuthFailed( + "preflight 204 carried non-zero content-length".to_owned(), + )); + } + if resp.header("transfer-encoding").is_some() { + return Err(UpstreamError::RelayAuthFailed( + "preflight 204 carried transfer-encoding".to_owned(), + )); + } + if !relay_proof_matches( + resp.header(LINK_RELAY_PROOF_HEADER), + Some(&challenge.expected_listener_proof), + ) { + return Err(UpstreamError::RelayAuthFailed( + "listener HMAC proof mismatch".to_owned(), + )); + } + + let mut verified_gen = None; + if let (Some(gen_val), Some(gen_proof)) = ( + resp.header(LINK_RELAY_CONNECTION_HEADER), + resp.header(LINK_RELAY_CONNECTION_PROOF_HEADER), + ) && is_valid_connection_generation(gen_val) + { + let expected_conn = compute_connection_proof( + &creds.token_fingerprint, + &creds.api_key_id, + &creds.link_id, + &challenge.nonce, + gen_val, + ); + if relay_proof_matches(Some(gen_proof), expected_conn.as_deref()) { + verified_gen = Some(gen_val.to_owned()); + } + } + + Ok(AuthenticatedUpstream { + reader, + writer, + nonce: challenge.nonce, + connection_generation: verified_gen, + }) +} + +/// Request head carrying the link token, built in one allocation of its final size so no +/// partial copy is left behind, and wiped on drop. +fn token_request_head( + before: &str, + auth_kind: RelayAuthKind, + token: &[u8], + after: &str, +) -> SecretBytes { + let name: &[u8] = match auth_kind { + RelayAuthKind::ApiKeyHeader => b"x-opencodex-api-key: ", + RelayAuthKind::Bearer => b"Authorization: Bearer ", + }; + let mut head = Vec::with_capacity(before.len() + name.len() + token.len() + 2 + after.len()); + head.extend_from_slice(before.as_bytes()); + head.extend_from_slice(name); + head.extend_from_slice(token); + head.extend_from_slice(b"\r\n"); + head.extend_from_slice(after.as_bytes()); + SecretBytes::new(head) +} + +pub fn fetch_home_authenticated( + tunnel_port: u16, + creds: &RelayCredentials, + method: &str, + target: &str, + auth_kind: RelayAuthKind, + body: Option<(&str, &[u8])>, + max_bytes: u64, +) -> Result<(HttpResponseHead, Vec, Option), UpstreamError> { + let limits = RequestLimits { + deadline: Some(Instant::now() + FETCH_DEADLINE), + cancel: None, + }; + fetch_home_with_limits( + tunnel_port, + creds, + method, + target, + auth_kind, + body, + max_bytes, + limits, + ) +} + +#[allow(clippy::too_many_arguments)] +fn fetch_home_with_limits( + tunnel_port: u16, + creds: &RelayCredentials, + method: &str, + target: &str, + auth_kind: RelayAuthKind, + body: Option<(&str, &[u8])>, + max_bytes: u64, + limits: RequestLimits<'_>, +) -> Result<(HttpResponseHead, Vec, Option), UpstreamError> { + let result = fetch_home_inner( + tunnel_port, + creds, + method, + target, + auth_kind, + body, + max_bytes, + limits, + ); + if let Some(cancel) = limits.cancel { + cancel.clear(); + } + result +} + +#[allow(clippy::too_many_arguments)] +fn fetch_home_inner( + tunnel_port: u16, + creds: &RelayCredentials, + method: &str, + target: &str, + auth_kind: RelayAuthKind, + body: Option<(&str, &[u8])>, + max_bytes: u64, + limits: RequestLimits<'_>, +) -> Result<(HttpResponseHead, Vec, Option), UpstreamError> { + let mut upstream = open_authenticated_upstream_with(tunnel_port, creds, false, limits)?; + upstream.reader.get_mut().per_read = FETCH_READ_TIMEOUT; + let _ = upstream + .writer + .set_write_timeout(Some(limits.remaining(FETCH_READ_TIMEOUT)?)); + std::str::from_utf8(creds.raw_token.as_slice()) + .map_err(|_| UpstreamError::RelayAuthFailed("invalid utf8 token".to_owned()))?; + let before = format!( + "{method} {target} HTTP/1.1\r\nHost: 127.0.0.1:{tunnel_port}\r\nAccept-Encoding: identity\r\nConnection: close\r\n{LINK_RELAY_SESSION_HEADER}: {}\r\n", + upstream.nonce + ); + let mut after = String::new(); + if let Some((ct, payload)) = body { + after.push_str(&format!( + "Content-Type: {ct}\r\nContent-Length: {}\r\n", + payload.len() + )); + } + after.push_str("\r\n"); + let head = token_request_head(&before, auth_kind, creds.raw_token.as_slice(), &after); + upstream + .writer + .write_all(head.as_slice()) + .map_err(|e| UpstreamError::HomeUnreachable(format!("request write failed: {e}")))?; + if let Some((_, payload)) = body { + upstream + .writer + .write_all(payload) + .map_err(|e| UpstreamError::HomeUnreachable(format!("body write failed: {e}")))?; + } + upstream + .writer + .flush() + .map_err(|e| UpstreamError::HomeUnreachable(format!("flush failed: {e}")))?; + + let resp_head = read_response_head(&mut upstream.reader).map_err(|e| match e { + FramingError::HeadersTooLarge => UpstreamError::ResponseHeadersTooLarge, + _ => UpstreamError::HomeUnreachable("failed reading Home response head".to_owned()), + })?; + let framing = response_framing(resp_head.status, method, &resp_head.headers) + .map_err(|_| UpstreamError::BadUpstreamResponse("invalid response framing".to_owned()))?; + let resp_body = read_bounded_body(&mut upstream.reader, framing, max_bytes).map_err(|e| { + if e.to_string().contains("body_too_large") { + UpstreamError::ResponseBodyTooLarge + } else { + UpstreamError::BadUpstreamResponse(format!("body read failed: {e}")) + } + })?; + Ok((resp_head, resp_body, upstream.connection_generation)) +} + +pub fn post_child_parity_report( + tunnel_port: u16, + creds: &RelayCredentials, + report: &LinkChildParityReport, +) -> Result { + post_parity_with(tunnel_port, creds, report, None) +} + +fn post_parity_with( + tunnel_port: u16, + creds: &RelayCredentials, + report: &LinkChildParityReport, + cancel: Option<&CancelSlot>, +) -> Result { + let json_bytes = report.to_bounded_json()?; + let limits = RequestLimits { + deadline: Some(Instant::now() + PARITY_DEADLINE), + cancel, + }; + let (head, _, _) = fetch_home_with_limits( + tunnel_port, + creds, + "POST", + "/v1/link-management/child-parity", + RelayAuthKind::Bearer, + Some(("application/json", &json_bytes)), + 1024, + limits, + ) + .map_err(|e| format!("{e:?}"))?; + Ok(head.status) +} + +struct SharedServerState { + paths: ChildPaths, + listen_port: u16, + started_instant: Instant, + started_at_ms: u64, + started_at_iso: String, + stop_flag: AtomicBool, + stop_cv: (Mutex, Condvar), + last_generation: Mutex>, + /// False until the lifecycle parent commits this generation; only `/healthz` is + /// answered before that. + committed: Arc, + /// In-flight parity request, aborted by shutdown so the parity thread ends promptly. + parity_cancel: CancelSlot, +} + +impl SharedServerState { + fn load_live_creds(&self) -> Result { + let state = match read_child_state(&self.paths.state_file) { + StateReadOutcome::Valid(s) => s, + _ => return Err("link_credential_unavailable"), + }; + let raw_token = read_verified_link_key(&self.paths.key_file, &state.token_fingerprint) + .map_err(|_| "link_credential_unavailable")?; + Ok(RelayCredentials { + api_key_id: state.api_key_id, + link_id: state.link_id, + token_fingerprint: state.token_fingerprint, + raw_token, + }) + } + + fn signal_shutdown(&self) { + self.stop_flag.store(true, Ordering::SeqCst); + self.parity_cancel.cancel(); + if let Ok(mut guard) = self.stop_cv.0.lock() { + *guard = true; + self.stop_cv.1.notify_all(); + } + // Wake blocking TcpListener::accept() + let _ = TcpStream::connect_timeout( + &SocketAddr::from(([127, 0, 0, 1], self.listen_port)), + Duration::from_millis(100), + ); + } +} + +/// Watch stdin for the parent's commit line. Before the commit, EOF, a read error, or any +/// other line means the parent died or abandoned this generation: exit without serving. +pub fn spawn_commit_watcher(generation: String) -> Arc { + let committed = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&committed); + thread::spawn(move || { + let expected = format!("commit {generation}\n"); + let mut line = Vec::with_capacity(expected.len()); + let read = std::io::stdin() + .lock() + .take(expected.len() as u64 + 1) + .read_until(b'\n', &mut line); + if read.is_ok() && line == expected.as_bytes() { + flag.store(true, Ordering::SeqCst); + return; + } + std::process::exit(3); + }); + committed +} + +/// Serve a direct foreground relay started by the operator (no lifecycle parent). +pub fn serve_foreground(paths: ChildPaths, listen_port: u16) -> Result<(), String> { + serve_foreground_inner(paths, listen_port, None) +} + +/// Serve a relay started by a lifecycle parent: refuse unless state still records +/// `generation`, and answer only `/healthz` until `committed` is set. +pub fn serve_foreground_lifecycle( + paths: ChildPaths, + listen_port: u16, + generation: &str, + committed: Arc, +) -> Result<(), String> { + serve_foreground_inner(paths, listen_port, Some((generation, committed))) +} + +fn serve_foreground_inner( + paths: ChildPaths, + listen_port: u16, + lifecycle: Option<(&str, Arc)>, +) -> Result<(), String> { + let startup_gate = FileLock::acquire_blocking(&paths.child_dir.join("daemon-start.lock")) + .map_err(|_| "serve refused: daemon startup gate unavailable".to_owned())?; + let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], listen_port))) + .map_err(|e| format!("failed to bind 127.0.0.1:{listen_port}: {e}"))?; + let state = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(state) + if state.pending_operation.is_none() && state.listen_port == listen_port => + { + state + } + _ => return Err("serve refused: child state changed during startup".to_owned()), + }; + if let Some((generation, _)) = &lifecycle + && state.daemon_generation.as_deref() != Some(*generation) + { + return Err("serve refused: this daemon generation is not the recorded one".to_owned()); + } + let committed = match lifecycle { + Some((_, committed)) => committed, + None => Arc::new(AtomicBool::new(true)), + }; + if !matches!(validate_existing_journal_paths(&paths), Ok(true)) + || read_verified_link_key(&paths.key_file, &state.token_fingerprint).is_err() + { + return Err("serve refused: child recovery state changed during startup".to_owned()); + } + drop(startup_gate); + let actual_port = listener + .local_addr() + .map_err(|e| format!("local_addr failed: {e}"))? + .port(); + let started_at_ms = now_epoch_ms(); + let shared = Arc::new(SharedServerState { + paths, + listen_port: actual_port, + started_instant: Instant::now(), + started_at_ms, + started_at_iso: format_iso8601_utc(started_at_ms), + stop_flag: AtomicBool::new(false), + stop_cv: (Mutex::new(false), Condvar::new()), + last_generation: Mutex::new(None), + committed, + parity_cancel: CancelSlot::default(), + }); + + let parity_shared = Arc::clone(&shared); + let parity_handle = thread::spawn(move || run_parity_loop(parity_shared)); + let sem = Arc::new((Mutex::new(0usize), Condvar::new())); + let control_active = Arc::new(AtomicUsize::new(0)); + + for incoming in listener.incoming() { + if shared.stop_flag.load(Ordering::SeqCst) { + break; + } + let Ok(client_stream) = incoming else { + continue; + }; + if shared.stop_flag.load(Ordering::SeqCst) { + break; + } + let sem_clone = Arc::clone(&sem); + let admitted = { + let (lock, _) = &*sem_clone; + let mut active = lock.lock().unwrap_or_else(|e| e.into_inner()); + if *active < MAX_CONCURRENT_WORKERS { + *active += 1; + true + } else { + false + } + }; + if !admitted { + // Every worker is busy. Health checks and the authenticated shutdown still get a + // short-lived control thread, so a full worker pool cannot keep the shutdown + // connection from being handled. A control connection holds its slot for at most + // `CONTROL_DEADLINE` per request head; past `MAX_CONTROL_CONNECTIONS` concurrent + // ones, a new connection is closed unanswered. + if control_active.fetch_add(1, Ordering::SeqCst) >= MAX_CONTROL_CONNECTIONS { + control_active.fetch_sub(1, Ordering::SeqCst); + continue; + } + let control_shared = Arc::clone(&shared); + let control_count = Arc::clone(&control_active); + thread::spawn(move || { + let _ = handle_client_connection( + client_stream, + &control_shared, + ConnectionMode::ControlOnly, + ); + control_count.fetch_sub(1, Ordering::SeqCst); + }); + continue; + } + let worker_shared = Arc::clone(&shared); + thread::spawn(move || { + let _ = handle_client_connection(client_stream, &worker_shared, ConnectionMode::Full); + let (lock, cv) = &*sem_clone; + let mut active = lock.lock().unwrap_or_else(|e| e.into_inner()); + *active = active.saturating_sub(1); + cv.notify_one(); + }); + } + + shared.signal_shutdown(); + let _ = parity_handle.join(); + Ok(()) +} + +fn run_parity_loop(shared: Arc) { + let backoffs_ms: [u64; 5] = [2_000, 5_000, 15_000, 30_000, 60_000]; + let mut failures = 0usize; + let mut local_seq: u64 = 0; + // Immediate initial attempt after short 250ms settle so Home sees parity quickly + let mut wait_ms = 250u64; + loop { + { + let (lock, cv) = &shared.stop_cv; + let guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + let (guard, _) = cv + .wait_timeout(guard, Duration::from_millis(wait_ms)) + .unwrap_or_else(|e| e.into_inner()); + if *guard || shared.stop_flag.load(Ordering::SeqCst) { + return; + } + } + if !shared.committed.load(Ordering::SeqCst) { + wait_ms = 50; + continue; + } + let StateReadOutcome::Valid(st) = read_child_state(&shared.paths.state_file) else { + wait_ms = 5_000; + continue; + }; + let Ok(creds) = shared.load_live_creds() else { + wait_ms = 5_000; + continue; + }; + local_seq = local_seq.max(st.parity_sequence).saturating_add(1); + let (last_apply_at, digest) = match &st.applied_settings { + Some(rec) => (Some(rec.applied_at_ms), Some(rec.digest.clone())), + None => (None, None), + }; + let report = LinkChildParityReport { + link_id: st.link_id, + last_apply_at, + runtime_started_at: shared.started_at_ms, + applied_codex_settings_digest: digest, + sequence: local_seq, + }; + match post_parity_with(st.tunnel_port, &creds, &report, Some(&shared.parity_cancel)) { + Ok(204) => { + failures = 0; + wait_ms = 60_000; + } + Ok(404) => return, // Home does not support child-parity + _ => { + let idx = failures.min(backoffs_ms.len() - 1); + wait_ms = backoffs_ms[idx]; + failures += 1; + } + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ConnectionMode { + Full, + /// Accepted while every worker is busy, with short timeouts and a request-head deadline: + /// `/healthz` and the authenticated shutdown only. + ControlOnly, +} + +fn handle_client_connection( + stream: TcpStream, + shared: &SharedServerState, + mode: ConnectionMode, +) -> std::io::Result<()> { + let _ = stream.set_nodelay(true); + let head_deadline = if mode == ConnectionMode::ControlOnly { + let _ = stream.set_read_timeout(Some(CONTROL_TIMEOUT)); + let _ = stream.set_write_timeout(Some(CONTROL_TIMEOUT)); + // One deadline covers the whole control exchange, including the shutdown proof frame. + Some(Instant::now() + CONTROL_DEADLINE) + } else { + let _ = stream.set_read_timeout(Some(Duration::from_secs(30))); + let _ = stream.set_write_timeout(Some(HEADER_AND_SSE_TIMEOUT)); + None + }; + let mut writer = stream.try_clone()?; + let mut reader = BufReader::new(DeadlineStream::new(stream, head_deadline, CONTROL_TIMEOUT)); + + let req = match read_request_head(&mut reader) { + Ok(r) => r, + Err(FramingError::Eof) => return Ok(()), + Err(FramingError::HeadersTooLarge) => { + return write_json_response( + &mut writer, + 431, + "Request Header Fields Too Large", + &[], + br#"{"error":"link relay request headers too large"}"#, + ); + } + Err(_) => { + return write_json_response( + &mut writer, + 400, + "Bad Request", + &[], + br#"{"error":"link relay request headers refused"}"#, + ); + } + }; + + let decision = classify_child_ingress( + &req.method, + &req.path, + &req.query, + req.header("host"), + req.header("origin"), + req.header("upgrade"), + ); + let committed = shared.committed.load(Ordering::SeqCst); + if mode == ConnectionMode::ControlOnly + && !matches!( + decision, + RouteDecision::LocalHealthz | RouteDecision::LocalShutdown + ) + { + return write_json_response( + &mut writer, + 503, + "Service Unavailable", + &[("Retry-After", "1"), ("Cache-Control", "no-store")], + br#"{"error":"child_busy"}"#, + ); + } + if !committed && decision != RouteDecision::LocalHealthz { + return write_json_response( + &mut writer, + 503, + "Service Unavailable", + &[("Retry-After", "1"), ("Cache-Control", "no-store")], + br#"{"error":"child_starting"}"#, + ); + } + + match decision { + RouteDecision::LocalShutdown => { + let valid_frame = |frame: &HttpRequestHead| { + frame.method == "POST" + && frame.path == "/__ocx_child/shutdown" + && frame.query.is_empty() + && frame.header("origin").is_none() + && frame.header("upgrade").is_none() + && frame.headers.iter().filter(|(k, _)| k.eq_ignore_ascii_case("host")).count() == 1 + && frame.header("host") == Some(format!("127.0.0.1:{}", shared.listen_port).as_str()) + && validate_and_sanitize_request_headers(&frame.headers) + .is_ok_and(|h| h.framing == BodyFraming::ContentLength(0)) + }; + let challenge_count = req.headers.iter() + .filter(|(k, _)| k.eq_ignore_ascii_case("x-ocx-child-shutdown-challenge")) + .count(); + let Some(nonce) = req.header("x-ocx-child-shutdown-challenge") else { + return write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#); + }; + let StateReadOutcome::Valid(state) = read_child_state(&shared.paths.state_file) else { + return write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#); + }; + if !valid_frame(&req) || challenge_count != 1 || !crate::relay_auth::is_valid_hex64(nonce) { + return write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#); + } + let Ok(server_nonce) = random_hex(32) else { + return write_json_response(&mut writer, 503, "Service Unavailable", &[], br#"{"error":"unavailable"}"#); + }; + let binding = format!("{nonce}{server_nonce}"); + let Some(listener_proof) = compute_child_shutdown_proof(&state.shutdown_secret, "listener", &binding) else { + return write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#); + }; + let Some(caller_proof) = compute_child_shutdown_proof(&state.shutdown_secret, "caller", &binding) else { + return Ok(()); + }; + write!( + writer, + "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nx-ocx-child-shutdown-nonce: {server_nonce}\r\nx-ocx-child-shutdown-proof: {listener_proof}\r\nConnection: keep-alive\r\n\r\n" + )?; + writer.flush()?; + let Ok(next) = read_request_head(&mut reader) else { return Ok(()) }; + let proof_count = next.headers.iter() + .filter(|(k, _)| k.eq_ignore_ascii_case("x-ocx-child-shutdown-proof")) + .count(); + let still_current = match read_child_state(&shared.paths.state_file) { + StateReadOutcome::Valid(current) => constant_time_eq( + current.shutdown_secret.as_bytes(), + state.shutdown_secret.as_bytes(), + ), + _ => false, + }; + if valid_frame(&next) + && proof_count == 1 + && still_current + && next.header("x-ocx-child-shutdown-proof") + .is_some_and(|proof| constant_time_eq(proof.as_bytes(), caller_proof.as_bytes())) + { + let _ = write_json_response(&mut writer, 200, "OK", &[], br#"{"ok":true}"#); + shared.signal_shutdown(); + return Ok(()); + } + write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#) + } + RouteDecision::LocalHealthz => { + let body = serde_json::to_vec(&serde_json::json!({ + "service": "opencodex", + "version": OCX_PROTOCOL_COMPAT_VERSION, + "role": "client", + "ephemeralChild": true, + "uptime": shared.started_instant.elapsed().as_secs_f64(), + "pid": std::process::id(), + "port": shared.listen_port, + "startedAt": shared.started_at_iso, + "generationCommitted": committed, + })) + .unwrap_or_default(); + write_json_response(&mut writer, 200, "OK", &[], &body) + } + RouteDecision::LocalReadyz => handle_local_readyz(&mut writer, shared), + RouteDecision::Reject403Origin => write_json_response( + &mut writer, + 403, + "Forbidden", + &[], + br#"{"error":{"message":"cross-origin data-plane request blocked","type":"invalid_request_error","code":"origin_rejected"}}"#, + ), + RouteDecision::Reject426Websocket => write_json_response( + &mut writer, + 426, + "Upgrade Required", + &[], + br#"{"error":{"message":"Responses WebSocket transport is disabled; use HTTP","type":"invalid_request_error","code":"upgrade_required"}}"#, + ), + RouteDecision::Reject404 => { + write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#) + } + RouteDecision::RelayData { auth } => { + relay_request(&mut reader, &mut writer, &req, shared, auth, LINK_RELAY_BODY_MAX_BYTES) + } + RouteDecision::RelayManagement => relay_request( + &mut reader, + &mut writer, + &req, + shared, + RelayAuthKind::Bearer, + HUB_RELAY_RESPONSE_BODY_MAX_BYTES, + ), + } +} + +fn handle_local_readyz( + writer: &mut W, + shared: &SharedServerState, +) -> std::io::Result<()> { + let uptime = shared.started_instant.elapsed().as_secs_f64(); + let pid = std::process::id(); + let port = shared.listen_port; + let StateReadOutcome::Valid(st) = read_child_state(&shared.paths.state_file) else { + let body = serde_json::to_vec(&serde_json::json!({ + "service": "opencodex", + "version": OCX_PROTOCOL_COMPAT_VERSION, + "role": "client", + "status": "pending", + "uptime": uptime, + "pid": pid, + "port": port, + "protocolVersion": 1, + "link": { "state": "unavailable", "reason": "link_credential_unavailable" } + })) + .unwrap_or_default(); + return write_json_response( + writer, + 503, + "Service Unavailable", + &[("Retry-After", "1"), ("Cache-Control", "no-store")], + &body, + ); + }; + let Ok(creds) = shared.load_live_creds() else { + let body = serde_json::to_vec(&serde_json::json!({ + "service": "opencodex", + "version": OCX_PROTOCOL_COMPAT_VERSION, + "role": "client", + "status": "pending", + "uptime": uptime, + "pid": pid, + "port": port, + "protocolVersion": 1, + "link": { "state": "unavailable", "reason": "link_credential_unavailable" } + })) + .unwrap_or_default(); + return write_json_response( + writer, + 503, + "Service Unavailable", + &[("Retry-After", "1"), ("Cache-Control", "no-store")], + &body, + ); + }; + match open_authenticated_upstream(st.tunnel_port, &creds, true) { + Ok(up) => { + if let Some(gen_val) = &up.connection_generation + && let Ok(mut g) = shared.last_generation.lock() + { + *g = Some(gen_val.clone()); + } + let link_obj = match up.connection_generation { + Some(g) => serde_json::json!({ "state": "available", "connectionGeneration": g }), + None => serde_json::json!({ "state": "available" }), + }; + let body = serde_json::to_vec(&serde_json::json!({ + "service": "opencodex", + "version": OCX_PROTOCOL_COMPAT_VERSION, + "role": "client", + "status": "ready", + "uptime": uptime, + "pid": pid, + "port": port, + "protocolVersion": 1, + "link": link_obj, + })) + .unwrap_or_default(); + write_json_response(writer, 200, "OK", &[], &body) + } + Err(err) => { + let reason = match err { + UpstreamError::RelayAuthFailed(_) => "link_auth_failed", + _ => "home_unreachable", + }; + let body = serde_json::to_vec(&serde_json::json!({ + "service": "opencodex", + "version": OCX_PROTOCOL_COMPAT_VERSION, + "role": "client", + "status": "pending", + "uptime": uptime, + "pid": pid, + "port": port, + "protocolVersion": 1, + "link": { "state": "unavailable", "reason": reason } + })) + .unwrap_or_default(); + write_json_response( + writer, + 503, + "Service Unavailable", + &[("Retry-After", "1"), ("Cache-Control", "no-store")], + &body, + ) + } + } +} + +fn relay_request( + client_reader: &mut BufReader, + client_writer: &mut TcpStream, + req: &HttpRequestHead, + shared: &SharedServerState, + auth_kind: RelayAuthKind, + response_cap_bytes: u64, +) -> std::io::Result<()> { + let validated = match validate_and_sanitize_request_headers(&req.headers) { + Ok(v) => v, + Err(FramingError::HeadersTooLarge) => { + return write_json_response( + client_writer, + 431, + "Request Header Fields Too Large", + &[], + br#"{"error":"link relay request headers too large"}"#, + ); + } + Err(_) => { + return write_json_response( + client_writer, + 400, + "Bad Request", + &[], + br#"{"error":"link relay request headers refused"}"#, + ); + } + }; + + let req_body = if req.method == "GET" || req.method == "HEAD" { + Vec::new() + } else { + let _ = client_reader + .get_ref() + .get_ref() + .set_read_timeout(Some(HEADER_AND_SSE_TIMEOUT)); + match read_bounded_body(client_reader, validated.framing, LINK_RELAY_BODY_MAX_BYTES) { + Ok(b) => b, + Err(e) if e.to_string().contains("body_too_large") => { + return write_json_response( + client_writer, + 413, + "Payload Too Large", + &[], + br#"{"error":"link relay request body too large"}"#, + ); + } + Err(_) => { + return write_json_response( + client_writer, + 400, + "Bad Request", + &[], + br#"{"error":"invalid request body"}"#, + ); + } + } + }; + + let StateReadOutcome::Valid(st) = read_child_state(&shared.paths.state_file) else { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[], + br#"{"error":"link_credential_unavailable"}"#, + ); + }; + let Ok(creds) = shared.load_live_creds() else { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[], + br#"{"error":"link_credential_unavailable"}"#, + ); + }; + + let req_id = random_hex(16).unwrap_or_else(|_| "00000000000000000000000000000000".to_owned()); + let mut upstream = match open_authenticated_upstream(st.tunnel_port, &creds, false) { + Ok(u) => u, + Err(UpstreamError::RelayAuthFailed(_)) => { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[ + ("Retry-After", "1"), + ("x-opencodex-link-request-id", &req_id), + ], + br#"{"error":"link relay authentication failed","code":"link_relay_auth_failed"}"#, + ); + } + Err(_) => { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[ + ("Retry-After", "1"), + ("x-opencodex-link-request-id", &req_id), + ], + br#"{"error":"Home is unreachable over Remote Link","code":"home_unreachable","message":"Home is unreachable over Remote Link"}"#, + ); + } + }; + + if let Some(gen_val) = &upstream.connection_generation + && let Ok(mut g) = shared.last_generation.lock() + { + *g = Some(gen_val.clone()); + } + + let _ = upstream + .writer + .set_read_timeout(Some(HEADER_AND_SSE_TIMEOUT)); + let _ = upstream + .writer + .set_write_timeout(Some(HEADER_AND_SSE_TIMEOUT)); + + if std::str::from_utf8(creds.raw_token.as_slice()).is_err() { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[], + br#"{"error":"link_credential_unavailable"}"#, + ); + } + + let before = format!( + "{} {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nAccept-Encoding: identity\r\nConnection: close\r\n{LINK_RELAY_SESSION_HEADER}: {}\r\n", + req.method, req.target, st.tunnel_port, upstream.nonce + ); + let mut after = String::new(); + for (k, v) in &validated.sanitized { + if k.eq_ignore_ascii_case("accept-encoding") { + continue; + } + after.push_str(&format!("{k}: {v}\r\n")); + } + if req.method != "GET" && req.method != "HEAD" { + after.push_str(&format!("Content-Length: {}\r\n", req_body.len())); + } + after.push_str("\r\n"); + let out_head = token_request_head(&before, auth_kind, creds.raw_token.as_slice(), &after); + + if upstream.writer.write_all(out_head.as_slice()).is_err() + || (!req_body.is_empty() && upstream.writer.write_all(&req_body).is_err()) + || upstream.writer.flush().is_err() + { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[ + ("Retry-After", "1"), + ("x-opencodex-link-request-id", &req_id), + ], + br#"{"error":"Home is unreachable over Remote Link","code":"home_unreachable","message":"Home is unreachable over Remote Link"}"#, + ); + } + + let resp_head = match read_response_head(&mut upstream.reader) { + Ok(h) => h, + Err(FramingError::HeadersTooLarge) => { + return write_json_response( + client_writer, + 502, + "Bad Gateway", + &[], + br#"{"error":"link relay response headers too large"}"#, + ); + } + Err(_) => { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[ + ("Retry-After", "1"), + ("x-opencodex-link-request-id", &req_id), + ], + br#"{"error":"Home is unreachable over Remote Link","code":"home_unreachable","message":"Home is unreachable over Remote Link"}"#, + ); + } + }; + + let Ok(up_framing) = response_framing(resp_head.status, &req.method, &resp_head.headers) else { + return write_json_response( + client_writer, + 502, + "Bad Gateway", + &[], + br#"{"error":"invalid upstream framing"}"#, + ); + }; + + let is_sse = is_sse_headers(&resp_head.headers); + if !is_sse + && let BodyFraming::ContentLength(len) = up_framing + && len > response_cap_bytes + { + return write_json_response( + client_writer, + 502, + "Bad Gateway", + &[], + br#"{"error":"link relay response body too large"}"#, + ); + } + + let sanitized_resp = sanitize_response_headers(&resp_head.headers); + let reason = if resp_head.reason.is_empty() { + "OK" + } else { + resp_head.reason.as_str() + }; + + if req.method == "HEAD" + || (100..200).contains(&resp_head.status) + || resp_head.status == 204 + || resp_head.status == 304 + { + write!(client_writer, "HTTP/1.1 {} {reason}\r\n", resp_head.status)?; + for (k, v) in &sanitized_resp { + write!(client_writer, "{k}: {v}\r\n")?; + } + write!(client_writer, "Connection: close\r\n\r\n")?; + return client_writer.flush(); + } + + write!(client_writer, "HTTP/1.1 {} {reason}\r\n", resp_head.status)?; + for (k, v) in &sanitized_resp { + write!(client_writer, "{k}: {v}\r\n")?; + } + write!( + client_writer, + "Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + )?; + client_writer.flush()?; + + let cap = if is_sse { + None + } else { + Some(response_cap_bytes) + }; + let _ = stream_upstream_to_chunked_client(&mut upstream.reader, client_writer, up_framing, cap); + Ok(()) +} diff --git a/native/ocx-child/src/relay_auth.rs b/native/ocx-child/src/relay_auth.rs new file mode 100644 index 00000000000..87b09cb9b7e --- /dev/null +++ b/native/ocx-child/src/relay_auth.rs @@ -0,0 +1,166 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::platform::{ + constant_time_eq, hex_decode_lower, hex_encode, random_hex, sha256_hex, volatile_zero_bytes, +}; + +pub const LINK_RELAY_AUTH_PATH: &str = "/.well-known/opencodex/link-relay-auth"; +pub const LINK_RELAY_SESSION_HEADER: &str = "x-opencodex-link-session"; +pub const LINK_RELAY_PROBE_HEADER: &str = "x-opencodex-link-probe"; +pub const LINK_RELAY_PROOF_HEADER: &str = "x-opencodex-link-proof"; +pub const LINK_RELAY_CONNECTION_HEADER: &str = "x-opencodex-link-connection"; +pub const LINK_RELAY_CONNECTION_PROOF_HEADER: &str = "x-opencodex-link-connection-proof"; +pub const LINK_RELAY_AUTH_VERSION: &str = "2"; + +type HmacSha256 = Hmac; + +pub fn compute_child_shutdown_proof(secret: &str, role: &str, binding: &str) -> Option { + if secret.len() != 48 + || !secret + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || (role != "caller" && role != "listener") + || binding.len() != 128 + || !binding + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return None; + } + let mut key = hex_decode_lower(secret)?; + let mac = HmacSha256::new_from_slice(&key); + volatile_zero_bytes(&mut key); + let mut mac = mac.ok()?; + mac.update(b"opencodex-child-shutdown-v1\0"); + mac.update(role.as_bytes()); + mac.update(b"\0"); + mac.update(binding.as_bytes()); + Some(hex_encode(&mac.finalize().into_bytes())) +} + +pub fn token_fingerprint_hex(raw_token: &str) -> String { + sha256_hex(raw_token.as_bytes()) +} + +pub fn is_valid_link_id(link_id: &str) -> bool { + link_id.len() == 20 + && link_id.starts_with("lnk_") + && link_id[4..] + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +pub fn is_valid_key_id(key_id: &str) -> bool { + let bytes = key_id.as_bytes(); + if bytes.is_empty() || bytes.len() > 256 { + return false; + } + if !bytes[0].is_ascii_alphanumeric() { + return false; + } + bytes[1..] + .iter() + .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-')) +} + +pub fn is_valid_hex64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +pub fn is_valid_connection_generation(value: &str) -> bool { + !value.is_empty() && value.len() <= 256 && value.bytes().all(|b| (0x21..=0x7e).contains(&b)) +} + +/// Computes `linkRelayProof` using the 32 raw bytes decoded from `fingerprint_hex`. +pub fn compute_relay_proof( + fingerprint_hex: &str, + direction: &str, + key_id: &str, + link_id: &str, + nonce: &str, +) -> Option { + if (direction != "caller" && direction != "listener") + || !is_valid_hex64(fingerprint_hex) + || !is_valid_key_id(key_id) + || !is_valid_link_id(link_id) + || !is_valid_hex64(nonce) + { + return None; + } + let key_bytes = hex_decode_lower(fingerprint_hex)?; // 32 raw bytes + let mut mac = HmacSha256::new_from_slice(&key_bytes).ok()?; + mac.update(b"opencodex-link-relay-v2\0"); + mac.update(direction.as_bytes()); + mac.update(b"\0"); + mac.update(key_id.as_bytes()); + mac.update(b"\0"); + mac.update(link_id.as_bytes()); + mac.update(b"\0"); + mac.update(nonce.as_bytes()); + Some(hex_encode(&mac.finalize().into_bytes())) +} + +pub fn compute_connection_proof( + fingerprint_hex: &str, + key_id: &str, + link_id: &str, + nonce: &str, + generation: &str, +) -> Option { + if !is_valid_connection_generation(generation) { + return None; + } + let _ = compute_relay_proof(fingerprint_hex, "listener", key_id, link_id, nonce)?; + let key_bytes = hex_decode_lower(fingerprint_hex)?; + let mut mac = HmacSha256::new_from_slice(&key_bytes).ok()?; + mac.update(b"opencodex-link-connection-v1\0"); + mac.update(key_id.as_bytes()); + mac.update(b"\0"); + mac.update(link_id.as_bytes()); + mac.update(b"\0"); + mac.update(nonce.as_bytes()); + mac.update(b"\0"); + mac.update(generation.as_bytes()); + Some(hex_encode(&mac.finalize().into_bytes())) +} + +pub fn relay_proof_matches(actual: Option<&str>, expected: Option<&str>) -> bool { + let (Some(a), Some(e)) = (actual, expected) else { + return false; + }; + if !is_valid_hex64(a) || !is_valid_hex64(e) { + return false; + } + let (Some(ab), Some(eb)) = (hex_decode_lower(a), hex_decode_lower(e)) else { + return false; + }; + constant_time_eq(&ab, &eb) +} + +pub struct RelayChallenge { + pub nonce: String, + pub caller_proof: String, + pub expected_listener_proof: String, +} + +pub fn create_relay_challenge( + fingerprint_hex: &str, + key_id: &str, + link_id: &str, +) -> Result { + let nonce = random_hex(32)?; + let caller_proof = compute_relay_proof(fingerprint_hex, "caller", key_id, link_id, &nonce) + .ok_or_else(|| "invalid link relay identity".to_owned())?; + let expected_listener_proof = + compute_relay_proof(fingerprint_hex, "listener", key_id, link_id, &nonce) + .ok_or_else(|| "invalid link relay identity".to_owned())?; + Ok(RelayChallenge { + nonce, + caller_proof, + expected_listener_proof, + }) +} diff --git a/native/ocx-child/src/routes.rs b/native/ocx-child/src/routes.rs new file mode 100644 index 00000000000..d3597d9c6c8 --- /dev/null +++ b/native/ocx-child/src/routes.rs @@ -0,0 +1,236 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayAuthKind { + Bearer, + ApiKeyHeader, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteDecision { + LocalHealthz, + LocalReadyz, + LocalShutdown, + Reject403Origin, + Reject426Websocket, + Reject404, + RelayData { auth: RelayAuthKind }, + RelayManagement, +} + +const CONTEXT_PATHS: &[&str] = &[ + "/v1/alpha/history/v2/list_windows", + "/v1/alpha/history/v2/list_items", + "/v1/alpha/history/v2/read_item", + "/v1/alpha/history/v2/search_contents", + "/v1/alpha/notes/v2/thread_hint", + "/v1/alpha/notes/v2/list_files_by_prefix", + "/v1/alpha/notes/v2/read_file", + "/v1/alpha/notes/v2/search_contents", + "/v1/alpha/notes/v2/append_to_file", + "/v1/alpha/notes/v2/write_file", +]; + +const POST_DATA_PATHS: &[&str] = &[ + "/v1/responses", + "/v1/responses/compact", + "/v1/messages", + "/v1/messages/count_tokens", + "/v1/chat/completions", + "/v1/audio/transcriptions", + "/v1/alpha/search", + "/v1/images/generations", + "/v1/images/edits", + "/v1/live", + "/v1/realtime/calls", +]; + +const LINK_MANAGEMENT_READ_PATHS: &[&str] = &[ + "/api/system/health", + "/api/startup-health", + "/api/providers", + "/api/settings", + "/api/usage", + "/api/effort-caps", + "/api/injection-model", + "/api/shadow-call-settings", + "/api/sidecar-settings", + "/api/v2", + "/api/provider-quotas", + "/api/quota-pool", + "/api/fallback", + "/api/codex-auth/active", + "/api/codex-auth/accounts", + "/api/oauth/accounts", + "/api/providers/keys", + "/api/oauth/providers", + "/api/catalog", + "/api/system/memory", + "/api/provider-presets", + "/api/provider-context-caps", +]; + +const FORBIDDEN_MANAGEMENT_QUERY_KEYS: &[&str] = &["refresh", "reload", "force", "quota"]; + +pub fn link_data_route_allowed(method: &str, path: &str, has_upgrade: bool) -> bool { + if has_upgrade { + return false; + } + if path == "/readyz" { + return method == "GET"; + } + if path == "/v1/catalog" || path == "/v1/hub-state" { + return method == "GET" || method == "HEAD"; + } + if path == "/v1/usage" || path == "/v1/models" { + return method == "GET"; + } + if POST_DATA_PATHS.contains(&path) || CONTEXT_PATHS.contains(&path) { + return method == "POST"; + } + method == "GET" && path.starts_with("/v1/opencodex/artifacts/") +} + +pub fn link_management_route_allowed( + method: &str, + path: &str, + query: &str, + has_upgrade: bool, +) -> bool { + if has_upgrade { + return false; + } + if path == "/v1/link-management/codex-settings" { + return method == "GET" && query.is_empty(); + } + let Some(suffix) = path.strip_prefix("/v1/link-management") else { + return false; + }; + if (method != "GET" && method != "HEAD") || !LINK_MANAGEMENT_READ_PATHS.contains(&suffix) { + return false; + } + let raw_q = query.strip_prefix('?').unwrap_or(query); + if raw_q.is_empty() { + return true; + } + for pair in raw_q.split('&') { + let key = pair.split('=').next().unwrap_or("").to_ascii_lowercase(); + if FORBIDDEN_MANAGEMENT_QUERY_KEYS.contains(&key.as_str()) { + return false; + } + } + true +} + +fn is_loopback_hostname(host: &str) -> bool { + matches!( + host.to_ascii_lowercase().as_str(), + "127.0.0.1" | "localhost" | "[::1]" | "::1" + ) +} + +fn parse_authority_hostname(authority: &str) -> Option<&str> { + let trimmed = authority.trim(); + if trimmed.is_empty() || trimmed.contains('/') || trimmed.contains('@') || trimmed.contains(' ') + { + return None; + } + if let Some(rest) = trimmed.strip_prefix('[') { + let end = rest.find(']')?; + let tail = &rest[end + 1..]; + if !tail.is_empty() { + let port = tail.strip_prefix(':')?; + if port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + } + return Some(&trimmed[..=end + 1]); + } + if let Some((h, p)) = trimmed.rsplit_once(':') { + if h.contains(':') + || h.is_empty() + || (!p.is_empty() && !p.bytes().all(|b| b.is_ascii_digit())) + { + return None; + } + return Some(h); + } + Some(trimmed) +} + +/// Mirrors `isAllowedRequestOrigin` (`src/server/auth-cors.ts:83-125`) for loopback listeners. +pub fn is_allowed_loopback_request_origin( + host_header: Option<&str>, + origin_header: Option<&str>, +) -> bool { + if let Some(host_val) = host_header + && let Some(hostname) = parse_authority_hostname(host_val) + && !is_loopback_hostname(hostname) + { + return false; + } + let Some(origin) = origin_header else { + return true; + }; + let trimmed = origin.trim(); + let rest = if let Some(r) = trimmed.strip_prefix("http://") { + r + } else if let Some(r) = trimmed.strip_prefix("https://") { + r + } else { + return false; + }; + let authority = rest.split('/').next().unwrap_or(""); + let Some(hostname) = parse_authority_hostname(authority) else { + return false; + }; + is_loopback_hostname(hostname) +} + +pub fn classify_child_ingress( + method: &str, + path: &str, + query: &str, + host_header: Option<&str>, + origin_header: Option<&str>, + upgrade_header: Option<&str>, +) -> RouteDecision { + if method == "POST" && path == "/__ocx_child/shutdown" && upgrade_header.is_none() { + return RouteDecision::LocalShutdown; + } + if let Some(upgrade) = upgrade_header { + if path == "/v1/responses" && upgrade.trim().eq_ignore_ascii_case("websocket") { + if !is_allowed_loopback_request_origin(host_header, origin_header) { + return RouteDecision::Reject403Origin; + } + return RouteDecision::Reject426Websocket; + } + return RouteDecision::Reject404; + } + if path == "/healthz" && method == "GET" { + return RouteDecision::LocalHealthz; + } + if path == "/readyz" { + return if method == "GET" { + RouteDecision::LocalReadyz + } else { + RouteDecision::Reject404 + }; + } + if link_data_route_allowed(method, path, false) { + if !is_allowed_loopback_request_origin(host_header, origin_header) { + return RouteDecision::Reject403Origin; + } + let auth = if path == "/v1/usage" { + RelayAuthKind::ApiKeyHeader + } else { + RelayAuthKind::Bearer + }; + return RouteDecision::RelayData { auth }; + } + if link_management_route_allowed(method, path, query, false) { + if !is_allowed_loopback_request_origin(host_header, origin_header) { + return RouteDecision::Reject403Origin; + } + return RouteDecision::RelayManagement; + } + RouteDecision::Reject404 +} diff --git a/native/ocx-child/src/state.rs b/native/ocx-child/src/state.rs new file mode 100644 index 00000000000..90d7e49989d --- /dev/null +++ b/native/ocx-child/src/state.rs @@ -0,0 +1,190 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::codex_settings::HomeCodexSettingsDocument; +use crate::platform::{ + SecretBytes, SecretString, reject_symlink, volatile_zero_bytes, write_private_atomic, +}; +use crate::relay_auth::token_fingerprint_hex; + +pub const CHILD_STATE_SCHEMA: &str = "ocx.child.state/1"; + +#[derive(Debug, Clone)] +pub struct ChildPaths { + pub opencodex_home: PathBuf, + pub codex_home: PathBuf, + pub child_dir: PathBuf, + pub lock_file: PathBuf, + pub state_file: PathBuf, + pub key_file: PathBuf, + pub journal_file: PathBuf, + pub rollback_file: PathBuf, + pub catalog_backup_file: PathBuf, + /// Random owner id this OpenCodex home uses in a `CODEX_HOME` ownership record. + pub owner_id_file: PathBuf, +} + +impl ChildPaths { + pub fn new(opencodex_home: PathBuf, codex_home: PathBuf) -> Self { + let child_dir = opencodex_home.join("child"); + Self { + lock_file: child_dir.join("lifecycle.lock"), + state_file: child_dir.join("state.json"), + key_file: child_dir.join("link-key"), + journal_file: child_dir.join("codex-journal.json"), + rollback_file: child_dir.join("attach-rollback.json"), + catalog_backup_file: child_dir.join("last-catalog.json"), + owner_id_file: child_dir.join("codex-home-owner-id"), + opencodex_home, + codex_home, + child_dir, + } + } + + pub fn codex_config_path(&self) -> PathBuf { + self.codex_home.join("config.toml") + } + + pub fn codex_catalog_path(&self) -> PathBuf { + self.codex_home.join("opencodex-catalog.json") + } + + pub fn codex_models_cache_path(&self) -> PathBuf { + self.codex_home.join("models_cache.json") + } + + /// Fixed lock serializing every ocx-child that writes this `CODEX_HOME`; never removed. + pub fn codex_lock_path(&self) -> PathBuf { + self.codex_home + .join(crate::codex_home::CODEX_HOME_LOCK_FILE) + } + + pub fn codex_owner_path(&self) -> PathBuf { + self.codex_home + .join(crate::codex_home::CODEX_HOME_OWNER_FILE) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AppliedSettingsRecord { + pub link_id: String, + pub fetched_at: String, + pub applied_at: String, + pub applied_at_ms: u64, + pub digest: String, + pub document: HomeCodexSettingsDocument, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChildStateV1 { + pub schema: String, + pub link_id: String, + pub api_key_id: String, + pub token_fingerprint: String, + pub tunnel_port: u16, + pub listen_port: u16, + pub connected_at: String, + pub catalog_fingerprint: String, + pub catalog_synced_at: String, + pub daemon_pid: Option, + pub daemon_started_at_ms: Option, + pub shutdown_secret: SecretString, + pub parity_sequence: u64, + pub pending_operation: Option, + pub applied_settings: Option, + /// Random nonce of the most recent daemon start. A lifecycle-started daemon serves only + /// if this still names its generation and its parent sent the matching commit line. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daemon_generation: Option, +} + +pub fn is_valid_daemon_generation(value: &str) -> bool { + value.len() == 32 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[derive(Debug)] +pub enum StateReadOutcome { + Absent, + Valid(Box), + Invalid(String), +} + +pub fn read_child_state(path: &Path) -> StateReadOutcome { + if let Err(e) = reject_symlink(path) { + return StateReadOutcome::Invalid(e); + } + let bytes = match fs::read(path) { + Ok(b) => SecretBytes::new(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return StateReadOutcome::Absent, + Err(e) => return StateReadOutcome::Invalid(format!("unreadable state.json: {e}")), + }; + match serde_json::from_slice::(bytes.as_slice()) { + Ok(st) if st.schema != CHILD_STATE_SCHEMA => { + StateReadOutcome::Invalid(format!("unsupported schema: {}", st.schema)) + } + Ok(st) + if st.shutdown_secret.len() == 48 + && st + .shutdown_secret + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => + { + StateReadOutcome::Valid(Box::new(st)) + } + Ok(_) => StateReadOutcome::Invalid("invalid child shutdown secret".to_owned()), + Err(e) => StateReadOutcome::Invalid(format!("malformed state.json: {e}")), + } +} + +pub fn write_child_state(path: &Path, state: &ChildStateV1) -> Result<(), String> { + let mut bytes = serde_json::to_vec_pretty(state) + .map_err(|e| format!("failed to serialize state.json: {e}"))?; + bytes.push(b'\n'); + let result = write_private_atomic(path, &bytes); + volatile_zero_bytes(&mut bytes); + result +} + +pub fn is_valid_link_token_format(raw_token: &str) -> bool { + raw_token.len() == 49 + && raw_token.starts_with("ocx_data_") + && raw_token[9..] + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +pub fn write_link_key(path: &Path, raw_token: &str) -> Result { + if !is_valid_link_token_format(raw_token) { + return Err("invalid link credential format".to_owned()); + } + let fp = token_fingerprint_hex(raw_token); + write_private_atomic(path, raw_token.as_bytes())?; + Ok(fp) +} + +pub fn read_verified_link_key( + path: &Path, + expected_fingerprint: &str, +) -> Result { + reject_symlink(path)?; + let raw = fs::read(path).map_err(|e| format!("link key unavailable: {e}"))?; + let secret = SecretBytes::new(raw); + let text = std::str::from_utf8(secret.as_slice()) + .map_err(|_| "link key invalid utf8".to_owned())? + .trim(); + if !is_valid_link_token_format(text) { + return Err("link key malformed".to_owned()); + } + let actual_fp = token_fingerprint_hex(text); + if actual_fp != expected_fingerprint { + return Err("link key fingerprint mismatch".to_owned()); + } + Ok(SecretBytes::new(text.as_bytes().to_vec())) +} diff --git a/native/ocx-child/src/test_hooks.rs b/native/ocx-child/src/test_hooks.rs new file mode 100644 index 00000000000..29e10dbfd30 --- /dev/null +++ b/native/ocx-child/src/test_hooks.rs @@ -0,0 +1,58 @@ +//! Env-gated synchronization points used by process-level lifecycle tests. +//! +//! Compiled only with `debug_assertions` (the profile `cargo test` builds the binary with); +//! a release build has no barrier and reads no barrier environment variable. +//! +//! Every call is a no-op unless both `OCX_CHILD_TEST_BARRIER_DIR` and +//! `OCX_CHILD_TEST_BARRIERS` are set. When the current point is listed, the process writes +//! `

/.reached` (containing its PID) and waits until `/.release` exists, +//! for at most 60 seconds. A point whose release file already exists passes immediately, so +//! a point reached repeatedly blocks only once. + +#[cfg(debug_assertions)] +pub const BARRIER_DIR_ENV: &str = "OCX_CHILD_TEST_BARRIER_DIR"; +#[cfg(debug_assertions)] +pub const BARRIERS_ENV: &str = "OCX_CHILD_TEST_BARRIERS"; + +#[cfg(debug_assertions)] +pub fn barrier(name: &str) { + use std::path::PathBuf; + use std::time::{Duration, Instant}; + + let Some(dir) = std::env::var_os(BARRIER_DIR_ENV).filter(|v| !v.is_empty()) else { + return; + }; + let Ok(list) = std::env::var(BARRIERS_ENV) else { + return; + }; + if !list.split(',').any(|entry| entry.trim() == name) { + return; + } + let dir = PathBuf::from(dir); + let release = dir.join(format!("{name}.release")); + if release.exists() { + return; + } + let _ = std::fs::write( + dir.join(format!("{name}.reached")), + std::process::id().to_string(), + ); + let deadline = Instant::now() + Duration::from_secs(60); + while !release.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(not(debug_assertions))] +#[inline(always)] +pub fn barrier(_name: &str) {} + +/// Keep a spawned relay from inheriting the barrier configuration of its lifecycle parent. +#[cfg(debug_assertions)] +pub fn strip_barrier_env(cmd: &mut std::process::Command) { + cmd.env_remove(BARRIER_DIR_ENV).env_remove(BARRIERS_ENV); +} + +#[cfg(not(debug_assertions))] +#[inline(always)] +pub fn strip_barrier_env(_cmd: &mut std::process::Command) {} diff --git a/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs new file mode 100644 index 00000000000..06548341b11 --- /dev/null +++ b/native/ocx-child/src/toml_surgeon.rs @@ -0,0 +1,1156 @@ +use serde::{Deserialize, Serialize}; + +use crate::codex_settings::HomeCodexSettings; + +pub const OCX_SECTION_MARKER: &str = "# Auto-injected by opencodex"; +pub const OCX_ROUTING_MARKER_LINE: &str = "# Auto-injected by opencodex (undo: ocx restore)"; +pub const MANAGED_SUBAGENT_DEFAULT_MARKER: &str = "# Managed by opencodex: native subagent default"; +pub const MANAGED_AGENTS_TABLE_MARKER: &str = + "# Managed by opencodex: native subagent defaults table"; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WrittenSubagentDefaults { + pub default_subagent_model: String, + pub default_subagent_reasoning_effort: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChildInjectedLedger { + pub openai_base_url: Option, + pub experimental_realtime_ws_base_url: Option, + pub model_catalog_json: Option, + pub injected_web_search: Option, + pub replaced_user_web_search_line: Option, + pub wrote_fast_mode: Option, + #[serde(default)] + pub prior_user_fast_mode_line: Option, + pub wrote_multi_agent_v2: Option, + #[serde(default)] + pub prior_user_multi_agent_v2_line: Option, + pub wrote_subagent_defaults: Option, +} + +pub fn dominant_eol(content: &str) -> &'static str { + let crlf = content.matches("\r\n").count(); + if crlf == 0 { + return "\n"; + } + let total_lf = content.matches('\n').count(); + let bare_lf = total_lf.saturating_sub(crlf); + if crlf >= bare_lf { "\r\n" } else { "\n" } +} + +pub fn apply_eol(content: &str, eol: &str) -> String { + let lf = content.replace("\r\n", "\n"); + if eol == "\n" { + lf + } else { + lf.replace('\n', "\r\n") + } +} + +pub fn is_ocx_routing_marker_line(line: &str) -> bool { + let t = line.trim(); + t == OCX_SECTION_MARKER || t == OCX_ROUTING_MARKER_LINE +} + +pub fn toml_escape_basic_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\u{0008}' => out.push_str("\\b"), + '\u{000c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 || c == '\u{007f}' => { + out.push_str(&format!("\\u{:04X}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn parse_toml_quoted_string(raw_val: &str) -> Option { + let trimmed = strip_inline_comment(raw_val).trim(); + if trimmed.len() >= 2 && trimmed.starts_with('\'') && trimmed.ends_with('\'') { + let inner = &trimmed[1..trimmed.len() - 1]; + if inner.contains('\'') { + return None; + } + return Some(inner.to_owned()); + } + if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + let inner = &trimmed[1..trimmed.len() - 1]; + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next()? { + '\\' => out.push('\\'), + '"' => out.push('"'), + 'b' => out.push('\u{0008}'), + 'f' => out.push('\u{000c}'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'u' => { + let mut hex = String::with_capacity(4); + for _ in 0..4 { + hex.push(chars.next()?); + } + let cp = u32::from_str_radix(&hex, 16).ok()?; + out.push(char::from_u32(cp)?); + } + 'U' => { + let mut hex = String::with_capacity(8); + for _ in 0..8 { + hex.push(chars.next()?); + } + let cp = u32::from_str_radix(&hex, 16).ok()?; + out.push(char::from_u32(cp)?); + } + _ => return None, + } + } else if ch == '"' { + return None; + } else { + out.push(ch); + } + } + return Some(out); + } + None +} + +fn strip_inline_comment(val: &str) -> &str { + let mut in_basic = false; + let mut in_literal = false; + let mut escaped = false; + for (idx, ch) in val.char_indices() { + if escaped { + escaped = false; + continue; + } + if in_basic { + if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_basic = false; + } + } else if in_literal { + if ch == '\'' { + in_literal = false; + } + } else { + match ch { + '"' => in_basic = true, + '\'' => in_literal = true, + '#' => return &val[..idx], + _ => {} + } + } + } + val +} + +pub fn parse_key_value_line(line: &str) -> Option<(String, &str)> { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') { + return None; + } + let (raw_key, rhs) = trimmed.split_once('=')?; + let key_trim = raw_key.trim(); + let key = if (key_trim.starts_with('"') && key_trim.ends_with('"')) + || (key_trim.starts_with('\'') && key_trim.ends_with('\'')) + { + parse_toml_quoted_string(key_trim)? + } else if !key_trim.is_empty() + && key_trim + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + key_trim.to_owned() + } else { + return None; + }; + Some((key, rhs.trim())) +} + +pub fn parse_single_line_string_assignment( + line: &str, + expected_key: &str, +) -> Result, String> { + let Some((key, rhs)) = parse_key_value_line(line) else { + return Ok(None); + }; + if key != expected_key { + return Ok(None); + } + parse_toml_quoted_string(rhs) + .map(Some) + .ok_or_else(|| format!("corrupt or multiline string assignment for {expected_key}")) +} + +fn is_table_header(line: &str) -> bool { + let t = line.trim(); + t.starts_with('[') && !t.starts_with('#') +} + +fn parse_simple_table_name(line: &str) -> Option { + let t = strip_inline_comment(line).trim(); + if !t.starts_with('[') || t.starts_with("[") && t.starts_with("[[") || !t.ends_with(']') { + return None; + } + let inner = t[1..t.len() - 1].trim(); + if (inner.starts_with('"') && inner.ends_with('"')) + || (inner.starts_with('\'') && inner.ends_with('\'')) + { + parse_toml_quoted_string(inner) + } else { + Some(inner.to_owned()) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MultilineQuote { + Basic, + Literal, +} + +fn quote_run_length(bytes: &[u8], start: usize, quote: u8) -> usize { + let mut end = start; + while end < bytes.len() && bytes[end] == quote && end - start < 5 { + end += 1; + } + end - start +} + +/// Ports `markStructuralLines` (`src/codex/toml-source-lines.ts:158-223`) so lines beginning +/// inside multiline strings (`"""`/`'''`) or multiline arrays/inline tables are never treated +/// as table headers, root keys, or ownership markers. Returns `(structural_flags, unclosed_multiline)`. +pub fn mark_structural_lines>(lines: &[S]) -> (Vec, bool) { + let mut multiline: Option = None; + let mut square_depth: usize = 0; + let mut curly_depth: usize = 0; + let mut flags = Vec::with_capacity(lines.len()); + + for raw in lines { + let text = raw.as_ref(); + flags.push(multiline.is_none() && square_depth == 0 && curly_depth == 0); + let mut single: Option = None; + let bytes = text.as_bytes(); + let mut idx = 0usize; + while idx < bytes.len() { + if let Some(MultilineQuote::Basic) = multiline { + if bytes[idx..].starts_with(b"\"\"\"") { + multiline = None; + idx += quote_run_length(bytes, idx, b'"'); + } else if bytes[idx] == b'\\' { + idx = (idx + 2).min(bytes.len()); + } else { + idx += 1; + } + continue; + } + if let Some(MultilineQuote::Literal) = multiline { + if bytes[idx..].starts_with(b"'''") { + multiline = None; + idx += quote_run_length(bytes, idx, b'\''); + } else { + idx += 1; + } + continue; + } + if let Some(MultilineQuote::Basic) = single { + if bytes[idx] == b'\\' { + idx = (idx + 2).min(bytes.len()); + } else if bytes[idx] == b'"' { + single = None; + idx += 1; + } else { + idx += 1; + } + continue; + } + if let Some(MultilineQuote::Literal) = single { + if bytes[idx] == b'\'' { + single = None; + } + idx += 1; + continue; + } + if bytes[idx] == b'#' { + break; + } + if bytes[idx..].starts_with(b"\"\"\"") { + multiline = Some(MultilineQuote::Basic); + idx += 3; + } else if bytes[idx..].starts_with(b"'''") { + multiline = Some(MultilineQuote::Literal); + idx += 3; + } else if bytes[idx] == b'"' { + single = Some(MultilineQuote::Basic); + idx += 1; + } else if bytes[idx] == b'\'' { + single = Some(MultilineQuote::Literal); + idx += 1; + } else if bytes[idx] == b'[' { + square_depth += 1; + idx += 1; + } else if bytes[idx] == b']' { + square_depth = square_depth.saturating_sub(1); + idx += 1; + } else if bytes[idx] == b'{' { + curly_depth += 1; + idx += 1; + } else if bytes[idx] == b'}' { + curly_depth = curly_depth.saturating_sub(1); + idx += 1; + } else { + idx += 1; + } + } + } + (flags, multiline.is_some()) +} + +fn root_end_index>(lines: &[S], structural: &[bool]) -> usize { + lines + .iter() + .enumerate() + .position(|(i, l)| structural[i] && is_table_header(l.as_ref())) + .unwrap_or(lines.len()) +} + +fn is_opencodex_catalog_path(path: &str) -> bool { + let norm = path.replace('\\', "/"); + norm.rsplit('/').next() == Some("opencodex-catalog.json") +} + +/// Mirrors `externalCodexModelProvider` (`src/codex/inject/config-toml.ts:30-45`). +pub fn external_codex_model_provider(content: &str) -> Option { + let lf = apply_eol(content, "\n"); + let lines: Vec<&str> = lf.split('\n').collect(); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); + let mut provider: Option = None; + for i in 0..root_end { + if !structural[i] { + continue; + } + let line = lines[i]; + if let Ok(Some(val)) = parse_single_line_string_assignment(line, "model_provider") { + provider = Some(val); + break; + } + } + let prov = provider?; + if prov.is_empty() || prov == "openai" || prov == "opencodex" { + return None; + } + // Check `[model_providers.]`: if present without `base_url`, treat as Codex App 26.924 placeholder. + let target_table_1 = format!("model_providers.{prov}"); + let target_table_2 = format!("model_providers.\"{prov}\""); + let mut table_start: Option = None; + for (idx, &line) in lines.iter().enumerate() { + if !structural[idx] { + continue; + } + if let Some(tname) = parse_simple_table_name(line) { + let normalized = tname.replace(' ', ""); + if normalized == target_table_1 || normalized == target_table_2 { + table_start = Some(idx); + break; + } + } + } + if let Some(start) = table_start { + let mut has_base_url = false; + for idx in (start + 1)..lines.len() { + if !structural[idx] { + continue; + } + if is_table_header(lines[idx]) { + break; + } + if let Ok(Some(_)) = parse_single_line_string_assignment(lines[idx], "base_url") { + has_base_url = true; + break; + } + } + if !has_base_url { + return None; + } + } + Some(prov) +} + +/// Checks for user-owned unmarked root routing keys before first attach so `ocx-child` fails closed. +pub fn detect_unowned_root_routing_conflict(content: &str) -> Result<(), String> { + if let Some(ext) = external_codex_model_provider(content) { + return Err(format!( + "attach refused: an external Codex model_provider ({ext}) owns config.toml" + )); + } + let lf = apply_eol(content, "\n"); + let lines: Vec<&str> = lf.split('\n').collect(); + let (structural, unclosed) = mark_structural_lines(&lines); + if unclosed { + return Err("attach refused: config.toml contains an unclosed multiline string".to_owned()); + } + let root_end = root_end_index(&lines, &structural); + for i in 0..root_end { + if !structural[i] { + continue; + } + let line = lines[i]; + for key in ["openai_base_url", "experimental_realtime_ws_base_url"] { + match parse_single_line_string_assignment(line, key) { + Ok(Some(_)) => { + let marked = + i > 0 && structural[i - 1] && is_ocx_routing_marker_line(lines[i - 1]); + if !marked { + return Err(format!( + "attach refused: user-owned root {key} already exists in config.toml" + )); + } + } + Ok(None) => {} + Err(e) => return Err(format!("attach refused: {e}")), + } + } + match parse_single_line_string_assignment(line, "model_catalog_json") { + Ok(Some(existing_path)) => { + if !is_opencodex_catalog_path(&existing_path) { + return Err( + "attach refused: user-owned model_catalog_json already exists in config.toml" + .to_owned(), + ); + } + } + Ok(None) => {} + Err(e) => return Err(format!("attach refused: {e}")), + } + } + Ok(()) +} + +fn insert_root_lines(content_lf: &str, added: &[String]) -> String { + if added.is_empty() { + return content_lf.to_owned(); + } + let mut lines: Vec = if content_lf.is_empty() { + Vec::new() + } else { + content_lf.split('\n').map(str::to_owned).collect() + }; + // Drop trailing empty split element if content ended with \n + let had_trailing_nl = content_lf.ends_with('\n'); + if had_trailing_nl && lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); + if root_end == lines.len() { + while !lines.is_empty() + && structural[lines.len() - 1] + && lines.last().is_some_and(|l| l.trim().is_empty()) + { + lines.pop(); + } + lines.extend(added.iter().cloned()); + let mut out = lines.join("\n"); + out.push('\n'); + return out; + } + let mut at = root_end; + while at > 0 && structural[at - 1] && lines[at - 1].trim().is_empty() { + at -= 1; + } + for (offset, item) in added.iter().enumerate() { + lines.insert(at + offset, item.clone()); + } + let mut out = lines.join("\n"); + if had_trailing_nl || !out.ends_with('\n') { + out.push('\n'); + } + out +} + +fn strip_owned_root_urls( + content_lf: &str, + journaled_base: Option<&str>, + journaled_ws: Option<&str>, +) -> String { + let lines: Vec<&str> = content_lf.split('\n').collect(); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); + let mut drop = vec![false; lines.len()]; + for i in 0..root_end { + if !structural[i] { + continue; + } + if let Ok(Some(val)) = parse_single_line_string_assignment(lines[i], "openai_base_url") { + let marker_above = + i > 0 && structural[i - 1] && is_ocx_routing_marker_line(lines[i - 1]); + let value_match = journaled_base == Some(val.as_str()); + if marker_above || value_match { + drop[i] = true; + if marker_above { + drop[i - 1] = true; + } + } else if marker_above { + drop[i - 1] = true; + } + } else if let Ok(Some(val)) = + parse_single_line_string_assignment(lines[i], "experimental_realtime_ws_base_url") + { + let marker_above = + i > 0 && structural[i - 1] && is_ocx_routing_marker_line(lines[i - 1]); + let value_match = journaled_ws == Some(val.as_str()); + if marker_above || value_match { + drop[i] = true; + if marker_above { + drop[i - 1] = true; + } + } + } + } + let kept: Vec<&str> = lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| (!drop[i]).then_some(l)) + .collect(); + kept.join("\n") +} + +fn strip_owned_web_search(content_lf: &str, injected_val: Option<&str>) -> String { + let lines: Vec<&str> = content_lf.split('\n').collect(); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); + let mut drop = vec![false; lines.len()]; + for i in 0..root_end { + if !structural[i] { + continue; + } + if let Ok(Some(val)) = parse_single_line_string_assignment(lines[i], "web_search") { + let marker_above = + i > 0 && structural[i - 1] && is_ocx_routing_marker_line(lines[i - 1]); + let value_match = injected_val == Some(val.as_str()); + if value_match { + drop[i] = true; + if marker_above { + drop[i - 1] = true; + } + } else if marker_above && injected_val.is_some() { + drop[i - 1] = true; + } + } + } + lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| (!drop[i]).then_some(l)) + .collect::>() + .join("\n") +} + +fn ensure_root_web_search( + content_lf: &str, + disable: bool, + prior_injected: Option<&str>, + prior_replaced_user_line: Option<&str>, +) -> Result<(String, Option, Option), String> { + let stripped = strip_owned_web_search(content_lf, prior_injected); + let lines: Vec<&str> = stripped.split('\n').collect(); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); + if !disable { + let mut has_user_ws = false; + for i in 0..root_end { + if !structural[i] { + continue; + } + if parse_single_line_string_assignment(lines[i], "web_search")?.is_some() { + has_user_ws = true; + break; + } + } + if let Some(restore) = prior_replaced_user_line + && !restore.trim().is_empty() + && !has_user_ws + { + let clean_restore = restore.trim_end_matches(['\r', '\n']).to_owned(); + return Ok((insert_root_lines(&stripped, &[clean_restore]), None, None)); + } + return Ok((stripped, None, None)); + } + let mut drop = vec![false; lines.len()]; + let mut replaced = prior_replaced_user_line.map(str::to_owned); + let mut saw_user = false; + for i in 0..root_end { + if !structural[i] { + continue; + } + if parse_single_line_string_assignment(lines[i], "web_search")?.is_some() { + if !saw_user { + replaced = Some(lines[i].to_owned()); + saw_user = true; + } + drop[i] = true; + } + } + let filtered = lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| (!drop[i]).then_some(l)) + .collect::>() + .join("\n"); + let next = insert_root_lines( + &filtered, + &[ + OCX_ROUTING_MARKER_LINE.to_owned(), + "web_search = \"disabled\"".to_owned(), + ], + ); + Ok((next, Some("disabled".to_owned()), replaced)) +} + +fn set_root_catalog_path(content_lf: &str, catalog_path: &str) -> String { + let key_line = format!( + "model_catalog_json = {}", + toml_escape_basic_string(catalog_path) + ); + let mut lines: Vec = content_lf.split('\n').map(str::to_owned).collect(); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); + let mut owned_indices = Vec::new(); + for (i, line) in lines[..root_end].iter().enumerate() { + if !structural[i] { + continue; + } + if let Ok(Some(existing)) = parse_single_line_string_assignment(line, "model_catalog_json") + && is_opencodex_catalog_path(&existing) + { + owned_indices.push(i); + } + } + if let Some(&first) = owned_indices.first() { + lines[first] = key_line; + let dup: std::collections::HashSet = owned_indices.into_iter().skip(1).collect(); + return lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| (!dup.contains(&i)).then_some(l)) + .collect::>() + .join("\n"); + } + insert_root_lines(content_lf, &[key_line]) +} + +fn strip_opencodex_catalog_path(content_lf: &str) -> String { + let lines: Vec<&str> = content_lf.split('\n').collect(); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); + lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| { + if i < root_end + && structural[i] + && let Ok(Some(p)) = parse_single_line_string_assignment(l, "model_catalog_json") + && is_opencodex_catalog_path(&p) + { + return None; + } + Some(l) + }) + .collect::>() + .join("\n") +} + +fn upsert_table_bool( + content_lf: &str, + table_name: &str, + key: &str, + val: bool, +) -> (String, Option) { + let mut lines: Vec = content_lf.split('\n').map(str::to_owned).collect(); + let had_trailing_nl = content_lf.ends_with('\n'); + if had_trailing_nl && lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + let (structural, _) = mark_structural_lines(&lines); + let table_idx = lines.iter().enumerate().position(|(i, l)| { + structural[i] && parse_simple_table_name(l).as_deref() == Some(table_name) + }); + let assign_line = format!("{key} = {val}"); + if let Some(start) = table_idx { + let end = lines[start + 1..] + .iter() + .enumerate() + .position(|(rel, l)| structural[start + 1 + rel] && is_table_header(l)) + .map(|rel| start + 1 + rel) + .unwrap_or(lines.len()); + for idx in (start + 1)..end { + if !structural[idx] { + continue; + } + let line = &mut lines[idx]; + if let Some((k, _)) = parse_key_value_line(line) + && k == key + { + let prior_line = line.clone(); + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + *line = format!("{indent}{assign_line}"); + let mut out = lines.join("\n"); + out.push('\n'); + return (out, Some(prior_line)); + } + } + let mut insert_at = end; + while insert_at > start + 1 + && structural[insert_at - 1] + && lines[insert_at - 1].trim().is_empty() + { + insert_at -= 1; + } + lines.insert(insert_at, assign_line); + let mut out = lines.join("\n"); + out.push('\n'); + return (out, None); + } + let trimmed = content_lf.trim_end_matches('\n'); + let out = if trimmed.is_empty() { + format!("[{table_name}]\n{assign_line}\n") + } else { + format!("{trimmed}\n\n[{table_name}]\n{assign_line}\n") + }; + (out, None) +} + +fn apply_subagent_defaults( + content_lf: &str, + desired: Option<&crate::codex_settings::SubagentDefaults>, + prior_written: Option<&WrittenSubagentDefaults>, +) -> (String, Option) { + let stripped = strip_subagent_defaults(content_lf, prior_written); + let Some(sub) = desired else { + return (stripped, None); + }; + let mut lines: Vec = stripped.split('\n').map(str::to_owned).collect(); + if stripped.ends_with('\n') && lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + let (structural, _) = mark_structural_lines(&lines); + let ag_idx = lines.iter().enumerate().position(|(i, l)| { + structural[i] && parse_simple_table_name(l).as_deref() == Some("agents") + }); + let mut to_add = vec![ + MANAGED_SUBAGENT_DEFAULT_MARKER.to_owned(), + format!( + "default_subagent_model = {}", + toml_escape_basic_string(&sub.model) + ), + ]; + if let Some(eff) = &sub.reasoning_effort { + to_add.push(MANAGED_SUBAGENT_DEFAULT_MARKER.to_owned()); + to_add.push(format!( + "default_subagent_reasoning_effort = {}", + toml_escape_basic_string(eff) + )); + } + let written = WrittenSubagentDefaults { + default_subagent_model: sub.model.clone(), + default_subagent_reasoning_effort: sub.reasoning_effort.clone(), + }; + if let Some(start) = ag_idx { + let end = lines[start + 1..] + .iter() + .enumerate() + .position(|(rel, l)| structural[start + 1 + rel] && is_table_header(l)) + .map(|rel| start + 1 + rel) + .unwrap_or(lines.len()); + // Respect user-owned existing keys in [agents] + for idx in (start + 1)..end { + if !structural[idx] { + continue; + } + let line = &lines[idx]; + if let Some((k, _)) = parse_key_value_line(line) + && (k == "default_subagent_model" || k == "default_subagent_reasoning_effort") + { + return (stripped, None); + } + } + let mut insert_at = end; + while insert_at > start + 1 + && structural[insert_at - 1] + && lines[insert_at - 1].trim().is_empty() + { + insert_at -= 1; + } + for (off, item) in to_add.into_iter().enumerate() { + lines.insert(insert_at + off, item); + } + let mut out = lines.join("\n"); + out.push('\n'); + return (out, Some(written)); + } + let trimmed = stripped.trim_end_matches('\n'); + let block = format!( + "{}\n[agents]\n{}\n", + MANAGED_AGENTS_TABLE_MARKER, + to_add.join("\n") + ); + let out = if trimmed.is_empty() { + block + } else { + format!("{trimmed}\n\n{block}") + }; + (out, Some(written)) +} + +fn strip_subagent_defaults(content_lf: &str, written: Option<&WrittenSubagentDefaults>) -> String { + let lines: Vec<&str> = content_lf.split('\n').collect(); + let (structural, _) = mark_structural_lines(&lines); + let Some(ag_idx) = lines.iter().enumerate().position(|(i, l)| { + structural[i] && parse_simple_table_name(l).as_deref() == Some("agents") + }) else { + return content_lf.to_owned(); + }; + let end_idx = lines[ag_idx + 1..] + .iter() + .enumerate() + .position(|(rel, l)| structural[ag_idx + 1 + rel] && is_table_header(l)) + .map(|rel| ag_idx + 1 + rel) + .unwrap_or(lines.len()); + let mut drop = vec![false; lines.len()]; + for i in (ag_idx + 1)..end_idx { + if !structural[i] { + continue; + } + let trimmed = lines[i].trim(); + if trimmed == MANAGED_SUBAGENT_DEFAULT_MARKER { + drop[i] = true; + continue; + } + if let Ok(Some(val)) = + parse_single_line_string_assignment(lines[i], "default_subagent_model") + { + let marked = i > ag_idx + 1 && lines[i - 1].trim() == MANAGED_SUBAGENT_DEFAULT_MARKER; + let matched = written.is_some_and(|w| w.default_subagent_model == val); + if (marked && written.is_none()) || matched { + drop[i] = true; + } + } else if let Ok(Some(val)) = + parse_single_line_string_assignment(lines[i], "default_subagent_reasoning_effort") + { + let marked = i > ag_idx + 1 && lines[i - 1].trim() == MANAGED_SUBAGENT_DEFAULT_MARKER; + let matched = written.and_then(|w| w.default_subagent_reasoning_effort.as_deref()) + == Some(val.as_str()); + if (marked && written.is_none()) || matched { + drop[i] = true; + } + } + } + let remaining_body_has_content = (ag_idx + 1..end_idx) + .filter(|&i| !drop[i]) + .any(|i| !structural[i] || !lines[i].trim().is_empty()); + if !remaining_body_has_content { + for d in &mut drop[ag_idx..end_idx] { + *d = true; + } + } + if ag_idx > 0 + && structural[ag_idx - 1] + && lines[ag_idx - 1].trim() == MANAGED_AGENTS_TABLE_MARKER + { + drop[ag_idx - 1] = true; + } + lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| (!drop[i]).then_some(l)) + .collect::>() + .join("\n") +} + +fn strip_features_ledger( + content_lf: &str, + wrote_fast_mode: Option, + prior_user_fast_mode_line: Option<&str>, + wrote_multi_agent_v2: Option, + prior_user_multi_agent_v2_line: Option<&str>, +) -> String { + if wrote_fast_mode.is_none() && wrote_multi_agent_v2.is_none() { + return content_lf.to_owned(); + } + let mut lines: Vec = content_lf.split('\n').map(str::to_owned).collect(); + let (structural, _) = mark_structural_lines(&lines); + let Some(feat_idx) = lines.iter().enumerate().position(|(i, l)| { + structural[i] && parse_simple_table_name(l).as_deref() == Some("features") + }) else { + return content_lf.to_owned(); + }; + let end_idx = lines[feat_idx + 1..] + .iter() + .enumerate() + .position(|(rel, l)| structural[feat_idx + 1 + rel] && is_table_header(l)) + .map(|rel| feat_idx + 1 + rel) + .unwrap_or(lines.len()); + let mut drop = vec![false; lines.len()]; + for i in (feat_idx + 1)..end_idx { + if !structural[i] { + continue; + } + if let Some((k, rhs)) = parse_key_value_line(&lines[i]) { + let val_clean = strip_inline_comment(rhs).trim(); + if let Some(fm) = wrote_fast_mode + && k == "fast_mode" + && val_clean == (if fm { "true" } else { "false" }) + { + if let Some(prior) = prior_user_fast_mode_line { + lines[i] = prior.to_owned(); + } else { + drop[i] = true; + } + continue; + } + if let Some(v2) = wrote_multi_agent_v2 + && k == "multi_agent_v2" + && val_clean == (if v2 { "true" } else { "false" }) + { + if let Some(prior) = prior_user_multi_agent_v2_line { + lines[i] = prior.to_owned(); + } else { + drop[i] = true; + } + } + } + } + let remaining_has_keys = (feat_idx + 1..end_idx) + .filter(|&i| !drop[i]) + .any(|i| !structural[i] || !lines[i].trim().is_empty()); + if !remaining_has_keys { + for d in &mut drop[feat_idx..end_idx] { + *d = true; + } + } + lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| (!drop[i]).then_some(l)) + .collect::>() + .join("\n") +} + +/// Applies Design B loopback keys + Home Codex settings onto `raw_input`. +pub fn apply_design_b_config( + raw_input: &str, + listen_port: u16, + catalog_path: &str, + settings: &HomeCodexSettings, + prior_ledger: Option<&ChildInjectedLedger>, +) -> Result<(String, ChildInjectedLedger), String> { + if let Some(ext) = external_codex_model_provider(raw_input) { + return Err(format!("external model_provider ({ext}) owns config.toml")); + } + let eol = if raw_input.is_empty() { + "\n" + } else { + dominant_eol(raw_input) + }; + let base_url = format!("http://127.0.0.1:{listen_port}/v1"); + let mut lf = apply_eol(raw_input, "\n"); + lf = strip_owned_root_urls( + &lf, + prior_ledger + .and_then(|l| l.openai_base_url.as_deref()) + .or(Some(base_url.as_str())), + prior_ledger + .and_then(|l| l.experimental_realtime_ws_base_url.as_deref()) + .or(Some(base_url.as_str())), + ); + let (ws_lf, wrote_ws, replaced_ws) = ensure_root_web_search( + &lf, + !settings.web_search_sidecar_enabled, + prior_ledger.and_then(|l| l.injected_web_search.as_deref()), + prior_ledger.and_then(|l| l.replaced_user_web_search_line.as_deref()), + )?; + lf = ws_lf; + lf = set_root_catalog_path(&lf, catalog_path); + lf = insert_root_lines( + &lf, + &[ + OCX_ROUTING_MARKER_LINE.to_owned(), + format!("openai_base_url = {}", toml_escape_basic_string(&base_url)), + OCX_ROUTING_MARKER_LINE.to_owned(), + format!( + "experimental_realtime_ws_base_url = {}", + toml_escape_basic_string(&base_url) + ), + ], + ); + let mut wrote_fast = prior_ledger.and_then(|l| l.wrote_fast_mode); + let mut prior_fast_line = prior_ledger.and_then(|l| l.prior_user_fast_mode_line.clone()); + if let Some(fm) = settings.fast_mode { + let (next_lf, overwritten) = upsert_table_bool(&lf, "features", "fast_mode", fm); + if wrote_fast.is_none() { + prior_fast_line = overwritten; + } + lf = next_lf; + wrote_fast = Some(fm); + } + let mut wrote_v2 = prior_ledger.and_then(|l| l.wrote_multi_agent_v2); + let mut prior_v2_line = prior_ledger.and_then(|l| l.prior_user_multi_agent_v2_line.clone()); + if let Some(mode) = &settings.multi_agent_mode { + let v2_bool = mode == "v2"; + let (next_lf, overwritten) = upsert_table_bool(&lf, "features", "multi_agent_v2", v2_bool); + if wrote_v2.is_none() { + prior_v2_line = overwritten; + } + lf = next_lf; + wrote_v2 = Some(v2_bool); + } + let (sub_lf, wrote_sub) = apply_subagent_defaults( + &lf, + settings.subagent_defaults.as_ref(), + prior_ledger.and_then(|l| l.wrote_subagent_defaults.as_ref()), + ); + lf = sub_lf; + let ledger = ChildInjectedLedger { + openai_base_url: Some(base_url.clone()), + experimental_realtime_ws_base_url: Some(base_url), + model_catalog_json: Some(catalog_path.to_owned()), + injected_web_search: wrote_ws, + replaced_user_web_search_line: replaced_ws, + wrote_fast_mode: wrote_fast, + prior_user_fast_mode_line: prior_fast_line, + wrote_multi_agent_v2: wrote_v2, + prior_user_multi_agent_v2_line: prior_v2_line, + wrote_subagent_defaults: wrote_sub, + }; + Ok((apply_eol(&lf, eol), ledger)) +} + +/// Tier-2 surgical value-matched strip surviving `#1798` comment-stripping rewrites. +pub fn surgical_detach_config( + raw_current: &str, + ledger: &ChildInjectedLedger, +) -> Result, String> { + let eol = dominant_eol(raw_current); + let lf = apply_eol(raw_current, "\n"); + // Validate every root managed key line parses cleanly; fail closed on corrupt spans. + let lines: Vec<&str> = lf.split('\n').collect(); + let (structural, unclosed) = mark_structural_lines(&lines); + if unclosed { + return Err("config.toml contains an unclosed multiline string".to_owned()); + } + let root_end = root_end_index(&lines, &structural); + for i in 0..root_end { + if !structural[i] { + continue; + } + let line = lines[i]; + for managed_key in [ + "openai_base_url", + "experimental_realtime_ws_base_url", + "model_catalog_json", + "web_search", + ] { + if let Some((k, _)) = parse_key_value_line(line) + && k == managed_key + { + parse_single_line_string_assignment(line, managed_key)?; + } + } + } + // Also drop orphaned marker above user-edited openai_base_url + let mut drop_orphan = vec![false; lines.len()]; + for i in 1..root_end { + if structural[i] + && structural[i - 1] + && is_ocx_routing_marker_line(lines[i - 1]) + && let Some((k, _)) = parse_key_value_line(lines[i]) + && (k == "openai_base_url" || k == "experimental_realtime_ws_base_url") + { + drop_orphan[i - 1] = true; + } + } + let mut work = lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| (!drop_orphan[i]).then_some(l)) + .collect::>() + .join("\n"); + work = strip_owned_root_urls( + &work, + ledger.openai_base_url.as_deref(), + ledger.experimental_realtime_ws_base_url.as_deref(), + ); + work = strip_opencodex_catalog_path(&work); + let (ws_restored, _, _) = ensure_root_web_search( + &work, + false, + ledger.injected_web_search.as_deref(), + ledger.replaced_user_web_search_line.as_deref(), + )?; + work = ws_restored; + work = strip_features_ledger( + &work, + ledger.wrote_fast_mode, + ledger.prior_user_fast_mode_line.as_deref(), + ledger.wrote_multi_agent_v2, + ledger.prior_user_multi_agent_v2_line.as_deref(), + ); + work = strip_subagent_defaults(&work, ledger.wrote_subagent_defaults.as_ref()); + work = collapse_excess_blank_lines(&work); + if work.trim().is_empty() { + Ok(None) + } else { + if !work.ends_with('\n') { + work.push('\n'); + } + Ok(Some(apply_eol(&work, eol))) + } +} + +fn collapse_excess_blank_lines(lf: &str) -> String { + let lines: Vec<&str> = lf.split('\n').collect(); + let (structural, _) = mark_structural_lines(&lines); + let mut out_lines: Vec<&str> = Vec::with_capacity(lines.len()); + let mut blank_run = 0usize; + let mut started = false; + for (i, &line) in lines.iter().enumerate() { + let is_struct_blank = structural[i] && line.is_empty(); + if !started && is_struct_blank { + continue; + } + started = true; + if is_struct_blank { + blank_run += 1; + if blank_run > 1 { + continue; + } + } else { + blank_run = 0; + } + out_lines.push(line); + } + while out_lines.len() > 1 + && out_lines.last() == Some(&"") + && out_lines[out_lines.len() - 2].is_empty() + { + out_lines.pop(); + } + out_lines.join("\n") +} diff --git a/native/ocx-child/tests/common/mod.rs b/native/ocx-child/tests/common/mod.rs new file mode 100644 index 00000000000..268fc63ab4a --- /dev/null +++ b/native/ocx-child/tests/common/mod.rs @@ -0,0 +1,485 @@ +#![allow(dead_code)] +//! Shared process-level harness for ocx-child integration tests: a mock Home that speaks +//! link-relay-auth v2, binary runners, and helpers for the env-gated test barrier. + +use std::collections::HashSet; +use std::fs; +use std::io::{BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use ocx_child::http_wire::{ + read_bounded_body, read_request_head, validate_and_sanitize_request_headers, +}; +use ocx_child::parity::{LinkChildParityReport, parse_link_child_parity_report}; +use ocx_child::relay_auth::{ + compute_connection_proof, compute_relay_proof, relay_proof_matches, token_fingerprint_hex, +}; + +pub struct MockHomeState { + pub spoof_listener_proof: AtomicBool, + pub fast_mode: Mutex>, + pub catalog_slug: Mutex, + pub catalog_status: AtomicU16, + pub settings_status: AtomicU16, + pub used_nonces: Mutex>, + pub parity_reports: Mutex>, + pub observed_data_headers: Mutex>>, + pub stop: AtomicBool, + /// 0 answers parity reports, 1 holds the socket without answering, 2 trickles an + /// unfinished response until the peer goes away. + pub parity_mode: AtomicU8, + pub parity_stalled: AtomicUsize, +} + +pub struct MockHomeServer { + pub port: u16, + pub state: Arc, + pub handle: Option>, +} + +impl MockHomeServer { + pub fn start(raw_token: &str, key_id: &str, link_id: &str) -> Self { + let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let port = listener.local_addr().unwrap().port(); + let fp = token_fingerprint_hex(raw_token); + let raw_token = raw_token.to_owned(); + let key_id = key_id.to_owned(); + let link_id = link_id.to_owned(); + let state = Arc::new(MockHomeState { + spoof_listener_proof: AtomicBool::new(false), + fast_mode: Mutex::new(None), + catalog_slug: Mutex::new("home-initial".to_owned()), + catalog_status: AtomicU16::new(200), + settings_status: AtomicU16::new(200), + used_nonces: Mutex::new(HashSet::new()), + parity_reports: Mutex::new(Vec::new()), + observed_data_headers: Mutex::new(Vec::new()), + stop: AtomicBool::new(false), + parity_mode: AtomicU8::new(0), + parity_stalled: AtomicUsize::new(0), + }); + let st = Arc::clone(&state); + let handle = thread::spawn(move || { + for incoming in listener.incoming() { + if st.stop.load(Ordering::SeqCst) { + break; + } + let Ok(sock) = incoming else { + continue; + }; + if st.stop.load(Ordering::SeqCst) { + break; + } + let st2 = Arc::clone(&st); + let fp2 = fp.clone(); + let tok2 = raw_token.clone(); + let kid2 = key_id.clone(); + let lid2 = link_id.clone(); + thread::spawn(move || { + handle_mock_home_socket(sock, &st2, &fp2, &tok2, &kid2, &lid2) + }); + } + }); + Self { + port, + state, + handle: Some(handle), + } + } + + pub fn shutdown(&mut self) { + self.state.stop.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(SocketAddr::from(([127, 0, 0, 1], self.port))); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +impl Drop for MockHomeServer { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn parse_query_param(query: &str, name: &str) -> Option { + let q = query.strip_prefix('?').unwrap_or(query); + for pair in q.split('&') { + if let Some((k, v)) = pair.split_once('=') + && k == name + { + return Some(v.replace("%3A", ":")); + } + } + None +} + +fn handle_mock_home_socket( + sock: TcpStream, + st: &MockHomeState, + fp: &str, + raw_token: &str, + key_id: &str, + link_id: &str, +) { + let peer_addr = sock.peer_addr().unwrap(); + let _ = sock.set_read_timeout(Some(Duration::from_secs(5))); + let mut writer = sock.try_clone().unwrap(); + let mut reader = BufReader::new(sock); + + // Request 1 on socket MUST be credential-free preflight + let Ok(pre) = read_request_head(&mut reader) else { + return; + }; + if pre.path != "/.well-known/opencodex/link-relay-auth" || pre.method != "GET" { + let _ = writer + .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + return; + } + assert!(pre.header("authorization").is_none()); + assert!(pre.header("x-opencodex-api-key").is_none()); + let ver = parse_query_param(&pre.query, "version").unwrap_or_default(); + let q_key = parse_query_param(&pre.query, "key").unwrap_or_default(); + let q_link = parse_query_param(&pre.query, "link").unwrap_or_default(); + let nonce = parse_query_param(&pre.query, "nonce").unwrap_or_default(); + let proof = parse_query_param(&pre.query, "proof").unwrap_or_default(); + let expected_caller = compute_relay_proof(fp, "caller", key_id, link_id, &nonce); + if ver != "2" + || q_key != key_id + || q_link != link_id + || !relay_proof_matches(Some(&proof), expected_caller.as_deref()) + { + let _ = writer + .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + return; + } + let is_probe = pre.header("x-opencodex-link-probe") == Some("1"); + if !is_probe { + let mut used = st.used_nonces.lock().unwrap(); + if !used.insert(nonce.clone()) { + let _ = writer.write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + return; + } + } + let listener_proof = if st.spoof_listener_proof.load(Ordering::SeqCst) { + "0".repeat(64) + } else { + compute_relay_proof(fp, "listener", key_id, link_id, &nonce).unwrap() + }; + let generation = "mock-home-inst:1"; + let conn_proof = compute_connection_proof(fp, key_id, link_id, &nonce, generation).unwrap(); + let pre_resp = format!( + "HTTP/1.1 204 No Content\r\nx-opencodex-link-proof: {listener_proof}\r\nx-opencodex-link-connection: {generation}\r\nx-opencodex-link-connection-proof: {conn_proof}\r\ncache-control: no-store\r\n\r\n" + ); + if writer.write_all(pre_resp.as_bytes()).is_err() || writer.flush().is_err() || is_probe { + return; + } + + // Request 2 MUST arrive on this EXACT same socket (`peer_addr`) with `x-opencodex-link-session: `! + assert_eq!(reader.get_ref().peer_addr().unwrap(), peer_addr); + let Ok(data_req) = read_request_head(&mut reader) else { + return; + }; + assert_eq!( + data_req.header("x-opencodex-link-session"), + Some(nonce.as_str()) + ); + st.observed_data_headers + .lock() + .unwrap() + .push(data_req.headers.clone()); + + let authed = data_req.header("x-opencodex-api-key") == Some(raw_token) + || data_req.header("authorization") == Some(&format!("Bearer {raw_token}")); + if !authed { + let _ = writer.write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + return; + } + + let v = validate_and_sanitize_request_headers(&data_req.headers).unwrap(); + let body = if data_req.method == "POST" { + read_bounded_body(&mut reader, v.framing, 1024 * 1024).unwrap() + } else { + Vec::new() + }; + + match (data_req.method.as_str(), data_req.path.as_str()) { + ("GET", "/readyz") => { + let b = br#"{"status":"ready","protocolVersion":1,"minClientProtocolVersion":1,"maxClientProtocolVersion":1,"version":"2.98.0","managementUrl":"http://127.0.0.1"}"#; + let _ = write!( + writer, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + b.len() + ); + let _ = writer.write_all(b); + } + ("GET", "/v1/catalog") => { + let status = st.catalog_status.load(Ordering::SeqCst); + if status != 200 { + let _ = write!( + writer, + "HTTP/1.1 {status} Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + return; + } + let slug = st.catalog_slug.lock().unwrap().clone(); + let b = format!("{{\"models\":[{{\"slug\":\"{slug}\"}}]}}\n"); + let _ = write!( + writer, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", + b.len() + ); + } + ("GET", "/v1/link-management/codex-settings") => { + let status = st.settings_status.load(Ordering::SeqCst); + if status != 200 { + let _ = write!( + writer, + "HTTP/1.1 {status} Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + return; + } + let fm = *st.fast_mode.lock().unwrap(); + let doc = serde_json::json!({ + "version": 1, + "home": { "name": "mock-home", "version": "2.98.0" }, + "settings": { + "fastMode": fm, + "multiAgentMode": "v1", + "webSearchSidecarEnabled": false, + "subagentDefaults": { "model": "gpt-5.6-sol", "reasoningEffort": "high" } + } + }); + let b = serde_json::to_vec(&doc).unwrap(); + let _ = write!( + writer, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + b.len() + ); + let _ = writer.write_all(&b); + } + ("POST", "/v1/link-management/child-parity") => { + let text = std::str::from_utf8(&body).unwrap(); + let parsed = parse_link_child_parity_report(text).expect("valid LinkChildParityReport"); + let mode = st.parity_mode.load(Ordering::SeqCst); + if mode != 0 { + st.parity_stalled.fetch_add(1, Ordering::SeqCst); + hold_parity_socket(&mut writer, mode, st); + return; + } + st.parity_reports.lock().unwrap().push(parsed); + let _ = writer.write_all( + b"HTTP/1.1 204 No Content\r\ncache-control: no-store\r\nConnection: close\r\n\r\n", + ); + } + ("POST", "/v1/responses") => { + let _ = writer.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"); + let ev1 = b"event: response.created\ndata: {\"id\":\"resp_1\"}\n\n"; + let ev2 = b"event: response.completed\ndata: {\"ok\":true}\n\n"; + let _ = write!(writer, "{:x}\r\n", ev1.len()); + let _ = writer.write_all(ev1); + let _ = writer.write_all(b"\r\n"); + let _ = writer.flush(); + let _ = write!(writer, "{:x}\r\n", ev2.len()); + let _ = writer.write_all(ev2); + let _ = writer.write_all(b"\r\n0\r\n\r\n"); + } + _ => { + let _ = writer.write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } + } + let _ = writer.flush(); +} + +pub fn run_bin( + ocx_home: &Path, + codex_home: &Path, + listen_port: u16, + args: &[&str], + stdin_bytes: Option<&[u8]>, +) -> (i32, String, String) { + let exe = env!("CARGO_BIN_EXE_ocx-child"); + let mut cmd = Command::new(exe); + cmd.args(args) + .env("OPENCODEX_HOME", ocx_home) + .env("CODEX_HOME", codex_home) + .env("OCX_CHILD_LISTEN_PORT", listen_port.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if stdin_bytes.is_some() { + cmd.stdin(Stdio::piped()); + } else { + cmd.stdin(Stdio::null()); + } + let mut child = cmd.spawn().unwrap(); + if let Some(input) = stdin_bytes { + child.stdin.take().unwrap().write_all(input).unwrap(); + } + let out = child.wait_with_output().unwrap(); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +pub fn raw_http_exchange(port: u16, raw_req: &str) -> String { + let mut sock = TcpStream::connect(SocketAddr::from(([127, 0, 0, 1], port))).unwrap(); + sock.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + sock.write_all(raw_req.as_bytes()).unwrap(); + sock.flush().unwrap(); + let mut out = Vec::new(); + use std::io::Read; + let _ = sock.read_to_end(&mut out); + String::from_utf8_lossy(&out).into_owned() +} + +fn hold_parity_socket(writer: &mut TcpStream, mode: u8, st: &MockHomeState) { + let deadline = Instant::now() + Duration::from_secs(60); + if mode == 2 { + if writer + .write_all( + b"HTTP/1.1 204 No Content +x-trickle: ", + ) + .is_err() + { + return; + } + while Instant::now() < deadline && !st.stop.load(Ordering::SeqCst) { + if writer + .write_all(b"a") + .and_then(|()| writer.flush()) + .is_err() + { + return; + } + thread::sleep(Duration::from_millis(200)); + } + return; + } + let _ = writer.set_read_timeout(Some(Duration::from_millis(50))); + let mut probe = [0u8; 1]; + while Instant::now() < deadline && !st.stop.load(Ordering::SeqCst) { + match writer.read(&mut probe) { + Ok(0) => return, + Ok(_) => {} + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + Err(_) => return, + } + } +} + +pub const BARRIER_DIR_ENV: &str = "OCX_CHILD_TEST_BARRIER_DIR"; +pub const BARRIERS_ENV: &str = "OCX_CHILD_TEST_BARRIERS"; + +/// Directory-backed handle for the env-gated barrier in `ocx_child::test_hooks`. +pub struct Barrier { + pub dir: PathBuf, +} + +impl Barrier { + pub fn new(root: &Path, label: &str) -> Self { + let dir = root.join(format!("barrier-{label}")); + fs::create_dir_all(&dir).unwrap(); + Self { dir } + } + + pub fn apply(&self, cmd: &mut Command, names: &str) { + cmd.env(BARRIER_DIR_ENV, &self.dir).env(BARRIERS_ENV, names); + } + + pub fn reached(&self, name: &str) -> bool { + self.dir.join(format!("{name}.reached")).exists() + } + + pub fn wait_reached(&self, name: &str, timeout: Duration) -> bool { + wait_until(timeout, || self.reached(name)) + } + + pub fn release(&self, name: &str) { + fs::write(self.dir.join(format!("{name}.release")), b"go").unwrap(); + } +} + +pub fn wait_until(timeout: Duration, mut done: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + timeout; + loop { + if done() { + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(20)); + } +} + +pub struct Scratch(pub PathBuf); + +impl Scratch { + pub fn new(label: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "ocx-child-{label}-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + fs::create_dir_all(&root).unwrap(); + Self(root) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +pub fn child_command( + ocx_home: &Path, + codex_home: &Path, + listen_port: u16, + args: &[&str], +) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_ocx-child")); + cmd.args(args) + .env("OPENCODEX_HOME", ocx_home) + .env("CODEX_HOME", codex_home) + .env("OCX_CHILD_LISTEN_PORT", listen_port.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd +} + +pub fn attach_args<'a>( + tunnel_port: &'a str, + link_id: &'a str, + listen_port: &'a str, +) -> Vec<&'a str> { + vec![ + "attach", + "--key-stdin", + "--tunnel-port", + tunnel_port, + "--link-id", + link_id, + "--listen-port", + listen_port, + ] +} diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs new file mode 100644 index 00000000000..1b6b64a8cdb --- /dev/null +++ b/native/ocx-child/tests/conformance.rs @@ -0,0 +1,1537 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +use ocx_child::cli::AttachRollbackSnapshot; +use ocx_child::codex_home::{ClaimPolicy, CodexHomeGuard}; +use ocx_child::codex_settings::{ + HomeCodexSettings, codex_settings_digest, codex_settings_digest_payload, + parse_home_codex_settings_document, +}; +use ocx_child::http_wire::{ + BodyFraming, FramingError, stream_upstream_to_chunked_client, + validate_and_sanitize_request_headers, +}; +use ocx_child::journal::{ + DetachError, DetachOutcome, JournalWriteFault, apply_attach_or_sync, + apply_attach_or_sync_with_fault, offline_detach, offline_detach_deferred, +}; +use ocx_child::parity::parse_link_child_parity_report; +use ocx_child::relay_auth::{ + compute_connection_proof, compute_relay_proof, relay_proof_matches, token_fingerprint_hex, +}; +use ocx_child::routes::{RelayAuthKind, RouteDecision, classify_child_ingress}; +use ocx_child::state::ChildPaths; +use ocx_child::toml_surgeon::{apply_design_b_config, surgical_detach_config}; + +#[test] +fn detach_preserves_user_edited_web_search_with_managed_marker() { + let settings = HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: false, + subagent_defaults: None, + }; + let (injected, ledger) = apply_design_b_config( + "web_search = \"cached\"\n", + 10100, + "/tmp/catalog.json", + &settings, + None, + ) + .unwrap(); + // Unchanged: the journaled injected value is removed and the user's line comes back. + let untouched = surgical_detach_config(&injected, &ledger).unwrap().unwrap(); + assert!(untouched.contains("web_search = \"cached\""), "{untouched}"); + assert!( + !untouched.contains("web_search = \"disabled\""), + "{untouched}" + ); + let edited = injected.replace("web_search = \"disabled\"", "web_search = \"live\""); + assert_ne!(edited, injected); + let restored = surgical_detach_config(&edited, &ledger).unwrap().unwrap(); + assert!(restored.contains("web_search = \"live\""), "{restored}"); + assert!(!restored.contains("web_search = \"cached\""), "{restored}"); +} + +#[test] +fn detached_reconciled_cache_can_be_retried_after_backup_cleanup() { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-retry-cache-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let _guard = ScratchGuard(scratch.clone()); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let paths = ChildPaths::new(ocx_home, codex_home); + let settings = HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: true, + subagent_defaults: None, + }; + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"remote-model"}]}"#, + &settings, + ) + .unwrap(); + let cache_path = paths.codex_models_cache_path(); + let mut cache: serde_json::Value = + serde_json::from_slice(&fs::read(&cache_path).unwrap()).unwrap(); + cache["models"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"slug":"local-added"})); + fs::write(&cache_path, serde_json::to_vec(&cache).unwrap()).unwrap(); + offline_detach_deferred(&detach_guard(&paths), false).unwrap(); + let restored = fs::read(&cache_path).unwrap(); + fs::remove_file(&paths.catalog_backup_file).unwrap(); + offline_detach_deferred(&detach_guard(&paths), false) + .expect("retry must not depend on removed backup"); + assert_eq!(fs::read(cache_path).unwrap(), restored); + assert!(String::from_utf8(restored).unwrap().contains("local-added")); +} + +#[test] +fn sync_preserves_new_external_cache_models_through_detach() { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-sync-cache-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let _guard = ScratchGuard(scratch.clone()); + let ocx = scratch.join("opencodex"); + let codex = scratch.join("codex"); + fs::create_dir_all(&ocx).unwrap(); + fs::create_dir_all(&codex).unwrap(); + let paths = ChildPaths::new(ocx, codex); + let settings = HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: true, + subagent_defaults: None, + }; + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"remote-v1"}]}"#, + &settings, + ) + .unwrap(); + let cache_path = paths.codex_models_cache_path(); + let mut cache: serde_json::Value = + serde_json::from_slice(&fs::read(&cache_path).unwrap()).unwrap(); + cache["models"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"slug":"local-added"})); + fs::write(&cache_path, serde_json::to_vec(&cache).unwrap()).unwrap(); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"remote-v2"}]}"#, + &settings, + ) + .unwrap(); + assert!( + fs::read_to_string(&cache_path) + .unwrap() + .contains("local-added") + ); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"remote-v3"}]}"#, + &settings, + ) + .unwrap(); + assert!( + fs::read_to_string(&cache_path) + .unwrap() + .contains("local-added") + ); + offline_detach(&detach_guard(&paths), false).unwrap(); + let detached = fs::read_to_string(cache_path).unwrap(); + assert!(detached.contains("local-added")); + assert!( + !detached.contains("remote-v1") + && !detached.contains("remote-v2") + && !detached.contains("remote-v3") + ); +} + +fn cache_fixture(label: &str) -> (ScratchGuard, ChildPaths, HomeCodexSettings) { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-{label}-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let guard = ScratchGuard(scratch.clone()); + let paths = ChildPaths::new(scratch.join("opencodex"), scratch.join("codex")); + fs::create_dir_all(&paths.opencodex_home).unwrap(); + fs::create_dir_all(&paths.codex_home).unwrap(); + let settings = HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: true, + subagent_defaults: None, + }; + (guard, paths, settings) +} + +fn cache_models(paths: &ChildPaths) -> Vec { + let cache: serde_json::Value = + serde_json::from_slice(&fs::read(paths.codex_models_cache_path()).unwrap()).unwrap(); + cache["models"].as_array().unwrap().clone() +} + +const NATIVE_GPT55_CACHE: &[u8] = br#"{"fetched_at":"2026-01-01T00:00:00Z","client_version":"1.0.0","models":[{"slug":"gpt-5.5","display_name":"native"}]}"#; +const HOME_GPT55_CATALOG: &[u8] = br#"{"models":[{"slug":"gpt-5.5","display_name":"home"}]}"#; + +#[test] +fn sync_after_external_cache_edit_keeps_home_entries_over_restored_pre_attach_versions() { + let (_guard, paths, settings) = cache_fixture("sync-shared-slug"); + fs::write(paths.codex_models_cache_path(), NATIVE_GPT55_CACHE).unwrap(); + let sync = || { + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + HOME_GPT55_CATALOG, + &settings, + ) + }; + sync().unwrap(); + let mut cache: serde_json::Value = + serde_json::from_slice(&fs::read(paths.codex_models_cache_path()).unwrap()).unwrap(); + cache["models"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"slug":"local-added"})); + fs::write( + paths.codex_models_cache_path(), + serde_json::to_vec(&cache).unwrap(), + ) + .unwrap(); + for round in 0..2 { + sync().unwrap_or_else(|e| panic!("sync round {round} failed: {e}")); + let models = cache_models(&paths); + assert_eq!( + models, + vec![ + serde_json::json!({"slug":"gpt-5.5","display_name":"home"}), + serde_json::json!({"slug":"local-added"}), + ], + "round {round}" + ); + } + offline_detach(&detach_guard(&paths), false).unwrap(); + assert_eq!( + cache_models(&paths), + vec![ + serde_json::json!({"slug":"gpt-5.5","display_name":"native"}), + serde_json::json!({"slug":"local-added"}), + ] + ); +} + +#[test] +fn sync_interrupted_before_the_cache_write_still_strips_the_previous_catalog() { + for retry_sync in [false, true] { + let (_guard, paths, settings) = cache_fixture("interrupted-cache"); + let sync = |catalog: &[u8], fault: Option| { + apply_attach_or_sync_with_fault( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + catalog, + &settings, + fault, + ) + }; + sync(br#"{"models":[{"slug":"remote-v1"}]}"#, None).unwrap(); + let mut cache: serde_json::Value = + serde_json::from_slice(&fs::read(paths.codex_models_cache_path()).unwrap()).unwrap(); + cache["models"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"slug":"local-added"})); + fs::write( + paths.codex_models_cache_path(), + serde_json::to_vec(&cache).unwrap(), + ) + .unwrap(); + // The new catalog and its backup are written, the cache still holds remote-v1. + let err = sync( + br#"{"models":[{"slug":"remote-v2"}]}"#, + Some(JournalWriteFault::AfterCatalogWrite), + ) + .unwrap_err(); + assert!(err.contains("AfterCatalogWrite"), "{err}"); + if retry_sync { + sync(br#"{"models":[{"slug":"remote-v2"}]}"#, None).unwrap(); + let slugs: Vec<_> = cache_models(&paths) + .iter() + .map(|m| m["slug"].as_str().unwrap().to_owned()) + .collect(); + assert_eq!(slugs, ["remote-v2", "local-added"]); + } + offline_detach(&detach_guard(&paths), false).unwrap(); + assert_eq!( + cache_models(&paths), + vec![serde_json::json!({"slug":"local-added"})], + "retry_sync={retry_sync}" + ); + } +} + +#[test] +fn sync_and_detach_treat_codex_reserialized_home_entries_as_child_owned() { + for sync_before_detach in [true, false] { + let (_guard, paths, settings) = cache_fixture("reserialized-home"); + fs::write(paths.codex_models_cache_path(), NATIVE_GPT55_CACHE).unwrap(); + let sync = || { + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + HOME_GPT55_CATALOG, + &settings, + ) + }; + sync().unwrap(); + // Codex refreshes the stale wrapper and writes the Home entry back with its own defaults. + let rewritten = br#"{"fetched_at":"2026-11-08T00:00:00Z","client_version":"0.99.0","etag":"abc","models":[{"display_name":"home","slug":"gpt-5.5","visibility":"list"},{"slug":"codex-new"}]}"#; + fs::write(paths.codex_models_cache_path(), rewritten).unwrap(); + if sync_before_detach { + sync().expect("sync must accept a Codex rewrite of Home entries"); + assert_eq!( + cache_models(&paths), + vec![ + serde_json::json!({"slug":"gpt-5.5","display_name":"home"}), + serde_json::json!({"slug":"codex-new"}), + ] + ); + } + offline_detach(&detach_guard(&paths), false).unwrap(); + assert_eq!( + cache_models(&paths), + vec![ + serde_json::json!({"slug":"gpt-5.5","display_name":"native"}), + serde_json::json!({"slug":"codex-new"}), + ], + "sync_before_detach={sync_before_detach}" + ); + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConformanceSuite { + schema: String, + route_cases: Vec, + framing_cases: Vec, + relay_auth_cases: Vec, + settings_digest_cases: Vec, + child_parity_cases: Vec, + journal_roundtrip_cases: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RouteCase { + id: String, + method: String, + path: String, + query: String, + host_header: Option, + origin_header: Option, + upgrade_header: Option, + expected_action: String, + expected_auth_header: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FramingCase { + id: String, + raw_headers: Vec<(String, String)>, + expected_outcome: String, + expected_survived_headers: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RelayAuthCase { + id: String, + raw_token: String, + token_sha256_hex: String, + key_id: String, + link_id: String, + nonce: String, + generation: String, + expected_caller_proof: String, + expected_listener_proof: String, + expected_connection_proof: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SettingsDigestCase { + id: String, + settings: HomeCodexSettings, + expected_canonical_json: String, + expected_digest_hex: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChildParityCase { + id: String, + raw_json: String, + expected_valid: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MidSessionMutation { + kind: String, + appended_toml: Option, + prepended_root_line: Option, + new_base_url: Option, + raw_override_toml: Option, + tampered_catalog: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JournalRoundtripCase { + id: String, + pre_attach_toml: Option, + pre_attach_catalog: Option, + listen_port: u16, + initial_settings: HomeCodexSettings, + sync_settings: Option, + mid_session_mutation: MidSessionMutation, + expected_attach_ok: bool, + expected_detach_outcome: String, + expected_final_toml: Option, + expected_final_catalog: Option, +} + +fn load_suite() -> ConformanceSuite { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = manifest_dir + .join("..") + .join("..") + .join("tests") + .join("fixtures") + .join("link") + .join("ocx-child-conformance.json"); + let raw = fs::read_to_string(&fixture_path) + .unwrap_or_else(|e| panic!("failed reading {}: {e}", fixture_path.display())); + serde_json::from_str(&raw).expect("valid conformance JSON") +} + +#[test] +fn conformance_routes_match_typescript() { + let suite = load_suite(); + assert_eq!(suite.schema, "ocx.child.conformance/1"); + for row in &suite.route_cases { + let decision = classify_child_ingress( + &row.method, + &row.path, + &row.query, + row.host_header.as_deref(), + row.origin_header.as_deref(), + row.upgrade_header.as_deref(), + ); + let (action, auth) = match decision { + RouteDecision::LocalHealthz => ("local_healthz", "none"), + RouteDecision::LocalReadyz => ("local_readyz", "none"), + RouteDecision::LocalShutdown => ("local_shutdown", "none"), + RouteDecision::Reject403Origin => ("reject_403_origin", "none"), + RouteDecision::Reject426Websocket => ("reject_426_websocket", "none"), + RouteDecision::Reject404 => ("reject_404", "none"), + RouteDecision::RelayData { + auth: RelayAuthKind::Bearer, + } => ("relay_data", "bearer"), + RouteDecision::RelayData { + auth: RelayAuthKind::ApiKeyHeader, + } => ("relay_data", "x-opencodex-api-key"), + RouteDecision::RelayManagement => ("relay_management", "bearer"), + }; + assert_eq!( + action, row.expected_action, + "route action mismatch on {}", + row.id + ); + assert_eq!( + auth, row.expected_auth_header, + "route auth mismatch on {}", + row.id + ); + } +} + +#[test] +fn conformance_framing_and_header_stripping_match_typescript() { + let suite = load_suite(); + for row in &suite.framing_cases { + match validate_and_sanitize_request_headers(&row.raw_headers) { + Ok(valid) => { + assert_eq!(row.expected_outcome, "allow", "case {}", row.id); + let mut survived: Vec = + valid.sanitized.into_iter().map(|(k, _)| k).collect(); + survived.sort(); + survived.dedup(); + assert_eq!(survived, row.expected_survived_headers, "case {}", row.id); + } + Err(FramingError::Smuggling) => { + assert_eq!( + row.expected_outcome, "reject_400_smuggling", + "case {}", + row.id + ); + } + Err(FramingError::Invalid) => { + assert_eq!( + row.expected_outcome, "reject_400_invalid", + "case {}", + row.id + ); + } + Err(FramingError::HeadersTooLarge) => { + assert_eq!( + row.expected_outcome, "reject_431_headers_too_large", + "case {}", + row.id + ); + } + Err(other) => panic!("unexpected framing error {other:?} on {}", row.id), + } + } +} + +#[test] +fn conformance_relay_auth_v2_and_connection_proofs_match_typescript() { + let suite = load_suite(); + for row in &suite.relay_auth_cases { + let fp = token_fingerprint_hex(&row.raw_token); + assert_eq!(fp, row.token_sha256_hex, "case {}", row.id); + let caller = + compute_relay_proof(&fp, "caller", &row.key_id, &row.link_id, &row.nonce).unwrap(); + let listener = + compute_relay_proof(&fp, "listener", &row.key_id, &row.link_id, &row.nonce).unwrap(); + let conn = + compute_connection_proof(&fp, &row.key_id, &row.link_id, &row.nonce, &row.generation) + .unwrap(); + assert_eq!(caller, row.expected_caller_proof, "case {}", row.id); + assert_eq!(listener, row.expected_listener_proof, "case {}", row.id); + assert_eq!(conn, row.expected_connection_proof, "case {}", row.id); + assert!(relay_proof_matches( + Some(&caller), + Some(&row.expected_caller_proof) + )); + assert!(!relay_proof_matches(Some(&caller), Some(&listener))); + } +} + +#[test] +fn conformance_ocx6_settings_digest_and_child_parity_match_typescript() { + let suite = load_suite(); + for row in &suite.settings_digest_cases { + assert_eq!( + codex_settings_digest_payload(&row.settings), + row.expected_canonical_json, + "case {}", + row.id + ); + assert_eq!( + codex_settings_digest(&row.settings), + row.expected_digest_hex, + "case {}", + row.id + ); + } + for row in &suite.child_parity_cases { + let parsed = parse_link_child_parity_report(&row.raw_json); + assert_eq!(parsed.is_some(), row.expected_valid, "case {}", row.id); + } +} + +#[test] +fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { + let suite = load_suite(); + for (idx, row) in suite.journal_roundtrip_cases.iter().enumerate() { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-conf-{}-{idx}-{}", + std::process::id(), + row.id + )); + let _ = fs::remove_dir_all(&scratch); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let paths = ChildPaths::new(ocx_home, codex_home); + + if let Some(pre_toml) = &row.pre_attach_toml { + fs::write(paths.codex_config_path(), pre_toml.as_bytes()).unwrap(); + } + if let Some(pre_cat) = &row.pre_attach_catalog { + fs::write(paths.codex_catalog_path(), pre_cat.as_bytes()).unwrap(); + } + + let sample_catalog = b"{\"models\":[{\"slug\":\"home-m1\"}]}\n"; + let attach_res = apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:test", + row.listen_port, + sample_catalog, + &row.initial_settings, + ); + assert_eq!( + attach_res.is_ok(), + row.expected_attach_ok, + "attach {}", + row.id + ); + + if !row.expected_attach_ok { + let detach_res = offline_detach(&detach_guard(&paths), false).unwrap(); + assert_eq!(detach_res, DetachOutcome::NotAttached, "case {}", row.id); + let final_toml = fs::read_to_string(paths.codex_config_path()).ok(); + let final_cat = fs::read_to_string(paths.codex_catalog_path()).ok(); + assert_eq!(final_toml, row.expected_final_toml, "case {}", row.id); + assert_eq!(final_cat, row.expected_final_catalog, "case {}", row.id); + let _ = fs::remove_dir_all(&scratch); + continue; + } + + if let Some(sync_set) = &row.sync_settings { + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:test", + row.listen_port, + sample_catalog, + sync_set, + ) + .unwrap(); + } + let effective_settings = row.sync_settings.as_ref().unwrap_or(&row.initial_settings); + let applied_toml = fs::read_to_string(paths.codex_config_path()).unwrap(); + if let Some(fm) = effective_settings.fast_mode { + assert!( + applied_toml.contains(&format!("fast_mode = {fm}")), + "case {}", + row.id + ); + } + if let Some(sub) = &effective_settings.subagent_defaults { + assert!( + applied_toml.contains(&format!("default_subagent_model = \"{}\"", sub.model)), + "case {}", + row.id + ); + } + + match row.mid_session_mutation.kind.as_str() { + "none" => {} + "strip_comments_and_append_table" => { + let cur = fs::read_to_string(paths.codex_config_path()).unwrap(); + let stripped: String = cur + .lines() + .filter(|l| !l.trim().starts_with('#')) + .collect::>() + .join("\n") + + "\n" + + row + .mid_session_mutation + .appended_toml + .as_deref() + .unwrap_or(""); + fs::write(paths.codex_config_path(), stripped.as_bytes()).unwrap(); + } + "strip_comments_and_prepend_root" => { + let cur = fs::read_to_string(paths.codex_config_path()).unwrap(); + let stripped = format!( + "{}{}\n", + row.mid_session_mutation + .prepended_root_line + .as_deref() + .unwrap_or(""), + cur.lines() + .filter(|l| !l.trim().starts_with('#')) + .collect::>() + .join("\n") + ); + fs::write(paths.codex_config_path(), stripped.as_bytes()).unwrap(); + } + "replace_root_base_url" => { + let cur = fs::read_to_string(paths.codex_config_path()).unwrap(); + let next = cur.replace( + &format!( + "openai_base_url = \"http://127.0.0.1:{}/v1\"", + row.listen_port + ), + &format!( + "openai_base_url = \"{}\"", + row.mid_session_mutation.new_base_url.as_deref().unwrap() + ), + ); + fs::write(paths.codex_config_path(), next.as_bytes()).unwrap(); + } + "corrupt_managed_span" => { + let raw = row + .mid_session_mutation + .raw_override_toml + .as_deref() + .unwrap(); + fs::write(paths.codex_config_path(), raw.as_bytes()).unwrap(); + } + "tamper_catalog" => { + let tampered = row + .mid_session_mutation + .tampered_catalog + .as_deref() + .unwrap(); + fs::write(paths.codex_catalog_path(), tampered.as_bytes()).unwrap(); + } + other => panic!("unknown mutation {other}"), + } + + let snap_cfg = fs::read(paths.codex_config_path()).ok(); + let snap_cat = fs::read(paths.codex_catalog_path()).ok(); + let snap_journal = fs::read(&paths.journal_file).ok(); + + match offline_detach(&detach_guard(&paths), false) { + Ok(DetachOutcome::ExactRestore) => { + assert_eq!( + row.expected_detach_outcome, "exact_restore", + "case {}", + row.id + ); + } + Ok(DetachOutcome::SurgicalRestore) => { + assert_eq!( + row.expected_detach_outcome, "surgical_restore", + "case {}", + row.id + ); + } + Ok(DetachOutcome::NotAttached) => { + assert_eq!( + row.expected_detach_outcome, "not_attached", + "case {}", + row.id + ); + } + Err(DetachError::Conflict(_)) => { + assert_eq!( + row.expected_detach_outcome, "refuse_conflict", + "case {}", + row.id + ); + assert_eq!( + fs::read(paths.codex_config_path()).ok(), + snap_cfg, + "conflict mutated config on {}", + row.id + ); + assert_eq!( + fs::read(paths.codex_catalog_path()).ok(), + snap_cat, + "conflict mutated catalog on {}", + row.id + ); + assert_eq!( + fs::read(&paths.journal_file).ok(), + snap_journal, + "conflict mutated journal on {}", + row.id + ); + } + Err(other) => panic!("unexpected detach error {other:?} on {}", row.id), + } + + let final_toml = fs::read_to_string(paths.codex_config_path()).ok(); + let final_cat = fs::read_to_string(paths.codex_catalog_path()).ok(); + if row.expected_final_toml.is_some() || row.expected_detach_outcome != "refuse_conflict" { + assert_eq!( + final_toml, row.expected_final_toml, + "final toml mismatch on {}", + row.id + ); + } + if row.expected_final_catalog.is_some() || row.expected_detach_outcome != "refuse_conflict" + { + assert_eq!( + final_cat, row.expected_final_catalog, + "final catalog mismatch on {}", + row.id + ); + } else { + assert!( + final_cat.is_some(), + "fail-closed conflict must leave catalog untouched on {}", + row.id + ); + } + let _ = fs::remove_dir_all(&scratch); + } +} + +/// Lock and claim a Codex home the way attach and sync do. +fn owned(paths: &ChildPaths) -> CodexHomeGuard { + CodexHomeGuard::acquire(paths, true, true, ClaimPolicy::Attach).expect("claim Codex home") +} + +/// Lock a Codex home the way detach does. +fn detach_guard(paths: &ChildPaths) -> CodexHomeGuard { + CodexHomeGuard::acquire(paths, true, false, ClaimPolicy::Detach).expect("lock Codex home") +} + +struct ScratchGuard(PathBuf); + +impl Drop for ScratchGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn homes_fixture() -> (ScratchGuard, ChildPaths, ChildPaths, HomeCodexSettings) { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-homes-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let guard = ScratchGuard(scratch.clone()); + let original = ChildPaths::new(scratch.join("ocx"), scratch.join("codex-a")); + let other = ChildPaths::new(scratch.join("ocx"), scratch.join("codex-b")); + fs::create_dir_all(&original.opencodex_home).unwrap(); + fs::create_dir_all(&original.codex_home).unwrap(); + fs::create_dir_all(&other.codex_home).unwrap(); + let settings = HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: true, + subagent_defaults: None, + }; + (guard, original, other, settings) +} + +#[test] +fn detach_refuses_a_different_codex_home_before_mutating_the_original() { + let (_guard, original, other, settings) = homes_fixture(); + fs::write( + original.codex_config_path(), + b"approval_policy = 'on-request'\n", + ) + .unwrap(); + apply_attach_or_sync( + &owned(&original), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let config_before = fs::read(original.codex_config_path()).unwrap(); + let catalog_before = fs::read(original.codex_catalog_path()).unwrap(); + let journal_before = fs::read(&original.journal_file).unwrap(); + + assert!(matches!( + offline_detach(&detach_guard(&other), false), + Err(DetachError::Conflict(_)) + )); + assert_eq!( + fs::read(original.codex_config_path()).unwrap(), + config_before + ); + assert_eq!( + fs::read(original.codex_catalog_path()).unwrap(), + catalog_before + ); + assert_eq!(fs::read(&original.journal_file).unwrap(), journal_before); + assert!(!other.codex_config_path().exists()); +} + +#[test] +fn sync_refuses_a_different_codex_home_without_rewriting_the_journal() { + let (_guard, original, other, settings) = homes_fixture(); + apply_attach_or_sync( + &owned(&original), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let journal_before = fs::read(&original.journal_file).unwrap(); + // Neither the sync nor the attach policy may claim a Codex home that the existing + // journal does not target, so journal application never runs against it. + for policy in [ClaimPolicy::Continue, ClaimPolicy::Attach] { + assert!(CodexHomeGuard::acquire(&other, true, false, policy).is_err()); + } + let unowned = detach_guard(&other); + assert!( + apply_attach_or_sync( + &unowned, + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home-next"}]}"#, + &settings, + ) + .is_err() + ); + drop(unowned); + assert_eq!(fs::read(&original.journal_file).unwrap(), journal_before); + assert!(!other.codex_config_path().exists()); + assert!(!other.codex_owner_path().exists()); +} + +#[cfg(unix)] +#[test] +fn detach_accepts_an_alias_of_the_same_canonical_codex_home() { + use std::os::unix::fs::symlink; + + let (guard, original, _other, settings) = homes_fixture(); + apply_attach_or_sync( + &owned(&original), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let alias = guard.0.join("codex-alias"); + symlink(&original.codex_home, &alias).unwrap(); + let equivalent = ChildPaths::new(original.opencodex_home.clone(), alias); + assert_eq!( + offline_detach(&detach_guard(&equivalent), false).unwrap(), + DetachOutcome::ExactRestore + ); +} + +#[cfg(unix)] +#[test] +fn detach_refuses_a_retargeted_codex_home_alias() { + use std::os::unix::fs::symlink; + + let (guard, original, other, settings) = homes_fixture(); + let alias = guard.0.join("codex-alias"); + symlink(&original.codex_home, &alias).unwrap(); + let aliased = ChildPaths::new(original.opencodex_home.clone(), alias.clone()); + apply_attach_or_sync( + &owned(&aliased), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let original_config = fs::read(original.codex_config_path()).unwrap(); + let original_journal = fs::read(&original.journal_file).unwrap(); + fs::remove_file(&alias).unwrap(); + symlink(&other.codex_home, &alias).unwrap(); + + assert!(matches!( + offline_detach(&detach_guard(&aliased), false), + Err(DetachError::Conflict(_)) + )); + assert!(fs::read(original.codex_config_path()).unwrap() == original_config); + assert!(fs::read(&original.journal_file).unwrap() == original_journal); + assert!(!other.codex_config_path().exists()); +} + +#[cfg(unix)] +#[test] +fn detach_refuses_a_replaced_codex_home_directory() { + let (guard, original, _other, settings) = homes_fixture(); + apply_attach_or_sync( + &owned(&original), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let saved = guard.0.join("saved-codex"); + fs::rename(&original.codex_home, &saved).unwrap(); + fs::create_dir(&original.codex_home).unwrap(); + + assert!(matches!( + offline_detach(&detach_guard(&original), false), + Err(DetachError::Conflict(_)) + )); + assert!(original.journal_file.is_file()); + assert!(saved.join("config.toml").is_file()); +} + +#[test] +fn immediate_rollback_refuses_a_replaced_codex_home() { + let (guard, original, _other, _settings) = homes_fixture(); + fs::write(original.codex_config_path(), b"original config").unwrap(); + let rollback = AttachRollbackSnapshot::capture(&owned(&original), None).unwrap(); + fs::rename(&original.codex_home, guard.0.join("saved-home")).unwrap(); + fs::create_dir(&original.codex_home).unwrap(); + fs::write(original.codex_config_path(), b"replacement config").unwrap(); + assert!(rollback.restore(&detach_guard(&original)).is_err()); + assert_eq!( + fs::read(original.codex_config_path()).unwrap(), + b"replacement config" + ); + assert!(original.rollback_file.exists()); +} + +#[test] +fn detach_refuses_missing_or_invalid_catalog_snapshot_before_mutation() { + for damaged in [serde_json::Value::Null, serde_json::json!("not base64!")] { + let (_guard, paths, _other, settings) = homes_fixture(); + fs::write( + paths.codex_catalog_path(), + br#"{"models":[{"slug":"prior"}]}"#, + ) + .unwrap(); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let config_before = fs::read(paths.codex_config_path()).unwrap(); + let catalog_before = fs::read(paths.codex_catalog_path()).unwrap(); + let mut journal: serde_json::Value = + serde_json::from_slice(&fs::read(&paths.journal_file).unwrap()).unwrap(); + journal["preAttachCatalogBytesB64"] = damaged; + fs::write(&paths.journal_file, serde_json::to_vec(&journal).unwrap()).unwrap(); + let journal_before = fs::read(&paths.journal_file).unwrap(); + + assert!(matches!( + offline_detach(&detach_guard(&paths), false), + Err(DetachError::Conflict(_)) + )); + assert!(fs::read(paths.codex_config_path()).unwrap() == config_before); + assert!(fs::read(paths.codex_catalog_path()).unwrap() == catalog_before); + assert!(fs::read(&paths.journal_file).unwrap() == journal_before); + } +} + +#[test] +fn detach_refuses_a_journal_with_a_foreign_catalog_target() { + let (_guard, original, other, settings) = homes_fixture(); + apply_attach_or_sync( + &owned(&original), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let foreign = other.codex_catalog_path(); + fs::write(&foreign, b"foreign").unwrap(); + let mut journal: serde_json::Value = + serde_json::from_slice(&fs::read(&original.journal_file).unwrap()).unwrap(); + journal["catalogPath"] = serde_json::Value::String(foreign.to_string_lossy().into_owned()); + fs::write( + &original.journal_file, + serde_json::to_vec(&journal).unwrap(), + ) + .unwrap(); + + assert!(matches!( + offline_detach(&detach_guard(&original), false), + Err(DetachError::Conflict(_)) + )); + assert_eq!(fs::read(foreign).unwrap(), b"foreign"); + assert!(original.journal_file.exists()); +} + +#[test] +fn failed_first_attach_rollback_keeps_the_recovery_journal() { + let (_guard, paths, _other, settings) = homes_fixture(); + fs::create_dir_all(&paths.child_dir).unwrap(); + fs::write(&paths.key_file, b"previous-fixture-key").unwrap(); + fs::write(&paths.state_file, b"previous-fixture-state").unwrap(); + let snapshot = AttachRollbackSnapshot::capture(&owned(&paths), None).unwrap(); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + fs::write(&paths.key_file, b"replacement-fixture-key").unwrap(); + fs::remove_file(paths.codex_config_path()).unwrap(); + fs::create_dir(paths.codex_config_path()).unwrap(); + + assert!(snapshot.restore(&detach_guard(&paths)).is_err()); + assert!( + paths.journal_file.is_file(), + "recovery journal must survive an incomplete rollback" + ); + let rollback = fs::read(paths.child_dir.join("attach-rollback.json")) + .expect("durable rollback snapshot must survive an incomplete rollback"); + let decoded: serde_json::Value = serde_json::from_slice(&rollback).unwrap(); + let expected: Vec = b"previous-fixture-key".to_vec(); + assert!( + decoded["key"] == serde_json::json!(expected), + "prior key was not preserved" + ); +} + +#[test] +fn detach_recovers_a_durable_incomplete_attach_snapshot() { + let (_guard, paths, other, settings) = homes_fixture(); + let snapshot = AttachRollbackSnapshot::capture(&owned(&paths), None).unwrap(); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + fs::remove_file(paths.codex_config_path()).unwrap(); + fs::create_dir(paths.codex_config_path()).unwrap(); + assert!(snapshot.restore(&detach_guard(&paths)).is_err()); + assert!(paths.rollback_file.is_file()); + let wrong_home = std::process::Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .arg("detach") + .env("OPENCODEX_HOME", &other.opencodex_home) + .env("CODEX_HOME", &other.codex_home) + .output() + .unwrap(); + assert!(!wrong_home.status.success()); + assert!(paths.rollback_file.is_file()); + assert!(paths.codex_config_path().is_dir()); + fs::remove_dir(paths.codex_config_path()).unwrap(); + + let result = std::process::Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .arg("detach") + .env("OPENCODEX_HOME", &paths.opencodex_home) + .env("CODEX_HOME", &paths.codex_home) + .output() + .unwrap(); + assert!( + result.status.success(), + "detach recovery failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(!paths.rollback_file.exists()); + assert!(!paths.journal_file.exists()); + assert!(!paths.codex_config_path().exists()); +} + +#[cfg(unix)] +#[test] +fn rollback_recovery_refuses_a_replaced_codex_home_directory() { + let (guard, paths, _other, settings) = homes_fixture(); + let snapshot = AttachRollbackSnapshot::capture(&owned(&paths), None).unwrap(); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + fs::remove_file(paths.codex_config_path()).unwrap(); + fs::create_dir(paths.codex_config_path()).unwrap(); + assert!(snapshot.restore(&detach_guard(&paths)).is_err()); + let saved = guard.0.join("saved-codex"); + fs::rename(&paths.codex_home, &saved).unwrap(); + fs::create_dir(&paths.codex_home).unwrap(); + + let result = std::process::Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .arg("detach") + .env("OPENCODEX_HOME", &paths.opencodex_home) + .env("CODEX_HOME", &paths.codex_home) + .output() + .unwrap(); + assert!(!result.status.success()); + assert!(paths.rollback_file.is_file()); + assert!(saved.join("config.toml").is_dir()); + assert!(!paths.codex_config_path().exists()); +} + +#[test] +fn detach_preserves_orphaned_key_when_the_journal_is_missing() { + let (_guard, paths, _other, _settings) = homes_fixture(); + fs::create_dir_all(&paths.child_dir).unwrap(); + fs::write(&paths.key_file, b"fixture-key").unwrap(); + let result = std::process::Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .arg("detach") + .env("OPENCODEX_HOME", &paths.opencodex_home) + .env("CODEX_HOME", &paths.codex_home) + .output() + .unwrap(); + assert!( + !result.status.success(), + "orphaned key must require recovery" + ); + assert!(fs::read(&paths.key_file).unwrap() == b"fixture-key"); +} + +#[cfg(unix)] +#[test] +fn detach_keeps_recovery_journal_when_required_file_removal_fails() { + use std::os::unix::fs::PermissionsExt; + + struct RestoreMode(PathBuf); + impl Drop for RestoreMode { + fn drop(&mut self) { + let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700)); + } + } + + let (_guard, paths, _other, settings) = homes_fixture(); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + fs::remove_file(paths.codex_models_cache_path()).unwrap(); + let _restore_mode = RestoreMode(paths.codex_home.clone()); + fs::set_permissions(&paths.codex_home, fs::Permissions::from_mode(0o500)).unwrap(); + + assert!(matches!( + offline_detach(&detach_guard(&paths), false), + Err(DetachError::Io(_)) + )); + assert!(paths.journal_file.is_file()); + assert!(paths.codex_config_path().is_file()); +} + +#[test] +fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { + let scratch = std::env::temp_dir().join(format!("ocx-child-wal-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let _guard = ScratchGuard(scratch.clone()); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let paths = ChildPaths::new(ocx_home, codex_home); + + let pre_toml = "developer_instructions = \"\"\"\n[Important]\nNever inject inside me\n\"\"\"\n"; + let pre_cat = b"{\"models\":[{\"slug\":\"native-prior\"}]}\n"; + fs::write(paths.codex_config_path(), pre_toml.as_bytes()).unwrap(); + fs::write(paths.codex_catalog_path(), pre_cat).unwrap(); + + let settings = HomeCodexSettings { + fast_mode: Some(true), + multi_agent_mode: Some("v1".to_owned()), + web_search_sidecar_enabled: true, + subagent_defaults: None, + }; + let cat_v1 = b"{\"models\":[{\"slug\":\"m-v1\"}]}\n"; + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + cat_v1, + &settings, + ) + .unwrap(); + + // 1. Verify root keys landed AFTER closing triple quotes, never inside [Important] + let injected = fs::read_to_string(paths.codex_config_path()).unwrap(); + assert!(injected.starts_with(pre_toml), "injected was:\n{injected}"); + assert!(injected.contains("openai_base_url = \"http://127.0.0.1:10100/v1\"")); + let cache_v1 = fs::read(paths.codex_models_cache_path()).unwrap(); + + // 2. Two consecutive interrupted syncs: + // - sync v2 fails right after write_journal (catalog, cache, config remain v1) + // - sync v3 fails right after catalog write (catalog is v3, cache & config remain v1) + let cat_v2 = b"{\"models\":[{\"slug\":\"m-v2\"}]}\n"; + let cat_v3 = b"{\"models\":[{\"slug\":\"m-v3\"}]}\n"; + let settings_v2 = HomeCodexSettings { + fast_mode: Some(false), + ..settings.clone() + }; + let settings_v3 = HomeCodexSettings { + multi_agent_mode: Some("v2".to_owned()), + ..settings_v2.clone() + }; + assert!( + apply_attach_or_sync_with_fault( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + cat_v2, + &settings_v2, + Some(JournalWriteFault::AfterJournalWrite), + ) + .is_err() + ); + assert!( + apply_attach_or_sync_with_fault( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + cat_v3, + &settings_v3, + Some(JournalWriteFault::AfterCatalogWrite), + ) + .is_err() + ); + assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), cat_v3); + assert_eq!(fs::read(paths.codex_models_cache_path()).unwrap(), cache_v1); + assert_eq!( + fs::read_to_string(paths.codex_config_path()).unwrap(), + injected + ); + + let outcome = offline_detach(&detach_guard(&paths), false).unwrap(); + assert_eq!(outcome, DetachOutcome::ExactRestore); + assert_eq!( + fs::read_to_string(paths.codex_config_path()).unwrap(), + pre_toml + ); + assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), pre_cat); + assert!(!paths.codex_models_cache_path().exists()); + + // 3. Two consecutive failed catalog writes (AfterJournalWrite twice) with no pre-attach catalog/cache + fs::remove_file(paths.codex_catalog_path()).unwrap(); + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + cat_v1, + &settings, + ) + .unwrap(); + for (cat, set) in [ + (cat_v2.as_slice(), &settings_v2), + (cat_v3.as_slice(), &settings_v3), + ] { + assert!( + apply_attach_or_sync_with_fault( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + cat, + set, + Some(JournalWriteFault::AfterJournalWrite), + ) + .is_err() + ); + } + assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), cat_v1); + assert!(paths.codex_models_cache_path().exists()); + + let outcome = offline_detach(&detach_guard(&paths), false).unwrap(); + assert_eq!(outcome, DetachOutcome::ExactRestore); + assert_eq!( + fs::read_to_string(paths.codex_config_path()).unwrap(), + pre_toml + ); + assert!(!paths.codex_catalog_path().exists()); + assert!(!paths.codex_models_cache_path().exists()); +} + +#[test] +fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { + // 1. Reject omitted keys, U+001B escape, uppercase effort, and UTF-16 > 256 + let omitted_fast = br#"{"version":1,"home":{"name":"h","version":"2.98.0"},"settings":{"multiAgentMode":null,"webSearchSidecarEnabled":true,"subagentDefaults":null}}"#; + assert!(parse_home_codex_settings_document(omitted_fast).is_none()); + let esc_model = "{\"version\":1,\"home\":{\"name\":\"h\",\"version\":\"2.98.0\"},\"settings\":{\"fastMode\":null,\"multiAgentMode\":null,\"webSearchSidecarEnabled\":true,\"subagentDefaults\":{\"model\":\"bad\\u001bmodel\",\"reasoningEffort\":null}}}"; + assert!(parse_home_codex_settings_document(esc_model.as_bytes()).is_none()); + let upper_effort = br#"{"version":1,"home":{"name":"h","version":"2.98.0"},"settings":{"fastMode":null,"multiAgentMode":null,"webSearchSidecarEnabled":true,"subagentDefaults":{"model":"gpt-5.6","reasoningEffort":"HIGH"}}}"#; + assert!(parse_home_codex_settings_document(upper_effort).is_none()); + // 128 astral emojis = 256 UTF-16 code units (valid); 129 emojis = 258 UTF-16 units (rejected) + let ok_astral = "😀".repeat(128); + let over_astral = "😀".repeat(129); + let ok_doc = format!( + "{{ \"version\": 1, \"home\": {{ \"name\": \"h\", \"version\": \"2.98.0\" }}, \"settings\": {{ \"fastMode\": null, \"multiAgentMode\": null, \"webSearchSidecarEnabled\": true, \"subagentDefaults\": {{ \"model\": \"{ok_astral}\", \"reasoningEffort\": \"high\" }} }} }}" + ); + let bad_doc = format!( + "{{ \"version\": 1, \"home\": {{ \"name\": \"h\", \"version\": \"2.98.0\" }}, \"settings\": {{ \"fastMode\": null, \"multiAgentMode\": null, \"webSearchSidecarEnabled\": true, \"subagentDefaults\": {{ \"model\": \"{over_astral}\", \"reasoningEffort\": \"high\" }} }} }}" + ); + assert!(parse_home_codex_settings_document(ok_doc.as_bytes()).is_some()); + assert!(parse_home_codex_settings_document(bad_doc.as_bytes()).is_none()); + // ECMAScript trim: U+FEFF is trimmed, while U+0085 is retained (matching String.prototype.trim) + let feff_doc = "{\"version\":1,\"home\":{\"name\":\"h\",\"version\":\"2.98.0\"},\"settings\":{\"fastMode\":null,\"multiAgentMode\":null,\"webSearchSidecarEnabled\":true,\"subagentDefaults\":{\"model\":\"\\ufeffgpt-5.6\",\"reasoningEffort\":null}}}"; + let nel_doc = "{\"version\":1,\"home\":{\"name\":\"h\",\"version\":\"2.98.0\"},\"settings\":{\"fastMode\":null,\"multiAgentMode\":null,\"webSearchSidecarEnabled\":true,\"subagentDefaults\":{\"model\":\"\\u0085gpt-5.6\",\"reasoningEffort\":null}}}"; + assert_eq!( + parse_home_codex_settings_document(feff_doc.as_bytes()) + .unwrap() + .settings + .subagent_defaults + .unwrap() + .model, + "gpt-5.6" + ); + assert_eq!( + parse_home_codex_settings_document(nel_doc.as_bytes()) + .unwrap() + .settings + .subagent_defaults + .unwrap() + .model, + "\u{0085}gpt-5.6" + ); + + // 2. Truncated chunked upstream EOF returns UnexpectedEof without emitting 0\r\n\r\n + let mut truncated_upstream = std::io::Cursor::new(b"5\r\nhello\r\n".to_vec()); + let mut downstream = Vec::new(); + let stream_err = stream_upstream_to_chunked_client( + &mut truncated_upstream, + &mut downstream, + BodyFraming::Chunked, + Some(1024), + ) + .unwrap_err(); + assert_eq!(stream_err.kind(), std::io::ErrorKind::UnexpectedEof); + assert!(!downstream.ends_with(b"0\r\n\r\n")); + + // 3. External catalog edit during attachment refuses sync and detach preserves user edit + let scratch = std::env::temp_dir().join(format!("ocx-child-cat-guard-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let paths = ChildPaths::new(scratch.join("ocx"), scratch.join("codex")); + fs::create_dir_all(&paths.opencodex_home).unwrap(); + fs::create_dir_all(&paths.codex_home).unwrap(); + let set = HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: true, + subagent_defaults: None, + }; + apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + b"{\"models\":[{\"slug\":\"m1\"}]}\n", + &set, + ) + .unwrap(); + let user_cat = b"{\"models\":[{\"slug\":\"user-edited\"}]}\n"; + fs::write(paths.codex_catalog_path(), user_cat).unwrap(); + let sync_err = apply_attach_or_sync( + &owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + b"{\"models\":[{\"slug\":\"m2\"}]}\n", + &set, + ) + .unwrap_err(); + assert!(sync_err.contains("client_catalog_ownership_changed")); + assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), user_cat); + assert!(matches!( + offline_detach(&detach_guard(&paths), false), + Err(DetachError::Conflict(_)) + )); + assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), user_cat); + + // 4. models_cache.json reconciliation fails closed on missing backup or invalid JSON and restores overwritten native entries + let _ = fs::remove_dir_all(&scratch); + let _guard = ScratchGuard(scratch.clone()); + fs::create_dir_all(&paths.opencodex_home).unwrap(); + fs::create_dir_all(&paths.codex_home).unwrap(); + let pre_cache = br#"{"fetched_at":"2000-01-01T00:00:00Z","client_version":"0.0.0","models":[{"slug":"shared-m","context_window":1}]}"#; + fs::write(paths.codex_models_cache_path(), pre_cache).unwrap(); + apply_attach_or_sync(&owned(&paths), + "lnk_0123456789abcdef", + "link:t", + 10100, + b"{\"models\":[{\"slug\":\"shared-m\",\"context_window\":2},{\"slug\":\"home-only\",\"context_window\":8}]}\n", + &set, + ) + .unwrap(); + // Invalid cache JSON fails closed without deleting journal + fs::write(paths.codex_models_cache_path(), b"{invalid").unwrap(); + assert!(matches!( + offline_detach(&detach_guard(&paths), false), + Err(DetachError::Conflict(_)) + )); + assert!(paths.journal_file.exists()); + // Valid modified cache + missing catalog backup also fails closed before restoring config + let ext_cache = br#"{"fetched_at":"2000-01-01T00:00:00Z","client_version":"0.0.0","models":[{"slug":"shared-m","context_window":2},{"slug":"home-only","context_window":8},{"slug":"ext-native","context_window":9}]}"#; + fs::write(paths.codex_models_cache_path(), ext_cache).unwrap(); + let saved_backup = fs::read(&paths.catalog_backup_file).unwrap(); + fs::remove_file(&paths.catalog_backup_file).unwrap(); + assert!(matches!( + offline_detach(&detach_guard(&paths), false), + Err(DetachError::Conflict(_)) + )); + assert!(paths.journal_file.exists()); + // Restore backup -> detach succeeds, restores shared-m context_window: 1, drops home-only, preserves ext-native + fs::write(&paths.catalog_backup_file, &saved_backup).unwrap(); + assert_eq!( + offline_detach(&detach_guard(&paths), false).unwrap(), + DetachOutcome::ExactRestore + ); + let final_cache: serde_json::Value = + serde_json::from_slice(&fs::read(paths.codex_models_cache_path()).unwrap()).unwrap(); + let models = final_cache["models"].as_array().unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0]["slug"], "shared-m"); + assert_eq!(models[0]["context_window"], 1); + assert_eq!(models[1]["slug"], "ext-native"); + assert_eq!(models[1]["context_window"], 9); + + // 5. AttachRollbackSnapshot::capture rejects non-NotFound read failure & restore reports incomplete rollback + let snap_ok = AttachRollbackSnapshot::capture(&owned(&paths), None).unwrap(); + let _ = fs::remove_file(paths.codex_config_path()); + fs::create_dir_all(paths.codex_config_path()).unwrap(); + let cap_err = AttachRollbackSnapshot::capture(&owned(&paths), None) + .err() + .expect("directory config.toml must fail capture"); + assert!( + cap_err.contains("attach failed reading"), + "err was: {cap_err}" + ); + let rb_err = snap_ok.restore(&detach_guard(&paths)).unwrap_err(); + assert!( + rb_err.contains("attach rollback incomplete"), + "err was: {rb_err}" + ); +} diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs new file mode 100644 index 00000000000..c23c78bde8a --- /dev/null +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -0,0 +1,942 @@ +#![deny(unsafe_op_in_unsafe_fn)] +//! Process-level regressions for ocx-child lifecycle serialization, recovery, daemon commit, +//! and shutdown guarantees. + +mod common; +use common::*; + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Child, Output, Stdio}; +use std::time::Duration; + +use ocx_child::cli::AttachRollbackSnapshot; +use ocx_child::codex_home::{ClaimPolicy, CodexHomeGuard}; +use ocx_child::codex_settings::HomeCodexSettings; +use ocx_child::daemon::pick_ephemeral_link_port; +use ocx_child::journal::apply_attach_or_sync; +use ocx_child::state::ChildPaths; + +const LINK_ID: &str = "lnk_0123456789abcdef"; +const KEY_ID: &str = "link:rust-test"; + +fn raw_token() -> String { + format!("ocx_data_{}", "a".repeat(40)) +} + +fn creds_json() -> String { + format!(r#"{{"apiKeyId":"{KEY_ID}","key":"{}"}}"#, raw_token()) +} + +fn distinct_port(taken: &[u16]) -> u16 { + loop { + let port = pick_ephemeral_link_port().unwrap(); + if !taken.contains(&port) { + return port; + } + } +} + +fn spawn_with_creds(mut cmd: std::process::Command) -> Child { + cmd.stdin(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(creds_json().as_bytes()) + .unwrap(); + child +} + +fn text(output: &Output) -> String { + format!( + "status={:?} stdout={} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// Bytes of every regular file directly inside `dir`, except the never-deleted lock file. +fn snapshot_dir(dir: &Path) -> BTreeMap> { + let mut out = BTreeMap::new(); + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().into_owned(); + if name == ".ocx-child.lock" || !entry.file_type().unwrap().is_file() { + continue; + } + out.insert(name, fs::read(entry.path()).unwrap()); + } + out +} + +fn assert_same_files( + actual: &BTreeMap>, + expected: &BTreeMap>, + what: &str, +) { + let differing: Vec<&String> = actual + .keys() + .chain(expected.keys()) + .filter(|name| actual.get(*name) != expected.get(*name)) + .collect(); + assert!(differing.is_empty(), "{what}: {differing:?}"); +} + +fn without_owner_record(mut files: BTreeMap>) -> BTreeMap> { + files.remove(".ocx-child-owner.json"); + files +} + +#[test] +fn two_child_homes_sharing_one_codex_home_are_serialized_and_exclusive() { + let scratch = Scratch::new("shared-codex"); + let ocx_a = scratch.0.join("ocx-a"); + let ocx_b = scratch.0.join("ocx-b"); + let codex = scratch.0.join("codex"); + for dir in [&ocx_a, &ocx_b, &codex] { + fs::create_dir_all(dir).unwrap(); + } + fs::write( + codex.join("config.toml"), + b"approval_policy = 'on-request'\n", + ) + .unwrap(); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + let tunnel = home.port.to_string(); + let port_a = distinct_port(&[]); + let port_b = distinct_port(&[port_a]); + let (pa, pb) = (port_a.to_string(), port_b.to_string()); + + let barrier_a = Barrier::new(&scratch.0, "a"); + let mut args_a = attach_args(&tunnel, LINK_ID, &pa); + args_a.push("--no-daemon"); + let mut cmd_a = child_command(&ocx_a, &codex, port_a, &args_a); + barrier_a.apply(&mut cmd_a, "attach-protected"); + let a = spawn_with_creds(cmd_a); + assert!( + barrier_a.wait_reached("attach-protected", Duration::from_secs(20)), + "A never entered the protected attach section" + ); + let before = snapshot_dir(&codex); + + let barrier_b = Barrier::new(&scratch.0, "b"); + let mut args_b = attach_args(&tunnel, LINK_ID, &pb); + args_b.push("--no-daemon"); + let mut cmd_b = child_command(&ocx_b, &codex, port_b, &args_b); + barrier_b.apply(&mut cmd_b, "attach-protected"); + let b = spawn_with_creds(cmd_b); + + let b_entered = barrier_b.wait_reached("attach-protected", Duration::from_millis(1500)); + if b_entered { + barrier_b.release("attach-protected"); + } + barrier_a.release("attach-protected"); + assert!( + !b_entered, + "B entered the protected section while A held the shared CODEX_HOME" + ); + assert_same_files( + &snapshot_dir(&codex), + &before, + "B changed shared files while A held them", + ); + + let out_a = a.wait_with_output().unwrap(); + assert!(out_a.status.success(), "A attach failed: {}", text(&out_a)); + assert!( + !String::from_utf8_lossy(&out_a.stdout).contains(KEY_ID) + && !String::from_utf8_lossy(&out_a.stderr).contains(KEY_ID), + "attach output must not name the link key id: {}", + text(&out_a) + ); + let after_a = snapshot_dir(&codex); + let out_b = b.wait_with_output().unwrap(); + assert!( + !out_b.status.success(), + "B attached a CODEX_HOME owned by A: {}", + text(&out_b) + ); + assert!( + String::from_utf8_lossy(&out_b.stderr).contains("owned by another ocx-child"), + "B refusal lacks an ownership recovery message: {}", + text(&out_b) + ); + assert!(!barrier_b.reached("attach-protected")); + assert_same_files( + &snapshot_dir(&codex), + &after_a, + "refused attach B changed shared files", + ); + assert!(!ocx_b.join("child").join("codex-journal.json").exists()); + assert!(!ocx_b.join("child").join("state.json").exists()); + + let (code, _, err) = run_bin(&ocx_a, &codex, port_a, &["detach"], None); + assert_eq!(code, 0, "A detach failed: {err}"); + assert!(!codex.join(".ocx-child-owner.json").exists()); + + let mut args_b = attach_args(&tunnel, LINK_ID, &pb); + args_b.push("--no-daemon"); + let out_b = spawn_with_creds(child_command(&ocx_b, &codex, port_b, &args_b)) + .wait_with_output() + .unwrap(); + assert!( + out_b.status.success(), + "B attach after A detach failed: {}", + text(&out_b) + ); + let (code, _, err) = run_bin(&ocx_b, &codex, port_b, &["detach"], None); + assert_eq!(code, 0, "B detach failed: {err}"); + assert_eq!( + fs::read(codex.join("config.toml")).unwrap(), + b"approval_policy = 'on-request'\n" + ); +} + +fn swap_in_sentinel_home(codex: &Path, saved: &Path) -> BTreeMap> { + fs::rename(codex, saved).unwrap(); + fs::create_dir(codex).unwrap(); + for (name, bytes) in [ + ("config.toml", &b"sentinel-config"[..]), + ("opencodex-catalog.json", &b"sentinel-catalog"[..]), + ("models_cache.json", &b"sentinel-cache"[..]), + ] { + fs::write(codex.join(name), bytes).unwrap(); + } + snapshot_dir(codex) +} + +/// An attach that crashed after its rollback snapshot and journal application: the durable +/// snapshot is still pending and detach must recover it. +fn crashed_attach_fixture(scratch: &Scratch) -> (ChildPaths, Vec) { + let paths = ChildPaths::new(scratch.0.join("ocx"), scratch.0.join("codex")); + fs::create_dir_all(&paths.opencodex_home).unwrap(); + fs::create_dir_all(&paths.codex_home).unwrap(); + let original = b"approval_policy = 'on-request'\n".to_vec(); + fs::write(paths.codex_config_path(), &original).unwrap(); + let guard = CodexHomeGuard::acquire(&paths, true, true, ClaimPolicy::Attach).unwrap(); + let snapshot = AttachRollbackSnapshot::capture(&guard, None).unwrap(); + apply_attach_or_sync( + &guard, + LINK_ID, + KEY_ID, + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &HomeCodexSettings { + fast_mode: None, + multi_agent_mode: None, + web_search_sidecar_enabled: true, + subagent_defaults: None, + }, + ) + .unwrap(); + drop(snapshot); + drop(guard); + assert!(paths.rollback_file.is_file()); + (paths, original) +} + +fn detach_through_swap( + paths: &ChildPaths, + barrier: &Barrier, + point: &str, + saved: &Path, +) -> (Output, BTreeMap>) { + let mut cmd = child_command(&paths.opencodex_home, &paths.codex_home, 0, &["detach"]); + cmd.stdin(Stdio::null()); + barrier.apply(&mut cmd, point); + let child = cmd.spawn().unwrap(); + assert!( + barrier.wait_reached(point, Duration::from_secs(20)), + "detach never reached {point}" + ); + let sentinels = swap_in_sentinel_home(&paths.codex_home, saved); + barrier.release(point); + (child.wait_with_output().unwrap(), sentinels) +} + +#[cfg(unix)] +#[test] +fn crash_recovery_rechecks_codex_home_identity_before_each_restore_write() { + let scratch = Scratch::new("recover-pre-write"); + let (paths, _) = crashed_attach_fixture(&scratch); + let barrier = Barrier::new(&scratch.0, "pre"); + let saved = scratch.0.join("saved-codex"); + let saved_before = snapshot_dir(&paths.codex_home); + let (out, sentinels) = detach_through_swap(&paths, &barrier, "restore-before-write", &saved); + assert!( + !out.status.success(), + "detach wrote into a replaced home: {}", + text(&out) + ); + assert_same_files( + &snapshot_dir(&paths.codex_home), + &sentinels, + "sentinel files were touched", + ); + assert_same_files( + &without_owner_record(snapshot_dir(&saved)), + &without_owner_record(saved_before), + "original home changed after the swap", + ); + assert!( + saved.join(".ocx-child-owner.json").is_file(), + "ownership was released" + ); + assert!(paths.rollback_file.is_file(), "rollback record was removed"); + assert!(paths.journal_file.is_file(), "journal was removed"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("recovery records were kept"), + "missing recovery error: {}", + text(&out) + ); +} + +#[cfg(unix)] +#[test] +fn crash_recovery_keeps_records_when_the_home_changes_after_a_restore_write() { + let scratch = Scratch::new("recover-post-write"); + let (paths, original) = crashed_attach_fixture(&scratch); + let barrier = Barrier::new(&scratch.0, "post"); + let saved = scratch.0.join("saved-codex"); + let (out, sentinels) = detach_through_swap(&paths, &barrier, "restore-after-write", &saved); + assert!( + !out.status.success(), + "detach finished in a replaced home: {}", + text(&out) + ); + assert_same_files( + &snapshot_dir(&paths.codex_home), + &sentinels, + "sentinel files were touched", + ); + // The one write that completed before the swap landed in the original directory. + assert_eq!(fs::read(saved.join("config.toml")).unwrap(), original); + assert!(paths.rollback_file.is_file(), "rollback record was removed"); + assert!(paths.journal_file.is_file(), "journal was removed"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("recovery records were kept"), + "missing recovery error: {}", + text(&out) + ); +} + +#[cfg(unix)] +#[test] +fn offline_detach_rechecks_codex_home_identity_before_each_restore_write() { + let scratch = Scratch::new("detach-pre-write"); + let paths = ChildPaths::new(scratch.0.join("ocx"), scratch.0.join("codex")); + fs::create_dir_all(&paths.opencodex_home).unwrap(); + fs::create_dir_all(&paths.codex_home).unwrap(); + fs::write( + paths.codex_config_path(), + b"approval_policy = 'on-request'\n", + ) + .unwrap(); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + let tunnel = home.port.to_string(); + let port = distinct_port(&[]); + let p = port.to_string(); + let mut args = attach_args(&tunnel, LINK_ID, &p); + args.push("--no-daemon"); + let out = spawn_with_creds(child_command( + &paths.opencodex_home, + &paths.codex_home, + port, + &args, + )) + .wait_with_output() + .unwrap(); + assert!(out.status.success(), "attach failed: {}", text(&out)); + + let barrier = Barrier::new(&scratch.0, "detach"); + let saved = scratch.0.join("saved-codex"); + let saved_before = snapshot_dir(&paths.codex_home); + let (out, sentinels) = detach_through_swap(&paths, &barrier, "restore-before-write", &saved); + assert!( + !out.status.success(), + "detach wrote into a replaced home: {}", + text(&out) + ); + assert_same_files( + &snapshot_dir(&paths.codex_home), + &sentinels, + "sentinel files were touched", + ); + assert_same_files( + &without_owner_record(snapshot_dir(&saved)), + &without_owner_record(saved_before), + "original home changed after the swap", + ); + assert!( + saved.join(".ocx-child-owner.json").is_file(), + "ownership was released" + ); + assert!(paths.journal_file.is_file(), "journal was removed"); + assert!(paths.key_file.is_file(), "link key was removed"); +} + +fn try_http_exchange(port: u16, raw_req: &str) -> Option { + use std::io::Read; + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let mut sock = std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500)).ok()?; + sock.set_read_timeout(Some(Duration::from_secs(3))).ok()?; + sock.write_all(raw_req.as_bytes()).ok()?; + let mut out = Vec::new(); + let _ = sock.read_to_end(&mut out); + Some(String::from_utf8_lossy(&out).into_owned()) +} + +fn models_request(port: u16) -> String { + format!("GET /v1/models HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n") +} + +/// Kills a leftover daemon if an assertion fails before the test cleans up. +struct KillOnDrop(Option); + +impl Drop for KillOnDrop { + fn drop(&mut self) { + if let Some(pid) = self.0 + && ocx_child::platform::process_is_alive(pid) + { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .status(); + } + } +} + +fn read_state(ocx: &Path) -> serde_json::Value { + serde_json::from_slice(&fs::read(ocx.join("child").join("state.json")).unwrap()).unwrap() +} + +#[cfg(unix)] +#[test] +fn daemon_exits_without_serving_when_its_parent_dies_before_commit() { + use ocx_child::platform::process_is_alive; + + let scratch = Scratch::new("parent-crash"); + let ocx = scratch.0.join("ocx"); + let codex = scratch.0.join("codex"); + fs::create_dir_all(&ocx).unwrap(); + fs::create_dir_all(&codex).unwrap(); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + let tunnel = home.port.to_string(); + let port = distinct_port(&[]); + let p = port.to_string(); + let barrier = Barrier::new(&scratch.0, "commit"); + let mut cmd = child_command(&ocx, &codex, port, &attach_args(&tunnel, LINK_ID, &p)); + barrier.apply(&mut cmd, "daemon-before-commit"); + let mut parent = spawn_with_creds(cmd); + assert!( + barrier.wait_reached("daemon-before-commit", Duration::from_secs(20)), + "attach never reached the commit point" + ); + let pid = read_state(&ocx)["daemonPid"] + .as_u64() + .expect("daemon pid recorded") as u32; + let mut leftover = KillOnDrop(Some(pid)); + assert!(process_is_alive(pid)); + + parent.kill().unwrap(); + let _ = parent.wait(); + let relayed_before = home.state.observed_data_headers.lock().unwrap().len(); + let _ = try_http_exchange(port, &models_request(port)); + assert!( + wait_until(Duration::from_secs(5), || !process_is_alive(pid)), + "uncommitted daemon kept running after its parent died" + ); + leftover.0 = None; + assert_eq!( + home.state.observed_data_headers.lock().unwrap().len(), + relayed_before, + "uncommitted daemon relayed a data request" + ); + assert!( + try_http_exchange(port, &models_request(port)).is_none_or(|r| r.is_empty()), + "uncommitted daemon still answers after its parent died" + ); + // The recorded attachment stays recoverable. + let (code, _, err) = run_bin(&ocx, &codex, port, &["detach"], None); + assert_eq!(code, 0, "detach after the parent crash failed: {err}"); +} + +#[cfg(unix)] +#[test] +fn attach_rollback_commits_the_restarted_prior_relay_only_after_removing_its_record() { + let scratch = Scratch::new("rollback-restart"); + let ocx = scratch.0.join("ocx"); + let codex = scratch.0.join("codex"); + fs::create_dir_all(&ocx).unwrap(); + fs::create_dir_all(&codex).unwrap(); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + let tunnel = home.port.to_string(); + let port = distinct_port(&[]); + let p = port.to_string(); + let out = spawn_with_creds(child_command( + &ocx, + &codex, + port, + &attach_args(&tunnel, LINK_ID, &p), + )) + .wait_with_output() + .unwrap(); + assert!(out.status.success(), "first attach failed: {}", text(&out)); + let prior_pid = read_state(&ocx)["daemonPid"].as_u64().unwrap() as u32; + let _prior_leftover = KillOnDrop(Some(prior_pid)); + + // Re-attach on another port. The prior relay is serving when the rollback snapshot is + // taken and is stopped before the new daemon starts; the occupied new port then fails the + // start, so rollback has to restart the prior relay. + let new_port = distinct_port(&[port]); + let np = new_port.to_string(); + let barrier = Barrier::new(&scratch.0, "rollback-restart"); + let mut cmd = child_command(&ocx, &codex, new_port, &attach_args(&tunnel, LINK_ID, &np)); + barrier.apply(&mut cmd, "attach-before-daemon-start,daemon-before-commit"); + let parent = spawn_with_creds(cmd); + assert!( + barrier.wait_reached("attach-before-daemon-start", Duration::from_secs(20)), + "re-attach never reached the daemon start" + ); + let blocker = + std::net::TcpListener::bind(std::net::SocketAddr::from(([127, 0, 0, 1], new_port))) + .expect("occupy the new listen port"); + barrier.release("attach-before-daemon-start"); + let reached_commit = barrier.wait_reached("daemon-before-commit", Duration::from_secs(20)); + let record_at_commit = ocx.join("child").join("attach-rollback.json").exists(); + let restarted_pid = read_state(&ocx)["daemonPid"].as_u64().map(|pid| pid as u32); + let _restarted_leftover = KillOnDrop(restarted_pid); + barrier.release("daemon-before-commit"); + let out = parent.wait_with_output().unwrap(); + drop(blocker); + assert!( + reached_commit, + "rollback never restarted the prior relay: {}", + text(&out) + ); + assert!( + !record_at_commit, + "the restarted prior relay was committed while the rollback record still existed" + ); + assert!(!out.status.success(), "re-attach unexpectedly succeeded"); + assert!( + !ocx.join("child").join("attach-rollback.json").exists(), + "rollback record left behind: {}", + text(&out) + ); + let healthz = + format!("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); + let health = try_http_exchange(port, &healthz).unwrap_or_default(); + assert!( + health.contains("200 OK") && health.contains("\"generationCommitted\":true"), + "prior relay not serving after rollback: {health} / {}", + text(&out) + ); + let (code, _, err) = run_bin(&ocx, &codex, port, &["detach"], None); + assert_eq!(code, 0, "detach after rollback failed: {err}"); +} + +/// Starts `serve --foreground --lifecycle-start` the way a lifecycle parent does, with a +/// start line for `generation`. +fn spawn_lifecycle_serve(ocx: &Path, codex: &Path, port: u16, generation: &str) -> Child { + let secret = read_state(ocx)["shutdownSecret"] + .as_str() + .unwrap() + .to_owned(); + let p = port.to_string(); + let mut cmd = child_command( + ocx, + codex, + port, + &[ + "serve", + "--foreground", + "--lifecycle-start", + "--listen-port", + &p, + ], + ); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = cmd.spawn().unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all(format!("ocx-child-start/1 {generation} {secret}\n").as_bytes()) + .unwrap(); + child +} + +fn wait_exit(child: &mut Child, timeout: Duration) -> Option { + let mut status = None; + wait_until(timeout, || { + status = child.try_wait().unwrap(); + status.is_some() + }); + if status.is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + status +} + +fn set_state_generation(ocx: &Path, generation: Option<&str>) { + let path = ocx.join("child").join("state.json"); + let mut state = read_state(ocx); + match generation { + Some(g) => state["daemonGeneration"] = serde_json::Value::String(g.to_owned()), + None => { + state.as_object_mut().unwrap().remove("daemonGeneration"); + } + } + fs::write(path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); +} + +#[cfg(unix)] +#[test] +fn lifecycle_start_requires_the_recorded_generation_and_a_commit() { + let scratch = Scratch::new("generation"); + let ocx = scratch.0.join("ocx"); + let codex = scratch.0.join("codex"); + fs::create_dir_all(&ocx).unwrap(); + fs::create_dir_all(&codex).unwrap(); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + let tunnel = home.port.to_string(); + let port = distinct_port(&[]); + let p = port.to_string(); + let mut args = attach_args(&tunnel, LINK_ID, &p); + args.push("--no-daemon"); + let out = spawn_with_creds(child_command(&ocx, &codex, port, &args)) + .wait_with_output() + .unwrap(); + assert!(out.status.success(), "attach failed: {}", text(&out)); + let relayed = || home.state.observed_data_headers.lock().unwrap().len(); + let stale = "a".repeat(32); + let current = "b".repeat(32); + + // A generation the state never recorded is refused even with a matching commit line. + set_state_generation(&ocx, None); + let before = relayed(); + let mut child = spawn_lifecycle_serve(&ocx, &codex, port, &stale); + let _ = child + .stdin + .as_mut() + .unwrap() + .write_all(format!("commit {stale}\n").as_bytes()); + let _ = try_http_exchange(port, &models_request(port)); + let status = wait_exit(&mut child, Duration::from_secs(5)); + assert!( + status.is_some_and(|s| !s.success()), + "stale generation was not refused: {status:?}" + ); + assert_eq!(relayed(), before, "stale generation relayed a request"); + + // A superseded generation is refused once state records a newer one. + set_state_generation(&ocx, Some(¤t)); + let mut child = spawn_lifecycle_serve(&ocx, &codex, port, &stale); + let _ = child + .stdin + .as_mut() + .unwrap() + .write_all(format!("commit {stale}\n").as_bytes()); + let status = wait_exit(&mut child, Duration::from_secs(5)); + assert!( + status.is_some_and(|s| !s.success()), + "superseded generation served" + ); + + // The recorded generation without a commit exits on EOF and never relays. + let mut child = spawn_lifecycle_serve(&ocx, &codex, port, ¤t); + assert!(wait_until(Duration::from_secs(5), || { + try_http_exchange( + port, + &format!( + "GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ), + ) + .is_some_and(|r| r.contains("200 OK")) + })); + let before = relayed(); + let pending = try_http_exchange(port, &models_request(port)).unwrap_or_default(); + assert!( + !pending.contains("404"), + "uncommitted daemon relayed: {pending}" + ); + assert_eq!(relayed(), before, "uncommitted daemon relayed a request"); + drop(child.stdin.take()); + let status = wait_exit(&mut child, Duration::from_secs(5)); + assert!( + status.is_some_and(|s| !s.success()), + "daemon without a commit did not exit: {status:?}" + ); + + // Positive control: the recorded generation plus its commit serves. + let mut child = spawn_lifecycle_serve(&ocx, &codex, port, ¤t); + child + .stdin + .as_mut() + .unwrap() + .write_all(format!("commit {current}\n").as_bytes()) + .unwrap(); + // Reap the daemon as soon as it exits, as init does for a detached daemon; otherwise + // the exited process lingers as a zombie that still looks alive to detach. + let reaper = std::thread::spawn(move || child.wait().unwrap()); + assert!( + wait_until(Duration::from_secs(5), || { + try_http_exchange( + port, + &format!( + "GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ), + ) + .is_some_and(|r| r.contains("\"generationCommitted\":true")) + }), + "committed daemon never reported its commit" + ); + let before = relayed(); + let _ = try_http_exchange(port, &models_request(port)); + assert_eq!(relayed(), before + 1, "committed daemon did not relay"); + let (code, _, err) = run_bin(&ocx, &codex, port, &["detach"], None); + assert_eq!(code, 0, "detach failed: {err}"); + assert!(reaper.join().unwrap().success()); +} + +/// Attach without a daemon, then run an operator foreground relay whose exit is reaped by a +/// background thread (as init reaps a detached daemon). +fn attached_foreground_relay( + scratch: &Scratch, + home: &MockHomeServer, +) -> ( + std::path::PathBuf, + std::path::PathBuf, + u16, + std::thread::JoinHandle, +) { + let ocx = scratch.0.join("ocx"); + let codex = scratch.0.join("codex"); + fs::create_dir_all(&ocx).unwrap(); + fs::create_dir_all(&codex).unwrap(); + let tunnel = home.port.to_string(); + let port = distinct_port(&[]); + let p = port.to_string(); + let mut args = attach_args(&tunnel, LINK_ID, &p); + args.push("--no-daemon"); + let out = spawn_with_creds(child_command(&ocx, &codex, port, &args)) + .wait_with_output() + .unwrap(); + assert!(out.status.success(), "attach failed: {}", text(&out)); + let mut cmd = child_command( + &ocx, + &codex, + port, + &["serve", "--foreground", "--listen-port", &p], + ); + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = cmd.spawn().unwrap(); + let healthz = + format!("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); + assert!( + wait_until(Duration::from_secs(5), || try_http_exchange(port, &healthz) + .is_some_and(|r| r.contains("200 OK"))), + "foreground relay never became healthy" + ); + let reaper = std::thread::spawn(move || child.wait().unwrap()); + (ocx, codex, port, reaper) +} + +fn join_within( + reaper: std::thread::JoinHandle, + timeout: Duration, +) -> bool { + wait_until(timeout, || reaper.is_finished()) && reaper.join().is_ok() +} + +#[cfg(unix)] +#[test] +fn detach_stops_a_relay_whose_parity_request_stalls_or_trickles() { + for (mode, label) in [(1u8, "stall"), (2u8, "trickle")] { + let scratch = Scratch::new(&format!("parity-{label}")); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + home.state + .parity_mode + .store(mode, std::sync::atomic::Ordering::SeqCst); + let (ocx, codex, port, reaper) = attached_foreground_relay(&scratch, &home); + assert!( + wait_until(Duration::from_secs(10), || home + .state + .parity_stalled + .load(std::sync::atomic::Ordering::SeqCst) + > 0), + "{label}: relay never sent a parity report" + ); + let started = std::time::Instant::now(); + let (code, _, err) = run_bin(&ocx, &codex, port, &["detach"], None); + let elapsed = started.elapsed(); + assert_eq!(code, 0, "{label}: detach failed after {elapsed:?}: {err}"); + assert!( + elapsed < Duration::from_secs(4), + "{label}: detach took {elapsed:?}" + ); + assert!( + join_within(reaper, Duration::from_secs(2)), + "{label}: relay process still running after detach" + ); + } +} + +#[cfg(unix)] +#[test] +fn detach_stops_a_relay_whose_workers_are_all_busy() { + let scratch = Scratch::new("saturated"); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + let (ocx, codex, port, reaper) = attached_foreground_relay(&scratch, &home); + // Every worker blocks reading a request head that never completes. + let mut idle = Vec::new(); + for _ in 0..40 { + let mut sock = + std::net::TcpStream::connect(std::net::SocketAddr::from(([127, 0, 0, 1], port))) + .unwrap(); + sock.write_all(b"GET /healthz HTTP/1.1\r\n").unwrap(); + idle.push(sock); + } + std::thread::sleep(Duration::from_millis(300)); + let started = std::time::Instant::now(); + let (code, _, err) = run_bin(&ocx, &codex, port, &["detach"], None); + let elapsed = started.elapsed(); + assert_eq!(code, 0, "detach failed after {elapsed:?}: {err}"); + assert!(elapsed < Duration::from_secs(4), "detach took {elapsed:?}"); + assert!( + join_within(reaper, Duration::from_secs(2)), + "relay process still running after detach" + ); + drop(idle); +} + +#[cfg(unix)] +#[test] +fn control_connections_that_trickle_their_request_head_are_cut_off() { + use std::io::Read; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let scratch = Scratch::new("control-trickle"); + let home = MockHomeServer::start(&raw_token(), KEY_ID, LINK_ID); + let (ocx, codex, port, reaper) = attached_foreground_relay(&scratch, &home); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + // Every worker blocks reading a request head that never completes. + let mut idle = Vec::new(); + for _ in 0..40 { + let mut sock = std::net::TcpStream::connect(addr).unwrap(); + sock.write_all(b"GET /healthz HTTP/1.1\r\n").unwrap(); + idle.push(sock); + } + std::thread::sleep(Duration::from_millis(300)); + // Fill every control slot with a sender that writes one header byte every 300 ms, inside + // the per-read timeout, and record when the relay closes each connection. + let started = std::time::Instant::now(); + let closed = Arc::new(AtomicUsize::new(0)); + let mut senders = Vec::new(); + for _ in 0..256 { + let closed = Arc::clone(&closed); + senders.push(std::thread::spawn(move || { + let Ok(mut sock) = std::net::TcpStream::connect(addr) else { + closed.fetch_add(1, Ordering::SeqCst); + return; + }; + let _ = sock.set_read_timeout(Some(Duration::from_millis(300))); + let mut head = b"GET /healthz HTTP/1.1\r\nx-slow: " + .iter() + .copied() + .chain(std::iter::repeat(b'a')); + let mut buf = [0u8; 256]; + while started.elapsed() < Duration::from_secs(8) { + let byte = [head.next().unwrap()]; + if sock.write_all(&byte).is_err() { + break; + } + match sock.read(&mut buf) { + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + _ => break, + } + } + if started.elapsed() < Duration::from_secs(8) { + closed.fetch_add(1, Ordering::SeqCst); + } + })); + } + let all_cut = wait_until(Duration::from_millis(4_500), || { + closed.load(Ordering::SeqCst) == 256 + }); + let cut_count = closed.load(Ordering::SeqCst); + let detach_started = std::time::Instant::now(); + let (code, _, err) = run_bin(&ocx, &codex, port, &["detach"], None); + let elapsed = detach_started.elapsed(); + for sender in senders { + let _ = sender.join(); + } + assert!( + all_cut, + "only {cut_count} of 256 trickling control connections were closed within 4.5 s" + ); + assert_eq!(code, 0, "detach failed after {elapsed:?}: {err}"); + assert!(elapsed < Duration::from_secs(4), "detach took {elapsed:?}"); + assert!( + join_within(reaper, Duration::from_secs(2)), + "relay process still running after detach" + ); + drop(idle); +} + +#[cfg(unix)] +#[test] +fn lock_open_refuses_a_symlink_swapped_in_after_the_symlink_check() { + let scratch = Scratch::new("lock-symlink"); + let ocx = scratch.0.join("ocx"); + let codex = scratch.0.join("codex"); + fs::create_dir_all(&ocx).unwrap(); + fs::create_dir_all(&codex).unwrap(); + let lock = ocx.join("child").join("lifecycle.lock"); + let target = scratch.0.join("outside-target"); + let barrier = Barrier::new(&scratch.0, "lock-open"); + let mut cmd = child_command(&ocx, &codex, distinct_port(&[]), &["detach"]); + cmd.stdin(Stdio::null()); + barrier.apply(&mut cmd, "lock-before-open"); + let child = cmd.spawn().unwrap(); + assert!( + barrier.wait_reached("lock-before-open", Duration::from_secs(20)), + "detach never reached the lock open" + ); + let _ = fs::remove_file(&lock); + std::os::unix::fs::symlink(&target, &lock).unwrap(); + barrier.release("lock-before-open"); + let out = child.wait_with_output().unwrap(); + assert!( + !target.exists(), + "the lock open followed a symlink and created {}: {}", + target.display(), + text(&out) + ); + assert!( + !out.status.success(), + "detach ran without its lock: {}", + text(&out) + ); + assert!( + fs::symlink_metadata(&lock) + .unwrap() + .file_type() + .is_symlink() + ); +} diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs new file mode 100644 index 00000000000..8e85f13b13e --- /dev/null +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -0,0 +1,1120 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod common; +use common::*; + +use std::fs; +use std::io::{BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use ocx_child::daemon::pick_ephemeral_link_port; +#[cfg(unix)] +use ocx_child::daemon::stop_owned_daemon; +use ocx_child::http_wire::read_response_head; +use ocx_child::platform::{FileLock, process_is_alive}; +use ocx_child::relay_auth::compute_child_shutdown_proof; +use ocx_child::state::ChildPaths; +#[cfg(unix)] +use ocx_child::state::{CHILD_STATE_SCHEMA, ChildStateV1}; + +#[test] +fn detached_foreground_serve_refuses_to_start() { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-detached-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + fs::create_dir_all(&scratch).unwrap(); + let listen_port = pick_ephemeral_link_port().unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .args([ + "serve", + "--foreground", + "--listen-port", + &listen_port.to_string(), + ]) + .env("OPENCODEX_HOME", scratch.join("opencodex")) + .env("CODEX_HOME", scratch.join("codex")) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut exited = false; + for _ in 0..100 { + if child.try_wait().unwrap().is_some() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(20)); + } + if !exited { + child.kill().unwrap(); + } + let output = child.wait_with_output().unwrap(); + let _ = fs::remove_dir_all(&scratch); + assert!(exited, "detached foreground server did not refuse startup"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("detached")); + assert!(TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], listen_port))).is_ok()); +} + +#[test] +fn direct_foreground_server_revalidates_attachment_after_binding() { + const MARKER: &str = "OCX_TEST_DIRECT_FOREGROUND_CHILD"; + if let Some(root) = std::env::var_os(MARKER) { + let port: u16 = std::env::var("OCX_TEST_DIRECT_FOREGROUND_PORT") + .unwrap() + .parse() + .unwrap(); + let root = std::path::PathBuf::from(root); + let paths = ChildPaths::new(root.join("opencodex"), root.join("codex")); + assert!(ocx_child::relay::serve_foreground(paths, port).is_err()); + return; + } + let scratch = std::env::temp_dir().join(format!( + "ocx-child-direct-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + fs::create_dir_all(&scratch).unwrap(); + let port = pick_ephemeral_link_port().unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "direct_foreground_server_revalidates_attachment_after_binding", + ]) + .env(MARKER, &scratch) + .env("OCX_TEST_DIRECT_FOREGROUND_PORT", port.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut exited = false; + for _ in 0..100 { + if child.try_wait().unwrap().is_some() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(20)); + } + if !exited { + child.kill().unwrap(); + } + let status = child.wait().unwrap(); + let _ = fs::remove_dir_all(&scratch); + assert!( + exited && status.success(), + "detached direct server did not refuse startup" + ); + assert!(TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))).is_ok()); +} + +#[test] +fn foreground_startup_waits_for_teardown_gate_before_binding() { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-start-gate-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let port = pick_ephemeral_link_port().unwrap(); + let raw_token = format!("ocx_data_{}", "a".repeat(40)); + let key_id = "link:rust-test"; + let link_id = "lnk_0123456789abcdef"; + let _home = MockHomeServer::start(&raw_token, key_id, link_id); + let creds = format!(r#"{{"apiKeyId":"{key_id}","key":"{raw_token}"}}"#); + let (connect, _, error) = run_bin( + &ocx_home, + &codex_home, + port, + &[ + "connect", + "--link", + "--key-stdin", + "--tunnel-port", + &_home.port.to_string(), + "--link-id", + link_id, + ], + Some(creds.as_bytes()), + ); + assert_eq!(connect, 0, "{error}"); + let gate = ocx_child::platform::FileLock::acquire_blocking( + &ocx_home.join("child").join("daemon-start.lock"), + ) + .unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .args(["serve", "--foreground", "--listen-port", &port.to_string()]) + .env("OPENCODEX_HOME", &ocx_home) + .env("CODEX_HOME", &codex_home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + thread::sleep(Duration::from_millis(300)); + let bound_before_release = TcpStream::connect(("127.0.0.1", port)).is_ok(); + fs::remove_file(ocx_home.join("child").join("state.json")).unwrap(); + drop(gate); + let mut exited = false; + for _ in 0..100 { + if child.try_wait().unwrap().is_some() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(20)); + } + if !exited { + let _ = child.kill(); + } + let _ = child.wait(); + let _ = run_bin(&ocx_home, &codex_home, port, &["detach"], None); + let _ = fs::remove_dir_all(&scratch); + assert!( + !bound_before_release, + "server bound before teardown gate was released" + ); + assert!( + exited, + "server remained running after attachment state was removed" + ); +} + +#[test] +fn detach_stops_authenticated_foreground_without_recorded_pid() { + let scratch = std::env::temp_dir().join(format!( + "ocx-child-foreground-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let port = pick_ephemeral_link_port().unwrap(); + let raw_token = format!("ocx_data_{}", "a".repeat(40)); + let key_id = "link:rust-test"; + let link_id = "lnk_0123456789abcdef"; + let mut home = MockHomeServer::start(&raw_token, key_id, link_id); + let creds = format!(r#"{{"apiKeyId":"{key_id}","key":"{raw_token}"}}"#); + let (connect, _, _) = run_bin( + &ocx_home, + &codex_home, + port, + &[ + "connect", + "--link", + "--key-stdin", + "--tunnel-port", + &home.port.to_string(), + "--link-id", + link_id, + ], + Some(creds.as_bytes()), + ); + assert_eq!(connect, 0); + let child = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .args(["serve", "--foreground", "--listen-port", &port.to_string()]) + .env("OPENCODEX_HOME", &ocx_home) + .env("CODEX_HOME", &codex_home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut ready = false; + for _ in 0..100 { + if TcpStream::connect(SocketAddr::from(([127, 0, 0, 1], port))).is_ok() { + ready = true; + break; + } + thread::sleep(Duration::from_millis(20)); + } + let child = Arc::new(Mutex::new(child)); + let waiter_child = Arc::clone(&child); + let waiter = thread::spawn(move || { + loop { + if let Some(status) = waiter_child.lock().unwrap().try_wait().unwrap() { + break status; + } + thread::sleep(Duration::from_millis(10)); + } + }); + let (detach, _, _) = run_bin(&ocx_home, &codex_home, port, &["detach"], None); + if detach != 0 { + let _ = child.lock().unwrap().kill(); + } + let stopped = waiter.join().unwrap().success(); + if detach != 0 { + let _ = run_bin(&ocx_home, &codex_home, port, &["detach"], None); + } + home.shutdown(); + let _ = fs::remove_dir_all(&scratch); + assert!(ready, "foreground server never became ready"); + assert_eq!( + detach, 0, + "authenticated foreground server must be detachable" + ); + assert!(stopped, "foreground daemon remained running after detach"); +} + +#[cfg(unix)] +#[test] +fn untrusted_health_pid_never_authorizes_signalling_an_unrelated_process() { + use std::io::Read; + + let mut unrelated = Command::new("/bin/sleep") + .arg("60") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let leaked = Arc::new(AtomicBool::new(false)); + let thread_leaked = Arc::clone(&leaked); + let sent_after_bad_proof = Arc::new(AtomicBool::new(false)); + let thread_sent_after_bad_proof = Arc::clone(&sent_after_bad_proof); + let claimed_pid = unrelated.id(); + let server = thread::spawn(move || { + while !thread_stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok((mut socket, _)) => { + let _ = socket.set_read_timeout(Some(Duration::from_millis(50))); + let mut bytes = [0u8; 2048]; + let count = socket.read(&mut bytes).unwrap_or(0); + let request = String::from_utf8_lossy(&bytes[..count]); + if request.contains(&"a".repeat(48)) { + thread_leaked.store(true, Ordering::SeqCst); + } + if request.starts_with("GET /healthz ") { + let body = format!( + r#"{{"service":"opencodex","role":"client","ephemeralChild":true,"pid":{claimed_pid},"port":{port}}}"# + ); + let _ = write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + } else if request.starts_with("POST ") { + let response = format!( + "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nx-ocx-child-shutdown-proof: {}\r\nConnection: keep-alive\r\n\r\n", + "0".repeat(64), + ); + let _ = socket.write_all(response.as_bytes()); + let count = socket.read(&mut bytes).unwrap_or(0); + if count > 0 { + thread_sent_after_bad_proof.store(true, Ordering::SeqCst); + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(e) => panic!("fixture listener failed: {e}"), + } + } + }); + let state = ChildStateV1 { + schema: CHILD_STATE_SCHEMA.to_owned(), + link_id: "lnk_0123456789abcdef".to_owned(), + api_key_id: "link:t".to_owned(), + token_fingerprint: "fingerprint".to_owned(), + tunnel_port: 12345, + listen_port: port, + connected_at: String::new(), + catalog_fingerprint: String::new(), + catalog_synced_at: String::new(), + daemon_pid: Some(unrelated.id()), + daemon_started_at_ms: None, + shutdown_secret: "a".repeat(48).into(), + parity_sequence: 0, + pending_operation: None, + applied_settings: None, + daemon_generation: None, + }; + + let result = stop_owned_daemon(&state); + let survived = unrelated.try_wait().unwrap().is_none(); + stop.store(true, Ordering::SeqCst); + server.join().unwrap(); + if survived { + unrelated.kill().unwrap(); + } + let _ = unrelated.wait(); + assert!( + result.is_err(), + "unattested listener must not be treated as stopped" + ); + assert!( + survived, + "an unauthenticated health response authorized a signal" + ); + assert!( + !leaked.load(Ordering::SeqCst), + "untrusted listener received the shutdown secret" + ); + assert!( + !sent_after_bad_proof.load(Ordering::SeqCst), + "caller sent authorization after an invalid listener proof" + ); +} + +#[test] +fn native_attach_starts_authorized_daemon_and_clears_rollback_snapshot() { + struct Cleanup { + root: std::path::PathBuf, + ocx: std::path::PathBuf, + codex: std::path::PathBuf, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let result = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .arg("detach") + .env("OPENCODEX_HOME", &self.ocx) + .env("CODEX_HOME", &self.codex) + .output(); + if result.is_ok_and(|output| output.status.success()) { + let _ = fs::remove_dir_all(&self.root); + } + } + } + let scratch = std::env::temp_dir().join(format!( + "ocx-child-attach-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let _cleanup = Cleanup { + root: scratch.clone(), + ocx: ocx_home.clone(), + codex: codex_home.clone(), + }; + let port = pick_ephemeral_link_port().unwrap(); + let raw_token = format!("ocx_data_{}", "a".repeat(40)); + let key_id = "link:rust-test"; + let link_id = "lnk_0123456789abcdef"; + let mut home = MockHomeServer::start(&raw_token, key_id, link_id); + let creds = format!(r#"{{"apiKeyId":"{key_id}","key":"{raw_token}"}}"#); + let (code, _, stderr) = run_bin( + &ocx_home, + &codex_home, + port, + &[ + "attach", + "--key-stdin", + "--tunnel-port", + &home.port.to_string(), + "--link-id", + link_id, + "--listen-port", + &port.to_string(), + ], + Some(creds.as_bytes()), + ); + assert_eq!(code, 0, "native attach failed: {stderr}"); + assert!(!ocx_home.join("child").join("attach-rollback.json").exists()); + let (sync_code, _, sync_error) = run_bin( + &ocx_home, + &codex_home, + port, + &["sync", "--restart-app-server-only"], + None, + ); + assert_eq!(sync_code, 0, "documented sync flag rejected: {sync_error}"); + assert!( + raw_http_exchange( + port, + &format!("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"), + ) + .contains("200 OK") + ); + let (detach_code, _, detach_error) = run_bin(&ocx_home, &codex_home, port, &["detach"], None); + assert_eq!(detach_code, 0, "detach failed: {detach_error}"); + home.shutdown(); +} + +#[test] +fn captured_shutdown_proof_cannot_be_replayed_on_another_connection() { + struct Cleanup { + root: std::path::PathBuf, + ocx: std::path::PathBuf, + codex: std::path::PathBuf, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let result = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .arg("detach") + .env("OPENCODEX_HOME", &self.ocx) + .env("CODEX_HOME", &self.codex) + .output(); + if result.is_ok_and(|output| output.status.success()) { + let _ = fs::remove_dir_all(&self.root); + } + } + } + let scratch = std::env::temp_dir().join(format!( + "ocx-child-replay-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let _cleanup = Cleanup { + root: scratch, + ocx: ocx_home.clone(), + codex: codex_home.clone(), + }; + let port = pick_ephemeral_link_port().unwrap(); + let raw_token = format!("ocx_data_{}", "a".repeat(40)); + let key_id = "link:rust-test"; + let link_id = "lnk_0123456789abcdef"; + let _home = MockHomeServer::start(&raw_token, key_id, link_id); + let creds = format!(r#"{{"apiKeyId":"{key_id}","key":"{raw_token}"}}"#); + let (code, _, stderr) = run_bin( + &ocx_home, + &codex_home, + port, + &[ + "attach", + "--key-stdin", + "--tunnel-port", + &_home.port.to_string(), + "--link-id", + link_id, + "--listen-port", + &port.to_string(), + ], + Some(creds.as_bytes()), + ); + assert_eq!(code, 0, "fixture attach failed: {stderr}"); + let state: serde_json::Value = + serde_json::from_slice(&fs::read(ocx_home.join("child").join("state.json")).unwrap()) + .unwrap(); + let secret = state["shutdownSecret"].as_str().unwrap(); + let nonce = "b".repeat(64); + let challenge = format!( + "POST /__ocx_child/shutdown HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nx-ocx-child-shutdown-challenge: {nonce}\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n" + ); + let mut first = TcpStream::connect(("127.0.0.1", port)).unwrap(); + first.write_all(challenge.as_bytes()).unwrap(); + let mut first_reader = BufReader::new(first); + let first_head = read_response_head(&mut first_reader).unwrap(); + assert_eq!(first_head.status, 204); + let binding = first_head + .header("x-ocx-child-shutdown-nonce") + .map(|server_nonce| format!("{nonce}{server_nonce}")) + .unwrap_or_else(|| nonce.clone()); + let captured = compute_child_shutdown_proof(secret, "caller", &binding).unwrap(); + drop(first_reader); + + let mut second = TcpStream::connect(("127.0.0.1", port)).unwrap(); + second.write_all(challenge.as_bytes()).unwrap(); + let mut reader = BufReader::new(second.try_clone().unwrap()); + assert_eq!(read_response_head(&mut reader).unwrap().status, 204); + let replay = format!( + "POST /__ocx_child/shutdown HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nx-ocx-child-shutdown-proof: {captured}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + second.write_all(replay.as_bytes()).unwrap(); + let response = read_response_head(&mut reader).unwrap(); + assert_eq!( + response.status, 404, + "captured shutdown authorization was replayed" + ); +} + +#[test] +fn rejected_home_authentication_never_becomes_a_stale_sync_success() { + struct Cleanup { + root: std::path::PathBuf, + ocx: std::path::PathBuf, + codex: std::path::PathBuf, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let result = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .arg("detach") + .env("OPENCODEX_HOME", &self.ocx) + .env("CODEX_HOME", &self.codex) + .output(); + if result.is_ok_and(|output| output.status.success()) { + let _ = fs::remove_dir_all(&self.root); + } + } + } + let scratch = std::env::temp_dir().join(format!( + "ocx-child-auth-{}-{}", + std::process::id(), + ocx_child::platform::random_hex(6).unwrap() + )); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + let _cleanup = Cleanup { + root: scratch, + ocx: ocx_home.clone(), + codex: codex_home.clone(), + }; + let port = pick_ephemeral_link_port().unwrap(); + let raw_token = format!("ocx_data_{}", "a".repeat(40)); + let key_id = "link:rust-test"; + let link_id = "lnk_0123456789abcdef"; + let home = MockHomeServer::start(&raw_token, key_id, link_id); + let creds = format!(r#"{{"apiKeyId":"{key_id}","key":"{raw_token}"}}"#); + let (code, _, stderr) = run_bin( + &ocx_home, + &codex_home, + port, + &[ + "attach", + "--key-stdin", + "--tunnel-port", + &home.port.to_string(), + "--link-id", + link_id, + "--listen-port", + &port.to_string(), + ], + Some(creds.as_bytes()), + ); + assert_eq!(code, 0, "fixture attach failed: {stderr}"); + let paths = ChildPaths::new(ocx_home.clone(), codex_home.clone()); + let config_before = fs::read(paths.codex_config_path()).unwrap(); + let journal_before = fs::read(&paths.journal_file).unwrap(); + let state_before = fs::read(&paths.state_file).unwrap(); + + for status in [401, 403] { + home.state.catalog_status.store(status, Ordering::SeqCst); + let (code, output, _) = run_bin( + &ocx_home, + &codex_home, + port, + &["sync", "--expect-link", link_id, "--json"], + None, + ); + assert_ne!( + code, 0, + "HTTP {status} must not be reported as stale success" + ); + assert!(!output.contains("\"state\":\"stale\"")); + assert!(fs::read(paths.codex_config_path()).unwrap() == config_before); + assert!(fs::read(&paths.journal_file).unwrap() == journal_before); + assert!(fs::read(&paths.state_file).unwrap() == state_before); + } + home.state.catalog_status.store(503, Ordering::SeqCst); + home.state.settings_status.store(401, Ordering::SeqCst); + let (code, _, _) = run_bin( + &ocx_home, + &codex_home, + port, + &["sync", "--expect-link", link_id, "--json"], + None, + ); + assert_ne!( + code, 0, + "settings authentication refusal must not be hidden by stale catalog" + ); + home.state.catalog_status.store(200, Ordering::SeqCst); + home.state.settings_status.store(200, Ordering::SeqCst); + home.state + .spoof_listener_proof + .store(true, Ordering::SeqCst); + let (code, _, _) = run_bin( + &ocx_home, + &codex_home, + port, + &["sync", "--expect-link", link_id, "--json"], + None, + ); + assert_ne!( + code, 0, + "listener proof failure must not become stale success" + ); + home.state + .spoof_listener_proof + .store(false, Ordering::SeqCst); + home.state.catalog_status.store(503, Ordering::SeqCst); + home.state.settings_status.store(503, Ordering::SeqCst); + let (code, output, stderr) = run_bin( + &ocx_home, + &codex_home, + port, + &["sync", "--expect-link", link_id, "--json"], + None, + ); + assert_eq!( + code, 0, + "temporary Home failure should retain offline sync: {stderr}" + ); + assert!(output.contains("\"state\":\"stale\"")); +} + +#[test] +fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detach() { + let scratch = std::env::temp_dir().join(format!("ocx-child-e2e-rust-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let ocx_home = scratch.join("opencodex"); + let codex_home = scratch.join("codex"); + fs::create_dir_all(&ocx_home).unwrap(); + fs::create_dir_all(&codex_home).unwrap(); + fs::write( + codex_home.join("config.toml"), + b"# keep my comment\napproval_policy = \"on-request\"\n", + ) + .unwrap(); + + let listen_port = pick_ephemeral_link_port().unwrap(); + let raw_token = format!("ocx_data_{}", "a".repeat(40)); + let key_id = "link:rust-test"; + let link_id = "lnk_0123456789abcdef"; + let mut home = MockHomeServer::start(&raw_token, key_id, link_id); + + // 1. Detached `restart` fails closed without spawning any daemon + let (rc_det_restart, _, _) = run_bin(&ocx_home, &codex_home, listen_port, &["restart"], None); + assert_eq!(rc_det_restart, 1); + + // 2. Fail-closed attach when full-ocx runtime-port.json is present + fs::write(ocx_home.join("runtime-port.json"), b"{\"pid\":99999}").unwrap(); + let cred_json = format!("{{\"apiKeyId\":\"{key_id}\",\"key\":\"{raw_token}\"}}"); + let (rc_conflict, _, err_conflict) = run_bin( + &ocx_home, + &codex_home, + listen_port, + &[ + "connect", + "--link", + "--key-stdin", + "--tunnel-port", + &home.port.to_string(), + "--link-id", + link_id, + ], + Some(cred_json.as_bytes()), + ); + assert_ne!(rc_conflict, 0); + assert!( + err_conflict.contains("full OpenCodex runtime state"), + "{err_conflict}" + ); + fs::remove_file(ocx_home.join("runtime-port.json")).unwrap(); + + // 3. Clean enrollment: connect + restart + let (rc_conn, _, err_conn) = run_bin( + &ocx_home, + &codex_home, + listen_port, + &[ + "connect", + "--link", + "--key-stdin", + "--tunnel-port", + &home.port.to_string(), + "--link-id", + link_id, + ], + Some(cred_json.as_bytes()), + ); + assert_eq!(rc_conn, 0, "connect stderr: {err_conn}"); + assert!(codex_home.join("models_cache.json").exists()); + + let rollback_path = ocx_home.join("child").join("attach-rollback.json"); + fs::write(&rollback_path, b"{}").unwrap(); + let mut untrusted = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .args([ + "serve", + "--foreground", + "--listen-port", + &listen_port.to_string(), + ]) + .env("OPENCODEX_HOME", &ocx_home) + .env("CODEX_HOME", &codex_home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut exited = false; + for _ in 0..100 { + if untrusted.try_wait().unwrap().is_some() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(20)); + } + if !exited { + untrusted.kill().unwrap(); + } + let status = untrusted.wait().unwrap(); + fs::remove_file(&rollback_path).unwrap(); + assert!( + exited && !status.success(), + "incomplete attach must refuse untrusted startup" + ); + + let state_path = ocx_home.join("child").join("state.json"); + let state_bytes = fs::read(&state_path).unwrap(); + let mut damaged: serde_json::Value = serde_json::from_slice(&state_bytes).unwrap(); + damaged["shutdownSecret"] = serde_json::Value::String(String::new()); + fs::write(&state_path, serde_json::to_vec(&damaged).unwrap()).unwrap(); + fs::write(&rollback_path, b"{}").unwrap(); + let mut malformed = Command::new(env!("CARGO_BIN_EXE_ocx-child")) + .args([ + "serve", + "--foreground", + "--listen-port", + &listen_port.to_string(), + ]) + .env("OPENCODEX_HOME", &ocx_home) + .env("CODEX_HOME", &codex_home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut exited = false; + for _ in 0..100 { + if malformed.try_wait().unwrap().is_some() { + exited = true; + break; + } + thread::sleep(Duration::from_millis(20)); + } + if !exited { + malformed.kill().unwrap(); + } + let status = malformed.wait().unwrap(); + fs::remove_file(&rollback_path).unwrap(); + fs::write(&state_path, state_bytes).unwrap(); + assert!( + exited && !status.success(), + "empty lifecycle secret must refuse startup" + ); + + let (rc_rest, _, err_rest) = run_bin(&ocx_home, &codex_home, listen_port, &["restart"], None); + assert_eq!(rc_rest, 0, "restart stderr: {err_rest}"); + + // Verify /healthz & /readyz & parity report + let health_resp = raw_http_exchange( + listen_port, + &format!("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\n\r\n"), + ); + assert!(health_resp.contains("200 OK")); + assert!(health_resp.contains("\"ephemeralChild\":true")); + assert!(!health_resp.contains("restartCapability")); + let state_json: serde_json::Value = + serde_json::from_slice(&fs::read(ocx_home.join("child").join("state.json")).unwrap()) + .unwrap(); + let secret = state_json["shutdownSecret"].as_str().unwrap(); + let direct_shutdown = raw_http_exchange( + listen_port, + &format!( + "POST /__ocx_child/shutdown HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\nx-ocx-child-shutdown: {secret}\r\nContent-Length: 0\r\n\r\n" + ), + ); + assert!(direct_shutdown.contains("404 Not Found")); + let origin_shutdown = raw_http_exchange( + listen_port, + &format!( + "POST /__ocx_child/shutdown HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\nOrigin: https://example.invalid\r\nx-ocx-child-shutdown-challenge: {}\r\nContent-Length: 0\r\n\r\n", + "a".repeat(64) + ), + ); + assert!(origin_shutdown.contains("404 Not Found")); + assert!(!origin_shutdown.contains("x-ocx-child-shutdown-proof")); + let first_pid: u32 = + serde_json::from_str::(health_resp.split("\r\n\r\n").nth(1).unwrap()) + .unwrap()["pid"] + .as_u64() + .unwrap() as u32; + + let wrong_codex_home = scratch.join("different-codex"); + fs::create_dir_all(&wrong_codex_home).unwrap(); + let paths = ChildPaths::new(ocx_home.clone(), codex_home.clone()); + let state_before = fs::read(&paths.state_file).unwrap(); + let journal_before = fs::read(&paths.journal_file).unwrap(); + let (wrong_rc, _, _) = run_bin(&ocx_home, &wrong_codex_home, listen_port, &["detach"], None); + assert_ne!( + wrong_rc, 0, + "detach with a different Codex home must refuse" + ); + assert!( + fs::read(&paths.state_file).unwrap() == state_before, + "child state changed" + ); + assert!( + fs::read(&paths.journal_file).unwrap() == journal_before, + "journal changed" + ); + assert!( + raw_http_exchange( + listen_port, + &format!("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\n\r\n"), + ) + .contains("200 OK") + ); + + let ready_resp = raw_http_exchange( + listen_port, + &format!("GET /readyz HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\n\r\n"), + ); + assert!(ready_resp.contains("200 OK")); + assert!(ready_resp.contains("mock-home-inst:1")); + assert!(!home.state.parity_reports.lock().unwrap().is_empty()); + + // 4. Relay SSE POST /v1/responses + strip caller credentials + reject Upgrade/smuggling + let sse_resp = raw_http_exchange( + listen_port, + &format!( + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\nAuthorization: Bearer caller-local-tok\r\nx-api-key: caller-ant\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{{}}" + ), + ); + assert!(sse_resp.contains("200 OK")); + assert!(sse_resp.contains("event: response.created")); + assert!(sse_resp.contains("event: response.completed")); + let last_hdrs = home + .state + .observed_data_headers + .lock() + .unwrap() + .last() + .cloned() + .unwrap(); + assert!( + !last_hdrs + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-api-key")) + ); + let auth_hdr = last_hdrs + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("authorization")) + .unwrap(); + assert_eq!(auth_hdr.1, format!("Bearer {raw_token}")); + + let ws_resp = raw_http_exchange( + listen_port, + &format!( + "GET /v1/responses HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\nUpgrade: websocket\r\n\r\n" + ), + ); + assert!(ws_resp.contains("426 Upgrade Required")); + + let smug_resp = raw_http_exchange( + listen_port, + &format!( + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\nContent-Length: 2\r\nTransfer-Encoding: chunked\r\n\r\n{{}}" + ), + ); + assert!(smug_resp.contains("400 Bad Request")); + + // 5. Spoofed listener proof rejected with 503 link_relay_auth_failed + home.state + .spoof_listener_proof + .store(true, Ordering::SeqCst); + let spoof_resp = raw_http_exchange( + listen_port, + &format!("GET /v1/models HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\n\r\n"), + ); + assert!(spoof_resp.contains("503 Service Unavailable")); + assert!(spoof_resp.contains("link_relay_auth_failed")); + home.state + .spoof_listener_proof + .store(false, Ordering::SeqCst); + + // 6. Busy-lock & mismatch --expect-link exit 5 + let paths = ChildPaths::new(ocx_home.clone(), codex_home.clone()); + { + let _held = FileLock::acquire_blocking(&paths.lock_file).unwrap(); + let (rc_busy, _, err_busy) = run_bin( + &ocx_home, + &codex_home, + listen_port, + &["sync", "--expect-link", link_id, "--json"], + None, + ); + assert_eq!(rc_busy, 5); + assert_eq!( + err_busy.trim().lines().last().unwrap(), + r#"{"phase":"child_link_mismatch","reason":"pending_operation"}"# + ); + } + let (rc_wrong, _, err_wrong) = run_bin( + &ocx_home, + &codex_home, + listen_port, + &["sync", "--expect-link", "lnk_ffffffffffffffff", "--json"], + None, + ); + assert_eq!(rc_wrong, 5); + assert_eq!( + err_wrong.trim().lines().last().unwrap(), + r#"{"phase":"child_link_mismatch","reason":"other_link"}"# + ); + + // 7. Home settings change -> sync --expect-link --json rolls over daemon & reports parity + *home.state.fast_mode.lock().unwrap() = Some(true); + *home.state.catalog_slug.lock().unwrap() = "home-synced-v2".to_owned(); + thread::sleep(Duration::from_millis(15)); + let (rc_sync, out_sync, err_sync) = run_bin( + &ocx_home, + &codex_home, + listen_port, + &["sync", "--expect-link", link_id, "--json"], + None, + ); + assert_eq!(rc_sync, 0, "sync stderr: {err_sync}"); + assert_eq!( + out_sync.trim(), + r#"{"schema":"ocx.sync.fanout/1","home":{"state":"synced","exit":0},"fanout":{"state":"not-applicable","reason":null},"children":[]}"# + ); + let cache_text = fs::read_to_string(codex_home.join("models_cache.json")).unwrap(); + assert!(cache_text.contains("home-synced-v2")); + let latest_parity = home + .state + .parity_reports + .lock() + .unwrap() + .last() + .cloned() + .unwrap(); + assert!(latest_parity.runtime_started_at >= latest_parity.last_apply_at.unwrap()); + + // 8. Shut down Home completely (OFFLINE), simulate #1798 comment strip, then detach! + home.shutdown(); + // Failed reattach on a different port (occupied OR unreachable Home) must keep the previous relay alive! + let occupied = TcpListener::bind("127.0.0.1:0").unwrap(); + let occupied_port = occupied.local_addr().unwrap().port(); + let (rc_occ, _, err_occ) = run_bin( + &ocx_home, + &codex_home, + occupied_port, + &[ + "attach", + "--link-id", + "lnk_0123456789abcdef", + "--tunnel-port", + &home.port.to_string(), + "--listen-port", + &occupied_port.to_string(), + ], + Some(cred_json.as_bytes()), + ); + assert_ne!(rc_occ, 0); + assert!( + err_occ.contains(&occupied_port.to_string()), + "stderr was: {err_occ}" + ); + drop(occupied); + let (rc_unreach, _, err_unreach) = run_bin( + &ocx_home, + &codex_home, + occupied_port, + &[ + "attach", + "--link-id", + "lnk_0123456789abcdef", + "--tunnel-port", + &home.port.to_string(), + "--listen-port", + &occupied_port.to_string(), + ], + Some(cred_json.as_bytes()), + ); + assert_ne!(rc_unreach, 0); + assert!( + err_unreach.contains("unreachable"), + "stderr was: {err_unreach}" + ); + let cfg_now = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let stripped_1798: String = cfg_now + .lines() + .filter(|l| !l.trim().starts_with('#')) + .collect::>() + .join("\n") + + "\nuser_extra = \"kept\"\n"; + fs::write(codex_home.join("config.toml"), stripped_1798.as_bytes()).unwrap(); + + let health_before_detach = raw_http_exchange( + listen_port, + &format!("GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:{listen_port}\r\n\r\n"), + ); + let active_pid: u32 = serde_json::from_str::( + health_before_detach.split("\r\n\r\n").nth(1).unwrap(), + ) + .unwrap()["pid"] + .as_u64() + .unwrap() as u32; + assert_ne!( + active_pid, first_pid, + "sync must have rolled over the daemon" + ); + + let (rc_det, _, err_det) = run_bin(&ocx_home, &codex_home, listen_port, &["disconnect"], None); + assert_eq!(rc_det, 0, "disconnect stderr: {err_det}"); + assert!( + !process_is_alive(active_pid), + "zero leftover daemon after disconnect" + ); + assert!(TcpStream::connect(SocketAddr::from(([127, 0, 0, 1], listen_port))).is_err()); + let restored_cfg = fs::read_to_string(codex_home.join("config.toml")).unwrap(); + assert!(!restored_cfg.contains("openai_base_url")); + assert!(!restored_cfg.contains("opencodex-catalog.json")); + assert!(restored_cfg.contains("approval_policy = \"on-request\"")); + assert!(restored_cfg.contains("user_extra = \"kept\"")); + assert!(!codex_home.join("opencodex-catalog.json").exists()); + assert!(!codex_home.join("models_cache.json").exists()); + let _ = fs::remove_dir_all(&scratch); +} + +#[cfg(unix)] +#[test] +fn child_state_debug_redacts_the_shutdown_secret_and_keeps_the_wire_format() { + let secret = "0123456789abcdef".repeat(3); + let state = ChildStateV1 { + schema: CHILD_STATE_SCHEMA.to_owned(), + link_id: "lnk_0123456789abcdef".to_owned(), + api_key_id: "link:t".to_owned(), + token_fingerprint: "fingerprint".to_owned(), + tunnel_port: 12345, + listen_port: 10100, + connected_at: String::new(), + catalog_fingerprint: String::new(), + catalog_synced_at: String::new(), + daemon_pid: None, + daemon_started_at_ms: None, + shutdown_secret: secret.clone().into(), + parity_sequence: 0, + pending_operation: None, + applied_settings: None, + daemon_generation: None, + }; + let debug = format!("{state:?}"); + assert!( + !debug.contains(&secret), + "Debug printed the secret: {debug}" + ); + let json: serde_json::Value = serde_json::to_value(&state).unwrap(); + assert_eq!( + json["shutdownSecret"], + serde_json::Value::String(secret.clone()) + ); + let back: ChildStateV1 = serde_json::from_value(json).unwrap(); + assert_eq!(back, state); +} diff --git a/package.json b/package.json index c6d5e47d21a..5104aacfbed 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "native/remote-workspace-helper/Cargo.toml", "native/remote-workspace-helper/Cargo.lock", "native/remote-workspace-helper/src", + "native/ocx-child/Cargo.toml", + "native/ocx-child/Cargo.lock", + "native/ocx-child/src", "LICENSE" ], "engines": { @@ -66,6 +69,8 @@ "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "build:ocx-child": "cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml", + "test:ocx-child": "cargo test --locked --manifest-path native/ocx-child/Cargo.toml", "build:standalone": "bun scripts/build-standalone.ts", "test:macos": "swift run --package-path app MenuBarCoreTests", "prepare:package": "bun scripts/prepare-package.ts", diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index bc7ece5d730..691d1eada3b 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -173,5 +173,9 @@ "hermes-profile-routes.test.ts": "clients", "cli-sync-expect-link.test.ts": "cli", "link-connection-generation.test.ts": "clients", - "ci-pr-hygiene-approvals.test.ts": "ci-workflows" + "ci-pr-hygiene-approvals.test.ts": "ci-workflows", + "link-ocx-child-conformance.test.ts": "clients", + "link-ephemeral-child-discovery.test.ts": "clients", + "link-enrollment-ephemeral-child.test.ts": "server", + "link-ocx-child-e2e.test.ts": "clients" } diff --git a/src/link/ephemeral-child.ts b/src/link/ephemeral-child.ts new file mode 100644 index 00000000000..ad0035f7683 --- /dev/null +++ b/src/link/ephemeral-child.ts @@ -0,0 +1,45 @@ +/** + * Contract predicates for the lightweight `ocx-child` ephemeral Remote Link Child runtime. + * + * Pure and free of import-time side effects inside `src/link/` so Home enrollment and + * diagnostics can distinguish a clean detached `ocx-child` host from a full standalone + * `ocx` daemon without changing persisted `LinkStore` records on disk. + */ + +export interface EphemeralChildConfigProjection { + readonly port: number; + readonly runtimeRole?: "standalone" | null | undefined; + readonly ephemeralChild: true; +} + +function isValidChildPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535; +} + +/** + * True when `config show --json` came from a clean, detached `ocx-child` binary ready for + * Home-initiated enrollment (`runtimeRole: "standalone"`, `ephemeralChild: true`, valid port). + * An already-attached `ocx-child` reports `runtimeRole: "client"` and returns `false`. + */ +export function isDetachedEphemeralChildConfig(value: unknown): value is EphemeralChildConfigProjection { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return record.ephemeralChild === true + && (record.runtimeRole ?? "standalone") === "standalone" + && isValidChildPort(record.port); +} + +/** True when a `/healthz` payload identifies a live `ocx-child` client relay on `expectedPort`. */ +export function isEphemeralChildHealth(value: unknown, expectedPort: number): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return record.service === "opencodex" + && record.role === "client" + && record.ephemeralChild === true + && record.port === expectedPort + && typeof record.pid === "number" + && Number.isInteger(record.pid) + && record.pid > 0 + && typeof record.startedAt === "string" + && Number.isFinite(Date.parse(record.startedAt)); +} diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index b93ff831c10..ed774ab3035 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -144,10 +144,12 @@ export function windowsNativeArgv(executable: string, args: readonly string[]): * Desktop first, then the GOAT-managed current junction, then legacy and PATH candidates. Each * must answer --version within ten seconds; only the selected one runs the real command. */ -const OCX_CANDIDATES = String.raw`$u=$env:USERPROFILE;$c=@("$u\.opencodex-desktop\bin\ocx.exe","$u\.local\share\goat\opencodex\current\ocx.exe","$u\.local\bin\ocx.exe") -if($env:LOCALAPPDATA){$c+="$env:LOCALAPPDATA\Programs\OpenCodex\ocx.exe"} -if($env:ProgramFiles){$c+="$env:ProgramFiles\OpenCodex\ocx.exe"} -$c+=@((Get-Command ocx.exe -Type Application -EA 0).Source) +const OCX_CANDIDATES = String.raw`$u=$env:USERPROFILE;$f=@("$u\.opencodex-desktop\bin\ocx.exe","$u\.local\share\goat\opencodex\current\ocx.exe","$u\.local\bin\ocx.exe") +if($env:LOCALAPPDATA){$f+="$env:LOCALAPPDATA\Programs\OpenCodex\ocx.exe"} +if($env:ProgramFiles){$f+="$env:ProgramFiles\OpenCodex\ocx.exe"} +$f+=@((Get-Command ocx.exe -Type Application -EA 0).Source) +$k=@("$u\.local\bin\ocx-child.exe","$u\.cache\opencodex\bin\ocx-child.exe");if($env:LOCALAPPDATA){$k+="$env:LOCALAPPDATA\opencodex\bin\ocx-child.exe"};$k+=@((Get-Command ocx-child.exe -Type Application -EA 0).Source) +$r=if($env:HOME){$env:HOME}elseif($u){$u}else{"."};$h=if($env:OPENCODEX_HOME){$env:OPENCODEX_HOME}else{"$r\.opencodex"};$c=if([IO.File]::Exists("$h\child\state.json")){$k+$f}else{$f+$k} `; export function windowsOcxArgv(args: readonly string[]): string[] { return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args }) + OCX_CANDIDATES + String.raw` diff --git a/src/link/ssh-argv.ts b/src/link/ssh-argv.ts index 26ca2ea9b45..5647d6bb168 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -146,7 +146,21 @@ export const REMOTE_PLATFORM_UNSUPPORTED = 125; * ocx as `"$@"` without another round of parsing. Desktop binaries precede PATH candidates; * each candidate must run --version successfully. A missing runnable ocx exits 127. */ -const REMOTE_OCX_CANDIDATES = `for candidate in "$HOME/.opencodex-desktop/bin/ocx" "/Applications/OpenCodex.app/Contents/MacOS/ocx" "/usr/bin/ocx"; do +const REMOTE_OCX_CANDIDATES = `run_ocx_child() { + for candidate in "$HOME/.local/bin/ocx-child" "$HOME/.cache/opencodex/bin/ocx-child"; do + try_ocx "$candidate" "$@" + done + remaining=$PATH + while :; do + directory=\${remaining%%:*} + try_ocx "\${directory:-.}/ocx-child" "$@" + case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac + done +} +if [ -f "\${OPENCODEX_HOME:-$HOME/.opencodex}/child/state.json" ]; then + run_ocx_child "$@" +fi +for candidate in "$HOME/.opencodex-desktop/bin/ocx" "/Applications/OpenCodex.app/Contents/MacOS/ocx" "/usr/bin/ocx"; do try_ocx "$candidate" "$@" done remaining=$PATH @@ -154,7 +168,8 @@ while :; do directory=\${remaining%%:*} try_ocx "\${directory:-.}/ocx" "$@" case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac -done`; +done +run_ocx_child "$@"`; export const REMOTE_OCX_SCRIPT = `export PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin" case "$(uname -s 2>/dev/null)" in Windows*|MSYS*|MINGW*|CYGWIN*) exit ${REMOTE_PLATFORM_UNSUPPORTED};; esac diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index e4e1bc6f0a8..a617a5dd575 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../../link/ssh-argv"; import { parseFingerprintLine } from "../../link/fingerprint"; +import { isDetachedEphemeralChildConfig } from "../../link/ephemeral-child"; import { requireRemotePlatform } from "../../link/remote-platform"; import type { SshRunner } from "../../link/ssh-runner"; import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe"; @@ -129,13 +130,16 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom let remotePlatform: "posix" | "windows"; const ocx = (args: readonly string[]) => remoteOcxArgv(args, remotePlatform); let record: LinkStore["links"][number] | undefined, input: Uint8Array | undefined; - let remoteMayBeConnected = false, phase = "remote_port"; + let ephemeralChild = false, remoteMayBeConnected = false, phase = "remote_port"; const failed = async (response: Response): Promise => { const compensation = record ? await compensateNewLink(ctx, state, record) : null; const outcome = compensation ?? response; if (!remoteMayBeConnected) return outcome; const payload = await outcome.json() as Record; - return Response.json({ ...payload, residual: true, recovery: "Run ocx disconnect and ocx restart on the Child before retrying." }, { status: outcome.status }); + const recovery = ephemeralChild + ? "Run ocx-child detach (or ocx disconnect) on the Child before retrying." + : "Run ocx disconnect and ocx restart on the Child before retrying."; + return Response.json({ ...payload, residual: true, recovery }, { status: outcome.status }); }; try { remotePlatform = confirmed.remotePlatform ?? await requireRemotePlatform(runner, alias, knownHosts); @@ -153,8 +157,14 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom const childPort = childConfig.port; if (remotePlatform === "posix" && (await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409); const before = await run(remoteChildHttpArgv(childPort, "healthz", LINK_RELAY_VERIFY_MS, remotePlatform)); - const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null; - if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409); + ephemeralChild = isDetachedEphemeralChildConfig(childConfig); + if (ephemeralChild) { + if (before.code === 255) return fail("child_preflight_failed", "The Child loopback port could not be inspected.", 502, sshFailureHint(before.stderr)); + if (before.code === 0) return fail("child_restart_unsupported", "The Child's target port is already in use; disconnect or free the port before linking.", 409); + } else { + const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null; + if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409); + } state.confirmedHosts?.delete(alias); phase = "home_setup"; const id = newLinkId(), issued = issueKey(ctx, "link:" + alias); diff --git a/structure/INDEX.md b/structure/INDEX.md index bab3fd26a1b..86ee6f30b0e 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -205,6 +205,7 @@ A source area can be described by more than one doc, because these docs are orga | `gui/src/account-pause-display.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `gui/src/components/ModelPriceDialog.tsx` | [`pricing.md`](pricing.md) | | `gui/src/hooks/useProviderAccountPools.ts` | [`account-auto-pause.md`](account-auto-pause.md) | +| `native/ocx-child/` | [`remote-link.md`](remote-link.md) | | `scripts/` | [`overview.md`](overview.md)
[`clients/client-support-matrix.md`](clients/client-support-matrix.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `scripts/generate-ocx-skill-surface.ts` | [`cli-management.md`](cli-management.md) | | `scripts/test-run-lock-gate.ts` | [`ops/test-sandbox-cleanup.md`](ops/test-sandbox-cleanup.md) | @@ -444,6 +445,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/codex-cli-update-routes.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | +| `src/server/management/link-enrollment.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/link-routes.ts` | [`remote-link.md`](remote-link.md) | | `src/server/management/logs-usage-routes.ts` | [`usage-client-attribution.md`](usage-client-attribution.md) | | `src/server/management/model-routes.ts` | [`pricing.md`](pricing.md) | diff --git a/structure/manifest.json b/structure/manifest.json index b6da2b17b1a..6d04de59b46 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1356,6 +1356,8 @@ "src/local-credentials/", "src/server/index/link-management.ts", "src/server/management/link-routes.ts", + "src/server/management/link-enrollment.ts", + "native/ocx-child/", "src/lib/gui-pair-intent.ts", "src/lib/windows-owner-acl.ts", "src/server/gui-pair-delivery.ts" diff --git a/structure/overview.md b/structure/overview.md index 99b223136dd..37740968f5d 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -317,3 +317,6 @@ manual and automatic triggers, while subsequent turns keep their conversation se - **INV-MESSAGES-SSE-01** — Messages SSE contains data only after a completed eligible first-field error declaration. Other blocks retain exact bytes and logical enqueue timing, including later error declarations. Enforced by `tests/server/messages-sse-error-filter.test.ts`. + +- **INV-LINK-CHILD-01** — `native/ocx-child` and the TypeScript Link modules agree byte for byte on data/management route admission, `Host`/`Origin` and smuggling rejection, caller credential stripping, `link-relay-auth` v2 HMAC proofs, OCX-6 `appliedCodexSettingsDigest`, and `#1798`-resilient offline journal attach/detach. + Enforced by `tests/clients/link-ocx-child-conformance.test.ts`. diff --git a/structure/remote-link.md b/structure/remote-link.md index e62ce55cbb6..d9043c1aa0c 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -183,3 +183,21 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. + +## Ephemeral Rust Child (`ocx-child`) + +`native/ocx-child/Cargo.toml` and `native/ocx-child/src/lib.rs` build a standalone lightweight Rust binary (`ocx-child`) for disposable SSH devboxes, containers and VMs that do not run a persistent Bun OpenCodex install. Remote candidate discovery in `src/link/ssh-argv.ts` and `src/link/remote-windows.ts` tries `ocx-child` first when `$OPENCODEX_HOME/child/state.json` is present (preserving an active `ocx-child` enrollment even if full `ocx` is installed later), and otherwise tries every full `ocx` path before falling back to `ocx-child` in `~/.local/bin`, `~/.cache/opencodex/bin` (plus `%LOCALAPPDATA%\opencodex\bin` on Windows) and `PATH`, keeping Windows encoded PowerShell below `POWERSHELL_ENCODED_MAX` (`8000`). + +When detached, `ocx-child config show --json` reports `{"port": , "runtimeRole": "standalone", "ephemeralChild": true}`, recognized by `isDetachedEphemeralChildConfig` in `src/link/ephemeral-child.ts`. During `POST /api/link/apply`, `src/server/management/link-enrollment.ts` accepts that clean detached state without a pre-running standalone daemon, refuses `409 child_restart_unsupported` before key issuance if the `/healthz` HTTP probe returns `200` on `` (`502 child_preflight_failed` on an SSH transport drop; non-200 foreign listeners are rejected by `ocx-child connect`'s `TcpListener::bind` port check and compensated by Home), enrolls through `connect --link --key-stdin`, starts `ocx-child serve` via `restart`, and verifies `role: "client"` `/healthz` plus an authenticated `/v1/catalog` callback. + +`ocx-child serve` binds `127.0.0.1:`, enforces `linkRouteAllowed` and `linkManagementRouteAllowed`, rejects `Upgrade` (`426` on `/v1/responses` WebSocket upgrade) and `Content-Length` + `Transfer-Encoding` smuggling, strips caller credentials, authenticates each upstream socket with `link-relay-auth` v2 before sending the single bound request, and posts OCX-6 `LinkChildParityReport` updates. Child state and a `0600` two-tier undo journal live under `$OPENCODEX_HOME/child/`: `ocx-child sync --expect-link --json` refreshes `opencodex-catalog.json` and `models_cache.json`, rolls over `serve` when `startedAt < appliedAt`, and emits `ocx.sync.fanout/1` JSON (or exit `5` `child_link_mismatch` JSON on stderr); `ocx-child detach` / `disconnect` stops the owned daemon, restores pre-attach bytes or surgically strips journaled values across `#1798` comment-stripping rewrites (failing closed with exit `4` on conflicts), and leaves zero background daemon. While attached, every slug of the injected Home catalog is Child-owned in `models_cache.json`, whatever that entry's current content, because Codex rewrites the cache and may re-serialize it: when the cache changed outside the Child, `sync` writes the Home version of each catalog slug and keeps entries with any other slug, and `detach` puts back the pre-attach version of each injected slug, or drops it when the pre-attach cache lacked it. The private catalog backup that tells them which slugs were injected names both the previous and the new catalog from before the cache write until after it, so an interrupted `sync` cannot leave previous Home entries looking like local ones. Shared golden vectors in `tests/fixtures/link/ocx-child-conformance.json` lock TypeScript and Rust parity. + +`CODEX_HOME` ownership (`native/ocx-child/src/codex_home.rs`): the lifecycle lock and journal live under `$OPENCODEX_HOME/child/`, but `config.toml`, `opencodex-catalog.json` and `models_cache.json` live in `CODEX_HOME`, which two `ocx-child` installs with different `OPENCODEX_HOME`s can share. Every lifecycle command that writes those files (`attach`/`connect`, `sync`, `restart`, `detach`) therefore also takes a fixed lock file `.ocx-child.lock` inside the canonical `CODEX_HOME` (never deleted or replaced, opened like every lock file without following a symlink or reparse point, and on Windows refused unless an existing one already carries the owner-only DACL, locked with the same `flock`/`LockFileEx` as the local lock) and checks a durable ownership record `.ocx-child-owner.json` there (schema `ocx.child.codex-home-owner/1`: random owner id, canonical `OPENCODEX_HOME`, directory identities of both homes, phase `attaching`/`attached`/`detaching`/`cleanup-complete`; no credentials; the owner id is mirrored in `$OPENCODEX_HOME/child/codex-home-owner-id`). Lock order everywhere is local lifecycle lock, then the `CODEX_HOME` lock, then the daemon start gate. `attach` takes it before the rollback snapshot and holds it through rollback or commit; `sync` from before journal application through the state write; `detach` through restore, cleanup, and release, which durably writes `cleanup-complete` before removing the record so a crash at any step leaves a record only the owning home's `detach` resumes. Ownership is never taken over by PID or timeout: a record owned by another `OPENCODEX_HOME`, an unreadable or corrupt record, or a lock I/O error refuses without mutation and names the recovery step. `journal::apply_attach_or_sync` and `offline_detach` take the `CodexHomeGuard` as a parameter, so they cannot run without the lock. The guarantee is scoped to `ocx-child` against `ocx-child`; a full OpenCodex install sharing the homes is still refused by `assert_no_full_ocx_conflict`, which does not take this lock. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (two real processes, two `OPENCODEX_HOME`s, one `CODEX_HOME`). + +Restoration (immediate attach rollback, crash recovery of an incomplete attach during `detach`, and offline `detach`) runs every write, rename, and removal through `CodexHomeGuard::restore_step`, which re-verifies the captured directory identities of both homes immediately before and immediately after the system call, under the `CODEX_HOME` lock. On a mismatch restoration stops at once, keeps the rollback snapshot, journal, key, and ownership record, and reports a recovery error; the rollback snapshot and journal are removed only by a step whose own checks passed. The window between an identity check and the system call it guards is not closed: anyone who can replace the home or a parent directory can still swap the home in that instant. Doing so gives them nothing they lack already, because the same access lets them replace those files directly; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against a writer who controls that directory tree. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (a test barrier between verification and the write while the home is replaced by a directory of sentinel files). + +Daemon start commit (`native/ocx-child/src/daemon.rs`, `relay.rs`): `attach`, `restart`, `sync` rollover, and rollback's restart of the prior relay all start `serve --foreground --lifecycle-start` through `start_or_rollover_daemon`. It records a fresh random `daemonGeneration` in `state.json` before spawning and passes `ocx-child-start/1 ` on the child's stdin, which stays open. Inside the daemon start gate the child requires `state.daemonGeneration` to equal its generation, and until it reads `commit ` on stdin it answers only `/healthz` (reporting `generationCommitted: false`), returning `503 child_starting` for every other route and posting no parity reports. EOF, a read error, or any other line before the commit makes it exit without serving, so a lifecycle parent that dies after handing over the secret leaves no relay behind and no recovery record is touched. The parent writes the commit line only after the state naming the generation and PID is durable and, for `attach`, after the ownership phase is `attached` and the rollback snapshot is removed, and for rollback's restart of the prior relay, after the rollback snapshot is removed (a removal that fails leaves that relay uncommitted, and it exits); it then waits for `/healthz` to report `generationCommitted: true`. An operator-run `ocx-child serve --foreground` (no `--lifecycle-start`) serves immediately and is refused while an attach rollback is pending. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (parent killed at a barrier before the commit; stale and superseded generations). + +Authenticated shutdown is bounded by `stop_owned_daemon`'s 3-second wait. One-shot Home calls carry a whole-request deadline (60 s for lifecycle fetches, 10 s for parity reports) enforced per read on the upstream socket, because per-read timeouts alone never end a trickling response. The relay's parity thread registers a clone of its in-flight socket in a `CancelSlot`; shutdown takes the same mutex, marks the slot cancelled, and shuts that socket down, so the request either sees the cancellation before it registers or is aborted after. A socket can be registered only once connected, so that connect is capped at 1 second. `serve` exits only after the parity thread has ended. When all 32 workers are busy, a new connection gets a short-lived control thread (at most 256) that answers only `/healthz` and the authenticated shutdown, with 1-second read and write timeouts and a 2-second deadline covering the request heads of the whole exchange. A full worker pool therefore cannot keep the shutdown connection from being handled, and a sender that trickles bytes holds a control slot for at most 2 seconds; while 256 control connections are open, a new one is closed unanswered. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (a Home that authenticates and then stalls or trickles the parity response; a relay whose workers are all blocked; control slots held by senders that trickle their request heads). Coverage: `tests/clients/link-ocx-child-conformance.test.ts`, `tests/clients/link-ephemeral-child-discovery.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/link-ocx-child-e2e.test.ts`. + +Foreground startup requires an attached state, matching recovery journal, verified link key and matching listener port. Sync, restart and detach refuse a journal bound to different canonical homes; detach checks this before changing state or stopping the relay. Shutdown authenticates both ends on one socket without sending the private secret and never signals a process based only on a reported PID. Attach keeps a private rollback snapshot until success or completed recovery; detach can retry an incomplete rollback. Failed restore or cleanup keeps recovery records for retry. On Windows, temporary replacements are created with a protected owner-only DACL before bytes are written. diff --git a/tests/clients/link-ephemeral-child-discovery.test.ts b/tests/clients/link-ephemeral-child-discovery.test.ts new file mode 100644 index 00000000000..db691839ccd --- /dev/null +++ b/tests/clients/link-ephemeral-child-discovery.test.ts @@ -0,0 +1,182 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { inflateRawSync } from "node:zlib"; +import { describe, expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { classifySyncResult, classifyVersionProbe } from "../../src/cli/sync-fanout"; +import { remoteOcxArgv, remotePinnedSyncArgv } from "../../src/link/ssh-argv"; +import { powershellArgv, POWERSHELL_ENCODED_MAX, windowsOcxArgv, windowsPinnedSyncArgv, windowsScriptHelperSource } from "../../src/link/remote-windows"; + +function writeExecScript(path: string, body: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + chmodSync(path, 0o755); +} + +function createPrivateShellPath(root: string): string { + const utilDir = join(root, "private-utils"); + mkdirSync(utilDir, { recursive: true }); + for (const util of ["sh", "uname", "mktemp", "rm", "sleep", "kill", "wc", "cat", "printf", "awk"]) { + for (const sysDir of ["/bin", "/usr/bin"]) { + const candidate = join(sysDir, util); + if (existsSync(candidate)) { + symlinkSync(candidate, join(utilDir, util)); + break; + } + } + } + return utilDir; +} + +function sandboxRemoteScript(script: string, root: string): string { + return script + .replaceAll("/Applications/OpenCodex.app/Contents/MacOS/ocx", join(root, "Apps/OpenCodex.app/Contents/MacOS/ocx")) + .replaceAll("/usr/bin/ocx", join(root, "sys-usr/bin/ocx")) + .replaceAll("/opt/homebrew/bin", join(root, "sys-homebrew/bin")) + .replaceAll("/usr/local/bin", join(root, "sys-local/bin")); +} + +function inflateWindowsScript(encoded: string): string { + const outer = Buffer.from(encoded, "base64").toString("utf16le"); + const match = /\$ocxPacked='([^']+)'/.exec(outer); + if (!match) return outer; + return inflateRawSync(Buffer.from(Array.from(match[1]!, ch => ch.charCodeAt(0) - 256))).toString("utf8"); +} + +describe("ephemeral ocx-child SSH discovery and sync fan-out argv", () => { + test("OCX_PROTOCOL_COMPAT_VERSION in native/ocx-child satisfies >= 2.93.0 fan-out gate", () => { + const libRs = readFileSync(repoPath("native", "ocx-child", "src", "lib.rs"), "utf8"); + const match = /OCX_PROTOCOL_COMPAT_VERSION:\s*&str\s*=\s*"([^"]+)"/.exec(libRs); + expect(match).not.toBeNull(); + expect(classifyVersionProbe(`opencodex ${match![1]!}`)).toBeNull(); + }); + + test("Windows remoteOcxArgv and windowsPinnedSyncArgv stay below POWERSHELL_ENCODED_MAX, preserve OcxJob, and order ocx.exe vs ocx-child.exe by state.json", () => { + const execArgv = windowsOcxArgv(["connect", "--link", "--key-stdin", "--tunnel-port", "22222", "--link-id", "lnk_0123456789abcdef"]); + const syncArgv = windowsPinnedSyncArgv("lnk_0123456789abcdef"); + expect(execArgv[5]!.length).toBeLessThan(POWERSHELL_ENCODED_MAX); + expect(syncArgv[5]!.length).toBeLessThan(POWERSHELL_ENCODED_MAX); + const decodedExec = Buffer.from(execArgv[5]!, "base64").toString("utf16le"); + expect(windowsScriptHelperSource(decodedExec)).toContain("CreateJobObjectW"); + for (const inflated of [inflateWindowsScript(execArgv[5]!), inflateWindowsScript(syncArgv[5]!)]) { + const ocxIdx = inflated.indexOf("Get-Command ocx.exe"); + const childIdx = inflated.indexOf("Get-Command ocx-child.exe"); + expect(ocxIdx).toBeGreaterThan(0); + expect(childIdx).toBeGreaterThan(ocxIdx); + expect(inflated).toContain("$r=if($env:HOME){$env:HOME}elseif($u){$u}else{\".\"};$h=if($env:OPENCODEX_HOME){$env:OPENCODEX_HOME}else{\"$r\\.opencodex\"}"); + expect(inflated).toContain("[IO.File]::Exists(\"$h\\child\\state.json\")){$k+$f}else{$f+$k}"); + } + }); + + test.skipIf(process.platform === "win32")("POSIX remoteOcxArgv prefers full ocx when detached, prefers ocx-child when child/state.json exists, and falls back to ocx-child", () => { + const scratch = mkdtempSync(join(tmpdir(), "ocx-child-disc-")); + try { + const utilDir = createPrivateShellPath(scratch); + const home = join(scratch, "home"); + const pathBin = join(scratch, "path-bin"); + mkdirSync(home, { recursive: true }); + mkdirSync(pathBin, { recursive: true }); + + writeExecScript( + join(home, ".local", "bin", "ocx"), + 'if [ "$1" = "--version" ]; then echo "opencodex 2.98.0"; exit 0; fi; echo "selected:full-ocx:$*"', + ); + writeExecScript( + join(home, ".cache", "opencodex", "bin", "ocx-child"), + 'if [ "$1" = "--version" ]; then echo "opencodex 2.98.0"; exit 0; fi; echo "selected:ocx-child:$*"', + ); + const argv = remoteOcxArgv(["config", "show", "--json"], "posix"); + argv[2] = sandboxRemoteScript(argv[2]!, scratch); + const both = Bun.spawnSync([...argv], { + env: { HOME: home, PATH: `${utilDir}:${pathBin}` }, + stdout: "pipe", + stderr: "pipe", + }); + expect(both.exitCode).toBe(0); + expect(both.stdout.toString().trim()).toBe("selected:full-ocx:config show --json"); + + // Active ocx-child enrollment (`~/.opencodex/child/state.json` present) prefers ocx-child even when full ocx is present! + mkdirSync(join(home, ".opencodex", "child"), { recursive: true }); + writeFileSync(join(home, ".opencodex", "child", "state.json"), "{}"); + const attachedPrefersChild = Bun.spawnSync([...argv], { + env: { HOME: home, PATH: `${utilDir}:${pathBin}` }, + stdout: "pipe", + stderr: "pipe", + }); + expect(attachedPrefersChild.exitCode).toBe(0); + expect(attachedPrefersChild.stdout.toString().trim()).toBe("selected:ocx-child:config show --json"); + + // Remove state.json and break full ocx -> falls back to ocx-child + writeExecScript(join(home, ".local", "bin", "ocx"), "exit 1"); + const fallbackCache = Bun.spawnSync([...argv], { + env: { HOME: home, OPENCODEX_HOME: join(scratch, "empty-ocx"), PATH: `${utilDir}:${pathBin}` }, + stdout: "pipe", + stderr: "pipe", + }); + expect(fallbackCache.exitCode).toBe(0); + expect(fallbackCache.stdout.toString().trim()).toBe("selected:ocx-child:config show --json"); + + const home2 = join(scratch, "home2"); + mkdirSync(home2, { recursive: true }); + writeExecScript( + join(pathBin, "ocx-child"), + 'if [ "$1" = "--version" ]; then echo "opencodex 2.98.0"; exit 0; fi; echo \'{"schema":"ocx.sync.fanout/1","home":{"state":"synced","exit":0},"fanout":{"state":"not-applicable","reason":null},"children":[]}\'', + ); + const syncCmd = remotePinnedSyncArgv("lnk_0123456789abcdef", "posix"); + syncCmd[2] = sandboxRemoteScript(syncCmd[2]!, scratch); + const syncRun = Bun.spawnSync([...syncCmd], { + env: { HOME: home2, PATH: `${pathBin}:${utilDir}` }, + stdout: "pipe", + stderr: "pipe", + }); + expect(syncRun.exitCode).toBe(0); + expect(classifySyncResult({ + code: syncRun.exitCode, + stdout: syncRun.stdout.toString(), + stderr: syncRun.stderr.toString(), + })).toEqual({ state: "synced", reason: null }); + } finally { + removeTreeWithRetry(scratch); + } + }); + + test.skipIf(process.platform !== "win32")("Windows execution selects ocx-child.exe when HOME/.opencodex/child/state.json exists and ocx.exe when state.json is only under USERPROFILE", async () => { + const scratch = mkdtempSync(join(tmpdir(), "ocx-child-win-disc-")); + try { + const userProfile = join(scratch, "user-profile"); + const altHome = join(scratch, "alt-home"); + mkdirSync(join(userProfile, ".local", "bin"), { recursive: true }); + mkdirSync(join(userProfile, ".cache", "opencodex", "bin"), { recursive: true }); + mkdirSync(altHome, { recursive: true }); + const compileExe = async (outPath: string, tag: string) => { + const cs = `using System; class P { static int Main(string[] a) { if (a.Length > 0 && a[0] == "--version") { Console.WriteLine("opencodex 2.98.0"); return 0; } Console.WriteLine("${tag}:" + String.Join("|", a)); return 0; } }`; + const p = Bun.spawn(powershellArgv(`Add-Type -TypeDefinition '${cs.replaceAll("'", "''")}' -OutputAssembly '${outPath.replaceAll("'", "''")}' -OutputType ConsoleApplication`), { stdout: "pipe", stderr: "pipe" }); + expect(await p.exited).toBe(0); + }; + await compileExe(join(userProfile, ".local", "bin", "ocx.exe"), "full-ocx"); + await compileExe(join(userProfile, ".cache", "opencodex", "bin", "ocx-child.exe"), "ocx-child"); + const psPath = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0"); + const runCmd = async (argv: string[]) => { + const env: Record = { ...process.env, HOME: altHome, USERPROFILE: userProfile, LOCALAPPDATA: userProfile, ProgramFiles: userProfile, PATH: psPath }; + delete env.OPENCODEX_HOME; + const proc = Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }); + const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]); + return { code, out: out.replaceAll("\r\n", "\n").trim() }; + }; + // 1. State exists only under USERPROFILE -> HOME wins, so detached precedence picks full ocx.exe + mkdirSync(join(userProfile, ".opencodex", "child"), { recursive: true }); + writeFileSync(join(userProfile, ".opencodex", "child", "state.json"), "{}"); + expect(await runCmd(windowsOcxArgv(["status"]))).toEqual({ code: 0, out: "full-ocx:status" }); + expect((await runCmd(windowsPinnedSyncArgv("lnk_0123456789abcdef"))).out).toContain("full-ocx:sync|--expect-link|lnk_0123456789abcdef|--json"); + // 2. State exists under HOME -> active ocx-child enrollment wins + mkdirSync(join(altHome, ".opencodex", "child"), { recursive: true }); + writeFileSync(join(altHome, ".opencodex", "child", "state.json"), "{}"); + expect(await runCmd(windowsOcxArgv(["status"]))).toEqual({ code: 0, out: "ocx-child:status" }); + expect((await runCmd(windowsPinnedSyncArgv("lnk_0123456789abcdef"))).out).toContain("ocx-child:sync|--expect-link|lnk_0123456789abcdef|--json"); + } finally { + removeTreeWithRetry(scratch); + } + }, 30000); +}); diff --git a/tests/clients/link-ocx-child-conformance.test.ts b/tests/clients/link-ocx-child-conformance.test.ts new file mode 100644 index 00000000000..d431528fbac --- /dev/null +++ b/tests/clients/link-ocx-child-conformance.test.ts @@ -0,0 +1,660 @@ +// Holds INV-LINK-CHILD-01 from structure/overview.md; shared TypeScript/Rust golden conformance suite for native/ocx-child. +import { createHash, createHmac } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { userInfo } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { fixturePath, repoPath } from "../helpers/repo-root"; +import { linkRouteAllowed } from "../../src/link/routes"; +import { linkManagementRouteAllowed } from "../../src/link/management-routes"; +import { isDetachedEphemeralChildConfig, isEphemeralChildHealth } from "../../src/link/ephemeral-child"; +import { linkConnectionProof, linkRelayProof, linkRelayProofMatches } from "../../src/link/relay-auth"; +import { codexSettingsDigest, codexSettingsDigestPayload, type HomeCodexSettings } from "../../src/link/codex-settings"; +import { parseLinkChildParityReport } from "../../src/link/child-parity"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; +import { isAllowedRequestOrigin } from "../../src/server/auth-cors"; +import { validateHubRelayRequestHeaders } from "../../src/client/hub-relay"; +import { handleLinkIngress } from "../../src/client/link-ingress"; +import { forwardLinkRequestHeaders, HOME_INITIATED_LINK_TUNNEL } from "../../src/client/link-relay"; +import { + applyEol, + dominantEol, + ensureFastModeFeature, + ensureRootWebSearchDisabled, + externalCodexModelProvider, + setRootModelCatalogPath, + setRootOpenaiBaseUrlForTarget, + setRootRealtimeWsBaseUrl, + stripOpencodexCatalogPath, +} from "../../src/codex/inject/config-toml"; +import { stripJournaledOpenaiBaseUrl } from "../../src/codex/injected-marker"; +import { MANAGED_AGENTS_TABLE_MARKER, transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "../../src/codex/subagent-defaults"; +import { rootAssignmentKey, rootSourceLines, sourceText, splitSourceLines, type SourceLine } from "../../src/codex/toml-source-lines"; + +interface ConformanceSuite { + schema: string; + routeCases: Array<{ + id: string; + method: string; + path: string; + query: string; + hostHeader: string | null; + originHeader: string | null; + upgradeHeader: string | null; + expectedAction: string; + expectedAuthHeader: string; + }>; + framingCases: Array<{ + id: string; + rawHeaders: Array<[string, string]>; + expectedOutcome: string; + expectedSurvivedHeaders: string[]; + }>; + relayAuthCases: Array<{ + id: string; + rawToken: string; + tokenSha256Hex: string; + keyId: string; + linkId: string; + nonce: string; + generation: string; + expectedCallerProof: string; + expectedListenerProof: string; + expectedConnectionProof: string; + }>; + settingsDigestCases: Array<{ + id: string; + settings: HomeCodexSettings; + expectedCanonicalJson: string; + expectedDigestHex: string; + }>; + childParityCases: Array<{ + id: string; + rawJson: string; + expectedValid: boolean; + }>; + journalRoundtripCases: Array<{ + id: string; + preAttachToml: string | null; + preAttachCatalog: string | null; + listenPort: number; + catalogPath: string; + initialSettings: HomeCodexSettings; + syncSettings: HomeCodexSettings | null; + midSessionMutation: { + kind: string; + appendedToml?: string; + prependedRootLine?: string; + newBaseUrl?: string; + rawOverrideToml?: string; + tamperedCatalog?: string; + }; + expectedAttachOk: boolean; + expectedDetachOutcome: string; + expectedFinalToml: string | null; + expectedFinalCatalog: string | null; + }>; +} + +const suite = JSON.parse( + readFileSync(fixturePath("link", "ocx-child-conformance.json"), "utf8"), +) as ConformanceSuite; + +function sha256Hex(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +function classifyChildIngressCase(row: ConformanceSuite["routeCases"][number]): { + action: string; + authHeader: string; +} { + const headers = new Headers(); + if (row.hostHeader !== null) headers.set("host", row.hostHeader); + if (row.originHeader !== null) headers.set("origin", row.originHeader); + if (row.upgradeHeader !== null) headers.set("upgrade", row.upgradeHeader); + const fullUrl = `http://127.0.0.1:10100${row.path}${row.query}`; + const url = new URL(fullUrl); + const req = new Request(fullUrl, { method: row.method, headers }); + const policy = { hostname: "127.0.0.1" }; + + if (row.upgradeHeader !== null) { + if (url.pathname === "/v1/responses" && row.upgradeHeader.trim().toLowerCase() === "websocket") { + if (!isAllowedRequestOrigin(req, policy)) return { action: "reject_403_origin", authHeader: "none" }; + return { action: "reject_426_websocket", authHeader: "none" }; + } + return { action: "reject_404", authHeader: "none" }; + } + if (url.pathname === "/healthz" && row.method === "GET") { + return { action: "local_healthz", authHeader: "none" }; + } + if (url.pathname === "/readyz") { + return row.method === "GET" + ? { action: "local_readyz", authHeader: "none" } + : { action: "reject_404", authHeader: "none" }; + } + if (linkRouteAllowed(url, req)) { + if (!isAllowedRequestOrigin(req, policy)) return { action: "reject_403_origin", authHeader: "none" }; + return { + action: "relay_data", + authHeader: url.pathname === "/v1/usage" ? "x-opencodex-api-key" : "bearer", + }; + } + if (linkManagementRouteAllowed(url, req)) { + if (!isAllowedRequestOrigin(req, policy)) return { action: "reject_403_origin", authHeader: "none" }; + return { action: "relay_management", authHeader: "bearer" }; + } + return { action: "reject_404", authHeader: "none" }; +} + +function isStructuralSimpleTable(line: SourceLine, expectedName: string): boolean { + if (!line.structural || !/^\s*\[/.test(line.text)) return false; + try { + const parsed = Bun.TOML.parse(line.text) as Record; + const entry = parsed[expectedName]; + return ( + Object.keys(parsed).length === 1 && + typeof entry === "object" && + entry !== null && + !Array.isArray(entry) && + Object.keys(entry).length === 0 + ); + } catch { + return false; + } +} + +function matchesStructuralBoolAssignment(line: SourceLine, key: string, expected: boolean): boolean { + if (!line.structural || rootAssignmentKey(line.text) !== key) return false; + try { + const parsed = Bun.TOML.parse(line.text) as Record; + return parsed[key] === expected; + } catch { + return false; + } +} + +function collapseStructuralBlankLines(lf: string): string { + const lines = splitSourceLines(lf); + const out: SourceLine[] = []; + let blankRun = 0; + let started = false; + for (const line of lines) { + const isStructBlank = line.structural && line.text.length === 0; + if (!started && isStructBlank) continue; + started = true; + if (isStructBlank) { + blankRun += 1; + if (blankRun <= 1) out.push(line); + } else { + blankRun = 0; + out.push(line); + } + } + while (out.length > 0 && out[out.length - 1]!.structural && out[out.length - 1]!.text.length === 0) { + out.pop(); + } + return sourceText(out); +} + +function findStructuralFeatureLine(content: string, key: string): string | null { + const lines = splitSourceLines(content); + const featIdx = lines.findIndex(line => isStructuralSimpleTable(line, "features")); + if (featIdx === -1) return null; + let endIdx = lines.findIndex((line, i) => i > featIdx && line.structural && /^\s*\[/.test(line.text)); + if (endIdx === -1) endIdx = lines.length; + for (let i = featIdx + 1; i < endIdx; i++) { + if (lines[i]!.structural && rootAssignmentKey(lines[i]!.text) === key) { + return lines[i]!.text; + } + } + return null; +} + +function applyMultiAgentFeature(content: string, mode: HomeCodexSettings["multiAgentMode"]): { content: string; createdKey: boolean; priorLine: string | null } { + if (mode === null) return { content, createdKey: false, priorLine: null }; + const desired = mode === "v2"; + const lines = splitSourceLines(content); + const eol = lines.find(l => l.eol)?.eol || "\n"; + const featIdx = lines.findIndex(line => isStructuralSimpleTable(line, "features")); + if (featIdx === -1) { + const trimmed = content.replace(/\n+$/, ""); + const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : ""; + return { content: `${prefix}[features]\nmulti_agent_v2 = ${desired}\n`, createdKey: true, priorLine: null }; + } + let endIdx = lines.findIndex((line, i) => i > featIdx && line.structural && /^\s*\[/.test(line.text)); + if (endIdx === -1) endIdx = lines.length; + for (let i = featIdx + 1; i < endIdx; i++) { + if (lines[i]!.structural && rootAssignmentKey(lines[i]!.text) === "multi_agent_v2") { + const priorLine = lines[i]!.text; + lines[i]!.text = `multi_agent_v2 = ${desired}`; + return { content: sourceText(lines), createdKey: false, priorLine }; + } + } + let insertAt = endIdx; + while (insertAt > featIdx + 1 && lines[insertAt - 1]!.structural && lines[insertAt - 1]!.text.trim() === "") insertAt--; + if (insertAt > 0 && !lines[insertAt - 1]!.eol) lines[insertAt - 1]!.eol = eol; + lines.splice(insertAt, 0, { text: `multi_agent_v2 = ${desired}`, eol, structural: true }); + return { content: sourceText(lines), createdKey: true, priorLine: null }; +} + +function stripSurgicalTableFeatures( + content: string, + wroteFastMode: boolean | null, + priorUserFastModeLine: string | null, + wroteMultiAgentV2: boolean | null, + priorUserMultiAgentV2Line: string | null, +): string { + if (wroteFastMode === null && wroteMultiAgentV2 === null) return content; + const lines = splitSourceLines(content); + const eol = lines.find(l => l.eol)?.eol || "\n"; + const featIdx = lines.findIndex(line => isStructuralSimpleTable(line, "features")); + if (featIdx === -1) return content; + let endIdx = lines.findIndex((line, i) => i > featIdx && line.structural && /^\s*\[/.test(line.text)); + if (endIdx === -1) endIdx = lines.length; + const body: SourceLine[] = []; + for (const line of lines.slice(featIdx + 1, endIdx)) { + if (wroteFastMode !== null && matchesStructuralBoolAssignment(line, "fast_mode", wroteFastMode)) { + if (priorUserFastModeLine !== null) body.push({ text: priorUserFastModeLine, eol: line.eol || eol, structural: true }); + continue; + } + if (wroteMultiAgentV2 !== null && matchesStructuralBoolAssignment(line, "multi_agent_v2", wroteMultiAgentV2)) { + if (priorUserMultiAgentV2Line !== null) body.push({ text: priorUserMultiAgentV2Line, eol: line.eol || eol, structural: true }); + continue; + } + body.push(line); + } + if (body.every(line => line.structural && line.text.trim() === "")) { + return collapseStructuralBlankLines(sourceText([...lines.slice(0, featIdx), ...lines.slice(endIdx)])); + } + return sourceText([...lines.slice(0, featIdx + 1), ...body, ...lines.slice(endIdx)]); +} + +function stripSurgicalSubagentDefaults(content: string, written: WrittenSubagentDefaults | null): string { + if (!written) return content; + const markerStripped = transformManagedSubagentDefaults(content, null, written); + let next = markerStripped.ok ? markerStripped.content : content; + const lines = splitSourceLines(next); + const agIdx = lines.findIndex(line => isStructuralSimpleTable(line, "agents")); + if (agIdx === -1) return next; + let endIdx = lines.findIndex((line, i) => i > agIdx && line.structural && /^\s*\[/.test(line.text)); + if (endIdx === -1) endIdx = lines.length; + const prefixLines = agIdx > 0 && lines[agIdx - 1]!.structural && lines[agIdx - 1]!.text.trim() === MANAGED_AGENTS_TABLE_MARKER + ? lines.slice(0, agIdx - 1) + : lines.slice(0, agIdx); + const body = lines.slice(agIdx + 1, endIdx).filter(line => { + if (!line.structural) return true; + if (written.default_subagent_model && line.text.trim() === `default_subagent_model = ${JSON.stringify(written.default_subagent_model)}`) return false; + if (written.default_subagent_reasoning_effort && line.text.trim() === `default_subagent_reasoning_effort = ${JSON.stringify(written.default_subagent_reasoning_effort)}`) return false; + return true; + }); + if (body.every(line => line.structural && line.text.trim() === "")) { + next = collapseStructuralBlankLines(sourceText([...prefixLines, ...lines.slice(endIdx)])); + } else { + next = sourceText([...prefixLines, lines[agIdx]!, ...body, ...lines.slice(endIdx)]); + } + return next; +} + +function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][number]) { + if (row.preAttachToml !== null && externalCodexModelProvider(row.preAttachToml) !== null) { + return { + attachOk: false, + detachOutcome: "not_attached", + finalToml: row.preAttachToml, + finalCatalog: row.preAttachCatalog, + }; + } + const eol = row.preAttachToml ? dominantEol(row.preAttachToml) : "\n"; + const target = { + baseUrl: `http://127.0.0.1:${row.listenPort}/v1`, + requiresAdmissionToken: false, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + }; + let replacedUserWebSearch: string | null = null; + let wroteWebSearch: string | null = null; + let wroteFastMode: boolean | null = null; + let priorUserFastModeLine: string | null = null; + let wroteMultiAgentV2: boolean | null = null; + let priorUserMultiAgentV2Line: string | null = null; + let wroteSubagent: WrittenSubagentDefaults | null = null; + + const applySettings = (rawInput: string, settings: HomeCodexSettings): string => { + let lf = applyEol(rawInput, "\n"); + lf = stripJournaledOpenaiBaseUrl(lf, target.baseUrl, target.baseUrl); + const ws = ensureRootWebSearchDisabled(lf, !settings.webSearchSidecarEnabled, { + injectedValue: wroteWebSearch, + replacedUserLine: replacedUserWebSearch, + }); + lf = ws.content; + replacedUserWebSearch = ws.replacedUserLine; + wroteWebSearch = ws.wroteValue; + lf = setRootModelCatalogPath(lf, row.catalogPath); + lf = setRootOpenaiBaseUrlForTarget(lf, target).content; + lf = setRootRealtimeWsBaseUrl(lf, target).content; + if (settings.fastMode !== null) { + if (wroteFastMode === null) { + priorUserFastModeLine = findStructuralFeatureLine(lf, "fast_mode"); + } + lf = ensureFastModeFeature(lf, settings.fastMode); + wroteFastMode = settings.fastMode; + } + if (settings.multiAgentMode !== null) { + const v2Res = applyMultiAgentFeature(lf, settings.multiAgentMode); + if (wroteMultiAgentV2 === null) priorUserMultiAgentV2Line = v2Res.priorLine; + lf = v2Res.content; + wroteMultiAgentV2 = settings.multiAgentMode === "v2"; + } + const desiredSub = settings.subagentDefaults + ? { + model: settings.subagentDefaults.model, + ...(settings.subagentDefaults.reasoningEffort + ? { reasoningEffort: settings.subagentDefaults.reasoningEffort } + : {}), + } + : null; + const subRes = transformManagedSubagentDefaults(lf, desiredSub, wroteSubagent); + if (subRes.ok) { + lf = subRes.content; + wroteSubagent = desiredSub + ? { + default_subagent_model: desiredSub.model, + ...(desiredSub.reasoningEffort + ? { default_subagent_reasoning_effort: desiredSub.reasoningEffort } + : {}), + } + : null; + } + return applyEol(lf, eol); + }; + + let currentToml = applySettings(row.preAttachToml ?? "", row.initialSettings); + let latestInjectedSha = sha256Hex(currentToml); + let untouchedByExternal = true; + let currentCatalog: string | null = "{\"models\":[{\"slug\":\"home-m1\"}]}\n"; + const latestCatalogSha = sha256Hex(currentCatalog); + + if (row.syncSettings) { + if (sha256Hex(currentToml) !== latestInjectedSha) untouchedByExternal = false; + currentToml = applySettings(currentToml, row.syncSettings); + latestInjectedSha = sha256Hex(currentToml); + } + + switch (row.midSessionMutation.kind) { + case "strip_comments_and_append_table": { + currentToml = currentToml + .split("\n") + .filter(line => !line.trim().startsWith("#")) + .join("\n") + .replace(/\n+$/, "\n") + (row.midSessionMutation.appendedToml ?? ""); + break; + } + case "strip_comments_and_prepend_root": { + currentToml = (row.midSessionMutation.prependedRootLine ?? "") + currentToml + .split("\n") + .filter(line => !line.trim().startsWith("#")) + .join("\n"); + break; + } + case "replace_root_base_url": { + currentToml = currentToml.replace( + `openai_base_url = "http://127.0.0.1:${row.listenPort}/v1"`, + `openai_base_url = ${JSON.stringify(row.midSessionMutation.newBaseUrl)}`, + ); + break; + } + case "corrupt_managed_span": { + currentToml = row.midSessionMutation.rawOverrideToml ?? currentToml; + break; + } + case "tamper_catalog": { + currentCatalog = row.midSessionMutation.tamperedCatalog ?? currentCatalog; + break; + } + } + + if (currentCatalog !== null && sha256Hex(currentCatalog) !== latestCatalogSha) { + return { + attachOk: true, + detachOutcome: "refuse_conflict", + finalToml: null, + finalCatalog: currentCatalog, + }; + } + + if (untouchedByExternal && sha256Hex(currentToml) === latestInjectedSha) { + return { + attachOk: true, + detachOutcome: "exact_restore", + finalToml: row.preAttachToml, + finalCatalog: row.preAttachCatalog, + }; + } + + const { lines: curStructLines, rootEnd: curRootEnd } = rootSourceLines(applyEol(currentToml, "\n")); + for (let i = 0; i < curRootEnd; i++) { + const line = curStructLines[i]!; + if (line.structural && /^\s*(?:openai_base_url|experimental_realtime_ws_base_url|model_catalog_json|web_search)\s*=/.test(line.text)) { + try { + Bun.TOML.parse(line.text); + } catch { + return { + attachOk: true, + detachOutcome: "refuse_conflict", + finalToml: currentToml, + finalCatalog: currentCatalog, + }; + } + } + } + + let surgical = applyEol(currentToml, "\n"); + surgical = surgical.replace(/^# Auto-injected by opencodex(?: \(undo: ocx restore\))?\n(?=\s*openai_base_url\s*=)/gm, ""); + surgical = stripJournaledOpenaiBaseUrl(surgical, target.baseUrl, target.baseUrl); + surgical = stripOpencodexCatalogPath(surgical); + surgical = ensureRootWebSearchDisabled(surgical, false, { + injectedValue: wroteWebSearch, + replacedUserLine: replacedUserWebSearch ? replacedUserWebSearch.replace(/(?:\r?\n)+$/, "") : null, + }).content; + surgical = stripSurgicalTableFeatures( + surgical, + wroteFastMode, + priorUserFastModeLine, + wroteMultiAgentV2, + priorUserMultiAgentV2Line, + ); + surgical = stripSurgicalSubagentDefaults(surgical, wroteSubagent); + surgical = collapseStructuralBlankLines(surgical); + if (surgical.length > 0 && !surgical.endsWith("\n")) surgical += "\n"; + const finalToml = surgical.trim().length === 0 ? null : applyEol(surgical, eol); + return { + attachOk: true, + detachOutcome: "surgical_restore", + finalToml, + finalCatalog: row.preAttachCatalog, + }; +} + +describe("ocx-child shared golden conformance vectors (INV-LINK-CHILD-01)", () => { + test("schema version and non-empty vector coverage", () => { + expect(suite.schema).toBe("ocx.child.conformance/1"); + expect(suite.routeCases.length).toBeGreaterThanOrEqual(40); + expect(suite.framingCases.length).toBeGreaterThanOrEqual(10); + expect(suite.relayAuthCases.length).toBeGreaterThanOrEqual(3); + expect(suite.settingsDigestCases.length).toBeGreaterThanOrEqual(4); + expect(suite.childParityCases.length).toBeGreaterThanOrEqual(6); + expect(suite.journalRoundtripCases.length).toBeGreaterThanOrEqual(8); + }); + + test("routeCases match linkRouteAllowed, linkManagementRouteAllowed, Upgrade, and Host/Origin anti-rebinding", () => { + const injectedToken = "ocx_data_" + "2".repeat(40); + for (const row of suite.routeCases) { + const actual = classifyChildIngressCase(row); + expect(actual, row.id).toEqual({ + action: row.expectedAction, + authHeader: row.expectedAuthHeader, + }); + if (row.expectedAction === "relay_data" || row.expectedAction === "relay_management") { + const forwarded = forwardLinkRequestHeaders(new Headers(), injectedToken, row.path); + if (row.expectedAuthHeader === "x-opencodex-api-key") { + expect(forwarded.get("x-opencodex-api-key"), row.id).toBe(injectedToken); + expect(forwarded.get("authorization"), row.id).toBeNull(); + } else { + expect(forwarded.get("authorization"), row.id).toBe(`Bearer ${injectedToken}`); + expect(forwarded.get("x-opencodex-api-key"), row.id).toBeNull(); + } + } + } + }); + + test("framingCases match link header smuggling validation, handleLinkIngress chunked forwarding, and CALLER_CREDENTIAL_HEADERS stripping", async () => { + const injectedToken = "ocx_data_" + "1".repeat(40); + for (const row of suite.framingCases) { + const hasCrLf = row.rawHeaders.some(([k, v]) => !k || /[\r\n]/.test(k) || /[\r\n]/.test(v)); + if (hasCrLf) { + const rawCheck = validateHubRelayRequestHeaders(row.rawHeaders); + expect(rawCheck.ok, row.id).toBe(false); + if (!rawCheck.ok) expect(rawCheck.reason, row.id).toBe("invalid"); + expect(row.expectedOutcome, row.id).toBe("reject_400_invalid"); + continue; + } + const contentLengths = row.rawHeaders.filter(([k]) => k.toLowerCase() === "content-length"); + const transferEncodings = row.rawHeaders.filter(([k]) => k.toLowerCase() === "transfer-encoding"); + const preNormalized = transferEncodings.length === 1 + && transferEncodings[0]![1]!.trim().toLowerCase() === "chunked" + && contentLengths.length === 0 + ? row.rawHeaders.filter(([k]) => k.toLowerCase() !== "transfer-encoding") + : row.rawHeaders; + const check = validateHubRelayRequestHeaders(preNormalized); + if (!check.ok) { + expect(row.expectedOutcome, row.id).toBe( + check.reason === "smuggling" ? "reject_400_smuggling" : "reject_400_invalid", + ); + continue; + } + expect(row.expectedOutcome, row.id).toBe("allow"); + const source = new Headers(); + for (const [k, v] of row.rawHeaders) source.append(k, v); + const forwarded = forwardLinkRequestHeaders(source, injectedToken, "/v1/responses"); + expect(forwarded.get("authorization"), row.id).toBe(`Bearer ${injectedToken}`); + forwarded.delete("authorization"); + expect([...forwarded.keys()].sort(), row.id).toEqual(row.expectedSurvivedHeaders); + const usageForwarded = forwardLinkRequestHeaders(source, injectedToken, "/v1/usage"); + expect(usageForwarded.get("x-opencodex-api-key"), row.id).toBe(injectedToken); + if (row.id === "allow_lone_transfer_encoding_chunked") { + let observedBody = ""; + const url = new URL("http://127.0.0.1:10100/v1/responses"); + const req = new Request(url, { method: "POST", headers: source, body: "{\"ok\":1}" }); + const resp = await handleLinkIngress(req, url, { + tunnelPort: 22222, + policy: { hostname: "127.0.0.1" }, + linkKey: () => injectedToken, + relay: { + tunnel: HOME_INITIATED_LINK_TUNNEL, + fetchImpl: (async (_u, init) => { + observedBody = await new Response(init?.body as BodyInit).text(); + return Response.json({ relayed: true }); + }) as typeof fetch, + }, + }); + expect(resp?.status).toBe(200); + expect(observedBody).toBe("{\"ok\":1}"); + } + } + }); + + test("relayAuthCases lock raw 32-byte SHA-256 HMAC key derivation and reject ASCII-hex key misuse", () => { + for (const row of suite.relayAuthCases) { + const fp = serviceApiTokenFingerprint(row.rawToken); + expect(fp, row.id).toBe(row.tokenSha256Hex); + const caller = linkRelayProof(fp, "caller", row.keyId, row.linkId, row.nonce); + const listener = linkRelayProof(fp, "listener", row.keyId, row.linkId, row.nonce); + const conn = linkConnectionProof(fp, row.keyId, row.linkId, row.nonce, row.generation); + expect(caller, row.id).toBe(row.expectedCallerProof); + expect(listener, row.id).toBe(row.expectedListenerProof); + expect(conn, row.id).toBe(row.expectedConnectionProof); + expect(linkRelayProofMatches(caller, row.expectedCallerProof), row.id).toBe(true); + expect(linkRelayProofMatches(caller, listener), row.id).toBe(false); + const asciiBug = createHmac("sha256", fp) + .update(`opencodex-link-relay-v2\0caller\0${row.keyId}\0${row.linkId}\0${row.nonce}`) + .digest("hex"); + expect(asciiBug, row.id).not.toBe(row.expectedCallerProof); + } + }); + + test("settingsDigestCases lock OCX-6 fixed-key canonical JSON and SHA-256 digests", () => { + for (const row of suite.settingsDigestCases) { + expect(codexSettingsDigestPayload(row.settings), row.id).toBe(row.expectedCanonicalJson); + expect(codexSettingsDigest(row.settings), row.id).toBe(row.expectedDigestHex); + } + }); + + test("childParityCases match parseLinkChildParityReport exact-key and range invariants", () => { + for (const row of suite.childParityCases) { + const parsed = parseLinkChildParityReport(JSON.parse(row.rawJson)); + expect(parsed !== null, row.id).toBe(row.expectedValid); + } + }); + + test("journalRoundtripCases verify exact restore, #1798 surgical strip, and fail-closed conflicts", () => { + for (const row of suite.journalRoundtripCases) { + const actual = runReferenceJournalCase(row); + expect(actual.attachOk, row.id).toBe(row.expectedAttachOk); + expect(actual.detachOutcome, row.id).toBe(row.expectedDetachOutcome); + if (row.expectedFinalToml !== null || actual.detachOutcome !== "refuse_conflict") { + expect(actual.finalToml, row.id).toBe(row.expectedFinalToml); + } + expect(actual.finalCatalog, row.id).toBe(row.expectedFinalCatalog); + } + }); + + test("ephemeral-child config and health predicates enforce detached vs attached invariants", () => { + expect(isDetachedEphemeralChildConfig({ port: 10100, runtimeRole: "standalone", ephemeralChild: true })).toBe(true); + expect(isDetachedEphemeralChildConfig({ port: 10100, runtimeRole: null, ephemeralChild: true })).toBe(true); + expect(isDetachedEphemeralChildConfig({ port: 10100, ephemeralChild: true })).toBe(true); + expect(isDetachedEphemeralChildConfig({ port: 10100, runtimeRole: "client", ephemeralChild: true })).toBe(false); + expect(isDetachedEphemeralChildConfig({ port: 10100, runtimeRole: "standalone" })).toBe(false); + expect(isDetachedEphemeralChildConfig({ port: 80, runtimeRole: "standalone", ephemeralChild: true })).toBe(false); + expect(isEphemeralChildHealth({ + service: "opencodex", + version: "2.98.0", + role: "client", + ephemeralChild: true, + pid: 1234, + port: 10100, + startedAt: "2026-11-07T00:00:00.000Z", + }, 10100)).toBe(true); + expect(isEphemeralChildHealth({ + service: "opencodex", + role: "client", + ephemeralChild: false, + pid: 1234, + port: 10100, + startedAt: "2026-11-07T00:00:00.000Z", + }, 10100)).toBe(false); + }); + + const exeSuffix = process.platform === "win32" ? ".exe" : ""; + const realHome = process.env.OCX_REAL_HOME?.trim() || userInfo().homedir; + const cargoBin = Bun.which("cargo") ?? join(realHome, ".cargo", "bin", `cargo${exeSuffix}`); + const cargoAvailable = existsSync(cargoBin); + + test.skipIf(!cargoAvailable)("Rust conformance runner passes against the identical shared fixture", () => { + const run = Bun.spawnSync( + [cargoBin, "test", "--locked", "--manifest-path", repoPath("native", "ocx-child", "Cargo.toml"), "--test", "conformance"], + { + env: { + ...process.env, + RUSTUP_HOME: process.env.RUSTUP_HOME || join(realHome, ".rustup"), + CARGO_HOME: process.env.CARGO_HOME || join(realHome, ".cargo"), + }, + stdout: "pipe", + stderr: "pipe", + timeout: 90_000, + }, + ); + if (run.signalCode) { + throw new Error(`cargo test conformance terminated by signal ${run.signalCode} (90s timeout check):\n${run.stderr.toString()}`); + } + expect(run.exitCode, run.stderr.toString()).toBe(0); + }, 90_000); +}); diff --git a/tests/clients/link-ocx-child-e2e.test.ts b/tests/clients/link-ocx-child-e2e.test.ts new file mode 100644 index 00000000000..b05f480f90c --- /dev/null +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -0,0 +1,474 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer as createNetServer } from "node:net"; +import { tmpdir, userInfo } from "node:os"; +import { join, resolve } from "node:path"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { classifySyncResult, runHomeFanout } from "../../src/cli/sync-fanout"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; +import { homeLinkChildParity } from "../../src/link/child-parity"; +import { codexSettingsDigest, projectHomeCodexSettings } from "../../src/link/codex-settings"; +import { linkStorePath } from "../../src/link/paths"; +import { LINK_RELAY_SESSION_HEADER } from "../../src/link/relay-auth"; +import type { SshRunner, SshRunResult } from "../../src/link/ssh-runner"; +import { readLinkStore, writeLinkStore, type LinkStore } from "../../src/link/store"; +import { remotePinnedSyncArgv } from "../../src/link/ssh-argv"; +import { handleLinkManagementRequest } from "../../src/server/index/link-management"; +import { createLinkRelaySessions } from "../../src/server/index/link-relay-sessions"; +import type { ManagementContext } from "../../src/server/management/context"; +import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; +import type { OcxConfig } from "../../src/types"; + +function findOrBuildOcxChildBinary(): string { + const exeSuffix = process.platform === "win32" ? ".exe" : ""; + // Cargo resolves a relative CARGO_TARGET_DIR against its working directory, which is ours. + const cargoTargetDir = process.env.CARGO_TARGET_DIR?.trim(); + const targetDir = cargoTargetDir ? resolve(cargoTargetDir) : repoPath("native", "ocx-child", "target"); + const releaseBin = join(targetDir, "release", `ocx-child${exeSuffix}`); + const debugBin = join(targetDir, "debug", `ocx-child${exeSuffix}`); + const realHome = process.env.OCX_REAL_HOME?.trim() || userInfo().homedir; + const cargo = Bun.which("cargo") ?? join(realHome, ".cargo", "bin", `cargo${exeSuffix}`); + if (existsSync(cargo)) { + const build = Bun.spawnSync( + [cargo, "build", "--locked", "--manifest-path", repoPath("native", "ocx-child", "Cargo.toml")], + { + env: { + ...process.env, + RUSTUP_HOME: process.env.RUSTUP_HOME || join(realHome, ".rustup"), + CARGO_HOME: process.env.CARGO_HOME || join(realHome, ".cargo"), + }, + stdout: "pipe", + stderr: "pipe", + timeout: 90_000, + }, + ); + if (build.exitCode !== 0) { + throw new Error(`cargo build failed: ${build.stderr.toString()}`); + } + return debugBin; + } + if (existsSync(releaseBin)) return releaseBin; + if (existsSync(debugBin)) return debugBin; + throw new Error("neither cargo nor prebuilt ocx-child binary is available"); +} + +async function pickFreeLoopbackPort(): Promise { + const srv = createNetServer(); + await new Promise(resolve => srv.listen(0, "127.0.0.1", resolve)); + const port = (srv.address() as { port: number }).port; + await new Promise(resolve => srv.close(() => resolve())); + return port; +} + +function sandboxRemoteSh(script: string, root: string): string { + return script + .replaceAll("/Applications/OpenCodex.app/Contents/MacOS/ocx", join(root, "Apps/OpenCodex.app/Contents/MacOS/ocx")) + .replaceAll("/usr/bin/ocx", join(root, "sys-usr/bin/ocx")) + .replaceAll("/opt/homebrew/bin", join(root, "sys-homebrew/bin")) + .replaceAll("/usr/local/bin", join(root, "sys-local/bin")); +} + +let scratch = ""; +let prevOcxHome: string | undefined; + +beforeEach(() => { + prevOcxHome = process.env.OPENCODEX_HOME; + scratch = mkdtempSync(join(tmpdir(), "ocx-child-e2e-full-")); + const homeOcxDir = join(scratch, "home-ocx"); + mkdirSync(homeOcxDir, { recursive: true }); + process.env.OPENCODEX_HOME = homeOcxDir; + homeLinkChildParity.clear(); +}); + +afterEach(() => { + homeLinkChildParity.clear(); + if (prevOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevOcxHome; + removeTreeWithRetry(scratch); +}); + +describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx-child lifecycle", () => { + test("enrolls detached ocx-child, streams SSE with stripped caller credentials, runs Home ocx sync fan-out with OCX-6 rollover/parity, classifies --expect-link mismatch exit 5, and detaches cleanly (#1798) with zero leftover daemon", async () => { + const binaryPath = findOrBuildOcxChildBinary(); + const childUserHome = join(scratch, "child-home"); + const childOcxHome = join(childUserHome, ".opencodex"); + const childCodexHome = join(childUserHome, ".codex"); + const childLocalBin = join(childUserHome, ".local", "bin"); + mkdirSync(childOcxHome, { recursive: true }); + mkdirSync(childCodexHome, { recursive: true }); + mkdirSync(childLocalBin, { recursive: true }); + const installedChildBin = join(childLocalBin, "ocx-child"); + copyFileSync(binaryPath, installedChildBin); + chmodSync(installedChildBin, 0o755); + // Also create symlink `ocx-symlink` in separate dir to verify symlinked invocation later + const symlinkDir = join(scratch, "symlink-bin"); + mkdirSync(symlinkDir, { recursive: true }); + symlinkSync(installedChildBin, join(symlinkDir, "ocx")); + + writeFileSync( + join(childCodexHome, "config.toml"), + "# Initial operator comment\napproval_policy = \"on-request\"\nweb_search = \"live\"\n", + ); + + const childListenPort = await pickFreeLoopbackPort(); + const privateUtils = join(scratch, "private-utils"); + mkdirSync(privateUtils, { recursive: true }); + for (const util of ["sh", "uname", "mktemp", "rm", "sleep", "kill", "wc", "cat", "printf", "awk", "curl"]) { + let resolved: string | null = null; + for (const sysDir of ["/bin", "/usr/bin"]) { + const p = join(sysDir, util); + if (existsSync(p)) { + resolved = p; + break; + } + } + resolved ??= Bun.which(util); + if (!resolved) { + throw new Error(`missing required POSIX utility for ocx-child E2E: ${util}`); + } + symlinkSync(resolved, join(privateUtils, util)); + } + const resolvedSh = join(privateUtils, "sh"); + let catalogModels = [{ slug: "home-initial-model" }]; + const observedUpstreamHeaders: Array> = []; + + const homeConfig: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + runtimeRole: "standalone", + defaultProvider: "openai", + fastMode: false, + multiAgentMode: "v1", + webSearchSidecar: { enabled: true } as OcxConfig["webSearchSidecar"], + providers: {}, + apiKeys: [], + }; + + const catalogCallbacks = new Set<(keyId: string) => void>(); + const relaySessions = createLinkRelaySessions({ + fingerprints: (keyId, linkId) => { + const store = readLinkStore(linkStorePath()); + const link = store.links.find(l => l.id === linkId && l.apiKeyId === keyId); + if (!link) return []; + const keyEntry = (homeConfig.apiKeys ?? []).find(k => k.id === keyId); + return keyEntry ? [serviceApiTokenFingerprint(keyEntry.key)] : []; + }, + connectionGeneration: () => "e2e-home-gen:1", + }); + + // Start real Bun.serve Home hub-link listener (serves tunnelPort directly on loopback!) + const homeHubLinkServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 60, + fetch(req, server) { + const u = new URL(req.url); + // Only admit requests that completed same-socket link-relay-auth v2! + if (u.pathname !== "/.well-known/opencodex/link-relay-auth" && !req.headers.has(LINK_RELAY_SESSION_HEADER)) { + return new Response(JSON.stringify({ error: "session_required" }), { status: 401 }); + } + return relaySessions.dispatch(req, server, async innerReq => { + const url = new URL(innerReq.url); + const rawAuth = innerReq.headers.get("x-opencodex-api-key") + ?? innerReq.headers.get("authorization")?.replace(/^Bearer\s+/i, "") + ?? ""; + const admittedKey = (homeConfig.apiKeys ?? []).find(k => k.key === rawAuth); + if (!admittedKey) return Response.json({ error: "unauthorized" }, { status: 401 }); + + const hdrSnapshot: Record = {}; + innerReq.headers.forEach((v, k) => { hdrSnapshot[k.toLowerCase()] = v; }); + observedUpstreamHeaders.push(hdrSnapshot); + + if (url.pathname === "/readyz" && innerReq.method === "GET") { + return Response.json({ + status: "ready", + protocolVersion: 1, + minClientProtocolVersion: 1, + maxClientProtocolVersion: 1, + version: "2.98.0", + managementUrl: `http://127.0.0.1:${server.port}`, + }); + } + if (url.pathname === "/v1/catalog" && innerReq.method === "GET") { + for (const cb of catalogCallbacks) cb(admittedKey.id); + return Response.json({ models: catalogModels }); + } + if (url.pathname === "/v1/responses" && innerReq.method === "POST") { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(ctrl) { + ctrl.enqueue(encoder.encode("event: response.created\ndata: {\"id\":\"resp_e2e\"}\n\n")); + ctrl.enqueue(encoder.encode("event: response.completed\ndata: {\"done\":true}\n\n")); + ctrl.close(); + }, + }); + return new Response(stream, { headers: { "content-type": "text/event-stream" } }); + } + const mgmt = await handleLinkManagementRequest( + innerReq, + url, + homeConfig, + {} as never, + undefined, + admittedKey.id, + ); + return mgmt ?? Response.json({ error: "not_found" }, { status: 404 }); + }); + }, + }); + + const tunnelPort = homeHubLinkServer.port!; + writeLinkStore(linkStorePath(), { version: 1, listenerPort: tunnelPort, links: [] }); + + const childEnv = { + HOME: childUserHome, + OPENCODEX_HOME: childOcxHome, + CODEX_HOME: childCodexHome, + OCX_CHILD_LISTEN_PORT: String(childListenPort), + PATH: privateUtils, + }; + + const runShellAsync = async (argv: string[], stdin?: Uint8Array): Promise => { + const proc = Bun.spawn(argv, { + cwd: childUserHome, + env: childEnv, + stdin: stdin ? "pipe" : "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (stdin && proc.stdin) { + proc.stdin.write(stdin); + proc.stdin.end(); + } + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { code, stdout, stderr }; + }; + + const runLocalAsSsh = async (remoteCommand: string, stdin?: Uint8Array): Promise => { + let patched = sandboxRemoteSh(remoteCommand, scratch); + // Force `link port` in enrollment to select our bound tunnelPort so ssh -R is simulated in-process + if (patched.includes("'link' 'port'")) { + return { code: 0, stdout: JSON.stringify({ port: tunnelPort }) + "\n", stderr: "" }; + } + return runShellAsync([resolvedSh, "-c", patched], stdin); + }; + + const sshRunner: SshRunner = { + async run(argv, options) { + if (argv[0] === "ssh-keygen") { + return { code: 0, stdout: "256 SHA256:e2efingerprint01 devbox (ED25519)\n", stderr: "" }; + } + const cmd = argv.at(-1) ?? ""; + if (cmd === "echo ocx-link-probe") { + const khArg = argv.find(a => a.startsWith("UserKnownHostsFile="))!; + const khPath = khArg.slice("UserKnownHostsFile=".length).replace(/^"|"$/g, ""); + writeFileSync(khPath, "devbox ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE2E\n"); + return { code: 0, stdout: "ocx-link-probe\n", stderr: "" }; + } + return runLocalAsSsh(cmd, options?.stdin); + }, + spawnTunnel() { throw new Error("unused"); }, + }; + + let issuedCounter = 0; + const knownHostsPath = join(scratch, "home-ocx", "known_hosts"); + const linkState: LinkRouteState = { + pendingHosts: new Map(), + confirmedHosts: new Map(), + operations: new Set(), + listener: { + ensureStarted: async () => {}, + status: () => ({ state: "listening", port: tunnelPort, reason: null }), + close: async () => {}, + onAuthenticatedCatalog: cb => { + catalogCallbacks.add(cb); + return () => { catalogCallbacks.delete(cb); }; + }, + }, + supervisor: { + start() {}, + ensureStarted: async () => {}, + reload: async () => {}, + holdLink: () => () => {}, + stopLink: async () => {}, + reconnect: async () => true, + status: () => readLinkStore(linkStorePath()).links.map(l => ({ + linkId: l.id, + state: { kind: "connected" as const, since: Date.now() }, + })), + stop: async () => {}, + }, + }; + + const mgmtCall = async (method: string, path: string, body?: unknown): Promise => { + const url = new URL(`http://127.0.0.1:10100${path}`); + const ctx: ManagementContext = { + req: new Request(url, { + method, + ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), + }), + url, + config: homeConfig, + deps: { + sshRunner, + readLinkStore: () => readLinkStore(linkStorePath()), + writeLinkStore: next => writeLinkStore(linkStorePath(), next), + linkKnownHostsPath: () => knownHostsPath, + liveListenPort: () => 10100, + issueApiKey: (cfg, name) => { + const key = { + id: `link-e2e-${++issuedCounter}`, + name, + key: `ocx_data_${String(issuedCounter).padStart(40, "0")}`, + createdAt: new Date().toISOString(), + }; + cfg.apiKeys = [...(cfg.apiKeys ?? []), key]; + return key; + }, + revokeApiKey: (cfg, id) => { + cfg.apiKeys = (cfg.apiKeys ?? []).filter(k => k.id !== id); + return true; + }, + }, + version: "2.98.0", + principal: "admin-token", + trustedLoopbackIngress: true, + guiSessionIssuance: null, + sessionControl: { isCurrent: () => true, isPaired: () => false, revokeCurrent: () => true }, + convergeCodexCatalog: async () => { throw new Error("unused"); }, + syncClaudeAgentDefsBestEffort: async () => {}, + }; + const res = await handleLinkRoutes(ctx, linkState); + if (!res) throw new Error("unhandled management route"); + return res; + }; + + try { + // Step A: Probe -> confirm-host -> apply on clean detached ocx-child + const probeRes = await mgmtCall("POST", "/api/link/probe", { alias: "devbox", includeVersion: true }); + expect(probeRes.status).toBe(200); + const probeBody = await probeRes.json() as { fingerprint: string; ocxVersion: string }; + expect(probeBody.ocxVersion).toBe("2.98.0"); + + const confirmRes = await mgmtCall("POST", "/api/link/confirm-host", { alias: "devbox", fingerprint: probeBody.fingerprint }); + expect(confirmRes.status).toBe(200); + + const applyRes = await mgmtCall("POST", "/api/link/apply", { alias: "devbox" }); + expect(applyRes.status).toBe(202); + const { linkId } = await applyRes.json() as { linkId: string }; + expect(linkId).toMatch(/^lnk_[0-9a-f]{16}$/); + + // Step B: Stream SSE POST /v1/responses through ocx-child with caller secret header + const sseRes = await fetch(`http://127.0.0.1:${childListenPort}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + "authorization": "Bearer caller-secret-short", + "x-api-key": "caller-ant-key", + }, + body: JSON.stringify({ model: "home-initial-model", input: "hi" }), + }); + expect(sseRes.status).toBe(200); + const sseText = await sseRes.text(); + expect(sseText).toContain("event: response.created"); + expect(sseText).toContain("event: response.completed"); + const lastUpHdr = observedUpstreamHeaders.at(-1)!; + expect(lastUpHdr["x-api-key"]).toBeUndefined(); + expect(lastUpHdr["authorization"]).not.toContain("caller-secret-short"); + + // Step C: Mutate Home catalog & settings, then run Home ocx sync fan-out! + catalogModels = [{ slug: "home-fanout-model-v2" }]; + homeConfig.fastMode = true; + homeConfig.webSearchSidecar = { enabled: false } as OcxConfig["webSearchSidecar"]; + const expectedDigest = codexSettingsDigest(projectHomeCodexSettings(homeConfig, "2.98.0").settings); + + const statusBeforeSync = await (await mgmtCall("GET", "/api/link/status")).json(); + const fanoutDoc = await runHomeFanout({ + home: { state: "synced", exit: 0 }, + catalogCurrent: true, + applicable: true, + readStore: () => readLinkStore(linkStorePath()), + readLinkStatus: async () => statusBeforeSync, + runChild: async link => { + const pinned = remotePinnedSyncArgv(link.id, "posix"); + pinned[2] = sandboxRemoteSh(pinned[2]!, scratch); + const out = await runShellAsync([...pinned]); + return { ...classifySyncResult(out), exit: out.code }; + }, + }); + expect(fanoutDoc.fanout.state).toBe("ran"); + expect(fanoutDoc.children).toEqual([ + { linkId, alias: "devbox", state: "synced", reason: null, exit: 0 }, + ]); + + // Verify Child models_cache.json updated & Home GET /api/link/status reflects OCX-6 parity! + const childCache = JSON.parse(readFileSync(join(childCodexHome, "models_cache.json"), "utf8")); + expect(childCache.models.map((m: { slug: string }) => m.slug)).toContain("home-fanout-model-v2"); + + const statusAfterSync = await (await mgmtCall("GET", "/api/link/status")).json() as { + links: Array<{ id: string; lastApplyAt: number | null; runtimeStartedAt: number | null; appliedCodexSettingsDigest: string | null }>; + }; + const linkRow = statusAfterSync.links.find(l => l.id === linkId)!; + expect(linkRow.appliedCodexSettingsDigest).toBe(expectedDigest); + expect(linkRow.lastApplyAt).not.toBeNull(); + expect(linkRow.runtimeStartedAt).not.toBeNull(); + expect(linkRow.runtimeStartedAt!).toBeGreaterThanOrEqual(linkRow.lastApplyAt!); + + // Also verify symlinked `ocx status --json` works identically on Child + const symStatus = Bun.spawnSync([join(symlinkDir, "ocx"), "status", "--json"], { + env: childEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(symStatus.exitCode).toBe(0); + const parsedChildStatus = JSON.parse(symStatus.stdout.toString()); + expect(parsedChildStatus.appliedCodexSettingsDigest).toBe(expectedDigest); + expect(parsedChildStatus.proxy.running).toBe(true); + + // Step D: Wrong --expect-link exits 5 and classifies as child-link-mismatch:other_link + const wrongPinned = remotePinnedSyncArgv("lnk_ffffffffffffffff", "posix"); + wrongPinned[2] = sandboxRemoteSh(wrongPinned[2]!, scratch); + const wrongOut = await runShellAsync([...wrongPinned]); + expect(wrongOut.code).toBe(5); + expect(classifySyncResult(wrongOut)).toEqual({ state: "skipped", reason: "child-link-mismatch:other_link" }); + + // Step E: Simulate #1798 Codex App comment-stripping rewrite on Child, then Home DELETE /api/link/:id! + const preDetachCfg = readFileSync(join(childCodexHome, "config.toml"), "utf8"); + const stripped1798 = preDetachCfg + .split("\n") + .filter(l => !l.trim().startsWith("#")) + .join("\n") + "\n[projects.\"/tmp/demo\"]\ntrust_level = \"trusted\"\n"; + writeFileSync(join(childCodexHome, "config.toml"), stripped1798); + + const delRes = await mgmtCall("DELETE", `/api/link/${linkId}`); + expect(delRes.status).toBe(200); + + // Verify zero leftover daemon (port closed) and byte-accurate surgical restoration + let portReachable = false; + try { + await fetch(`http://127.0.0.1:${childListenPort}/healthz`, { signal: AbortSignal.timeout(300) }); + portReachable = true; + } catch { portReachable = false; } + expect(portReachable).toBe(false); + + const finalToml = readFileSync(join(childCodexHome, "config.toml"), "utf8"); + expect(finalToml).not.toContain("openai_base_url"); + expect(finalToml).not.toContain("opencodex-catalog.json"); + expect(finalToml).toContain('web_search = "live"'); + expect(finalToml).toContain('approval_policy = "on-request"'); + expect(finalToml).toContain('[projects."/tmp/demo"]'); + expect(existsSync(join(childCodexHome, "opencodex-catalog.json"))).toBe(false); + expect(existsSync(join(childCodexHome, "models_cache.json"))).toBe(false); + } finally { + // Ensure no child daemon survives even if an assertion failed + await runShellAsync([installedChildBin, "disconnect"]); + relaySessions.abortReservations(); + await homeHubLinkServer.stop(true); + } + }, 30_000); +}); diff --git a/tests/fixtures/link/ocx-child-conformance.json b/tests/fixtures/link/ocx-child-conformance.json new file mode 100644 index 00000000000..1b03996f82a --- /dev/null +++ b/tests/fixtures/link/ocx-child-conformance.json @@ -0,0 +1,1101 @@ +{ + "schema": "ocx.child.conformance/1", + "routeCases": [ + { + "id": "post_data_1", + "method": "POST", + "path": "/v1/responses", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_2", + "method": "POST", + "path": "/v1/responses/compact", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_3", + "method": "POST", + "path": "/v1/messages", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_4", + "method": "POST", + "path": "/v1/messages/count_tokens", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_5", + "method": "POST", + "path": "/v1/chat/completions", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_6", + "method": "POST", + "path": "/v1/audio/transcriptions", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_7", + "method": "POST", + "path": "/v1/alpha/search", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_8", + "method": "POST", + "path": "/v1/images/generations", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_9", + "method": "POST", + "path": "/v1/images/edits", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_10", + "method": "POST", + "path": "/v1/live", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_11", + "method": "POST", + "path": "/v1/realtime/calls", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_12", + "method": "POST", + "path": "/v1/alpha/history/v2/list_windows", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_13", + "method": "POST", + "path": "/v1/alpha/history/v2/list_items", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_14", + "method": "POST", + "path": "/v1/alpha/history/v2/read_item", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_15", + "method": "POST", + "path": "/v1/alpha/history/v2/search_contents", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_16", + "method": "POST", + "path": "/v1/alpha/notes/v2/thread_hint", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_17", + "method": "POST", + "path": "/v1/alpha/notes/v2/list_files_by_prefix", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_18", + "method": "POST", + "path": "/v1/alpha/notes/v2/read_file", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_19", + "method": "POST", + "path": "/v1/alpha/notes/v2/search_contents", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_20", + "method": "POST", + "path": "/v1/alpha/notes/v2/append_to_file", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "post_data_21", + "method": "POST", + "path": "/v1/alpha/notes/v2/write_file", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "get_catalog", + "method": "GET", + "path": "/v1/catalog", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "head_catalog", + "method": "HEAD", + "path": "/v1/catalog", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "get_hub_state", + "method": "GET", + "path": "/v1/hub-state", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "head_hub_state", + "method": "HEAD", + "path": "/v1/hub-state", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "get_models", + "method": "GET", + "path": "/v1/models", + "query": "", + "hostHeader": "localhost:10100", + "originHeader": "http://localhost:3000", + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "get_artifact", + "method": "GET", + "path": "/v1/opencodex/artifacts/art-42", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "bearer" + }, + { + "id": "get_usage_apikey", + "method": "GET", + "path": "/v1/usage", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_data", + "expectedAuthHeader": "x-opencodex-api-key" + }, + { + "id": "get_mgmt_codex_settings", + "method": "GET", + "path": "/v1/link-management/codex-settings", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_management", + "expectedAuthHeader": "bearer" + }, + { + "id": "reject_mgmt_codex_settings_query", + "method": "GET", + "path": "/v1/link-management/codex-settings", + "query": "?extra=1", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "reject_404", + "expectedAuthHeader": "none" + }, + { + "id": "get_mgmt_api_settings", + "method": "GET", + "path": "/v1/link-management/api/settings", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_management", + "expectedAuthHeader": "bearer" + }, + { + "id": "head_mgmt_api_providers", + "method": "HEAD", + "path": "/v1/link-management/api/providers", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "relay_management", + "expectedAuthHeader": "bearer" + }, + { + "id": "reject_mgmt_forbidden_refresh_query", + "method": "GET", + "path": "/v1/link-management/api/provider-quotas", + "query": "?refresh=1", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "reject_404", + "expectedAuthHeader": "none" + }, + { + "id": "local_healthz", + "method": "GET", + "path": "/healthz", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "local_healthz", + "expectedAuthHeader": "none" + }, + { + "id": "local_readyz", + "method": "GET", + "path": "/readyz", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "local_readyz", + "expectedAuthHeader": "none" + }, + { + "id": "reject_head_readyz", + "method": "HEAD", + "path": "/readyz", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "reject_404", + "expectedAuthHeader": "none" + }, + { + "id": "ws_upgrade_responses_426", + "method": "GET", + "path": "/v1/responses", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": "http://127.0.0.1:5173", + "upgradeHeader": "websocket", + "expectedAction": "reject_426_websocket", + "expectedAuthHeader": "none" + }, + { + "id": "ws_upgrade_cross_origin_403", + "method": "GET", + "path": "/v1/responses", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": "https://evil.example", + "upgradeHeader": "websocket", + "expectedAction": "reject_403_origin", + "expectedAuthHeader": "none" + }, + { + "id": "dns_rebind_host_403", + "method": "POST", + "path": "/v1/responses", + "query": "", + "hostHeader": "evil.example:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "reject_403_origin", + "expectedAuthHeader": "none" + }, + { + "id": "cross_origin_post_403", + "method": "POST", + "path": "/v1/responses", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": "https://evil.example", + "upgradeHeader": null, + "expectedAction": "reject_403_origin", + "expectedAuthHeader": "none" + }, + { + "id": "h2c_upgrade_catalog_404", + "method": "GET", + "path": "/v1/catalog", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": "h2c", + "expectedAction": "reject_404", + "expectedAuthHeader": "none" + }, + { + "id": "reject_admin_api_config", + "method": "GET", + "path": "/api/config", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "reject_404", + "expectedAuthHeader": "none" + }, + { + "id": "reject_delete_responses", + "method": "DELETE", + "path": "/v1/responses", + "query": "", + "hostHeader": "127.0.0.1:10100", + "originHeader": null, + "upgradeHeader": null, + "expectedAction": "reject_404", + "expectedAuthHeader": "none" + } + ], + "framingCases": [ + { + "id": "strip_caller_credentials_and_connection_nominated", + "rawHeaders": [ + [ + "Host", + "127.0.0.1:10100" + ], + [ + "Content-Length", + "42" + ], + [ + "Authorization", + "Bearer caller-tok-1" + ], + [ + "api-key", + "az-caller-1" + ], + [ + "x-api-key", + "ant-caller-1" + ], + [ + "x-goog-api-key", + "goog-caller-1" + ], + [ + "x-opencodex-api-key", + "ocx-caller-1" + ], + [ + "chatgpt-account-id", + "acct-local-1" + ], + [ + "cookie", + "sid=local1" + ], + [ + "Connection", + "keep-alive, x-drop-hop" + ], + [ + "Keep-Alive", + "timeout=5" + ], + [ + "x-drop-hop", + "secret-hop" + ], + [ + "x-trace-id", + "trace-keep-1" + ], + [ + "Content-Type", + "application/json" + ] + ], + "expectedOutcome": "allow", + "expectedSurvivedHeaders": [ + "content-type", + "x-trace-id" + ] + }, + { + "id": "allow_lone_transfer_encoding_chunked", + "rawHeaders": [ + [ + "Host", + "127.0.0.1:10100" + ], + [ + "Transfer-Encoding", + "chunked" + ], + [ + "Content-Type", + "application/json" + ] + ], + "expectedOutcome": "allow", + "expectedSurvivedHeaders": [ + "content-type" + ] + }, + { + "id": "reject_cl_and_te_smuggling", + "rawHeaders": [ + [ + "Content-Length", + "10" + ], + [ + "Transfer-Encoding", + "chunked" + ] + ], + "expectedOutcome": "reject_400_smuggling", + "expectedSurvivedHeaders": [] + }, + { + "id": "reject_duplicate_content_length", + "rawHeaders": [ + [ + "Content-Length", + "10" + ], + [ + "Content-Length", + "10" + ] + ], + "expectedOutcome": "reject_400_smuggling", + "expectedSurvivedHeaders": [] + }, + { + "id": "reject_conflicting_content_length", + "rawHeaders": [ + [ + "Content-Length", + "10" + ], + [ + "Content-Length", + "20" + ] + ], + "expectedOutcome": "reject_400_smuggling", + "expectedSurvivedHeaders": [] + }, + { + "id": "reject_signed_content_length", + "rawHeaders": [ + [ + "Content-Length", + "+10" + ] + ], + "expectedOutcome": "reject_400_smuggling", + "expectedSurvivedHeaders": [] + }, + { + "id": "reject_non_digit_content_length", + "rawHeaders": [ + [ + "Content-Length", + "10abc" + ] + ], + "expectedOutcome": "reject_400_smuggling", + "expectedSurvivedHeaders": [] + }, + { + "id": "reject_non_chunked_transfer_encoding", + "rawHeaders": [ + [ + "Transfer-Encoding", + "gzip, chunked" + ] + ], + "expectedOutcome": "reject_400_smuggling", + "expectedSurvivedHeaders": [] + }, + { + "id": "reject_upgrade_in_relay_headers", + "rawHeaders": [ + [ + "Upgrade", + "websocket" + ] + ], + "expectedOutcome": "reject_400_smuggling", + "expectedSurvivedHeaders": [] + }, + { + "id": "reject_crlf_in_header_value", + "rawHeaders": [ + [ + "x-bad", + "line1\r\nInjected: 1" + ] + ], + "expectedOutcome": "reject_400_invalid", + "expectedSurvivedHeaders": [] + } + ], + "relayAuthCases": [ + { + "id": "relay_v2_vector_1", + "rawToken": "ocx_data_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "keyId": "link:devbox-alpha", + "linkId": "lnk_0123456789abcdef", + "nonce": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "generation": "550e8400-e29b-41d4-a716-446655440000:1", + "tokenSha256Hex": "90612638dc4cc370fcf5224aeae4c1954fb177e8a91452af9b5670280e7a05d0", + "expectedCallerProof": "e715bbfd7169a7219ba0e154b75f9d080d61fe21f8b37839e0adfb9638e14b04", + "expectedListenerProof": "769575b4755f64c75e76858519251c9e29ed7aa930b8cd1bd9fa772106aedfb6", + "expectedConnectionProof": "89399fa6986dbeb4c00707d6461bc565782a6fa0ee79332570d06681c1c812c3" + }, + { + "id": "relay_v2_vector_2", + "rawToken": "ocx_data_0101010101010101010101010101010101010101", + "keyId": "link_key_beta.2", + "linkId": "lnk_fedcba9876543210", + "nonce": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "generation": "11111111-2222-3333-4444-555555555555:42", + "tokenSha256Hex": "a334ba709ddce0a8d87d24dc0d76ad8d159c0e2196068da656a7c159f3bb3c77", + "expectedCallerProof": "fa55dee78a5f49afcbead306a0b5132776ff967a88765ddca82488524c0e31c7", + "expectedListenerProof": "1740abe2719550f4fbb31b6122c5b23c3b1500f793a6de1905678a6449258437", + "expectedConnectionProof": "a4cd6208cb5503399059ee368f9d892b834ee28b5c5c675f8383a81acda2ef6d" + }, + { + "id": "relay_v2_vector_3", + "rawToken": "ocx_data_f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", + "keyId": "k9:ssh-vm", + "linkId": "lnk_0011223344556677", + "nonce": "ffffffffffffffffffffffffffffffff00000000000000001234567890abcdef", + "generation": "inst-abc:7", + "tokenSha256Hex": "8562ae8aa27c82c019f7c4424c13f545dea70395b48c88077079a63f7e40b8bb", + "expectedCallerProof": "aa5853fbd0c3bce1456acebb4b9a91b8a7d8dd5eec127b985fcb80a895f9676d", + "expectedListenerProof": "191ebb722270c590f5e5b05cf0fdbb2e93104c124fe4661f0edd51d5830eb271", + "expectedConnectionProof": "6372865c64d3066454cc10d6857c4b345434887a87d836f8cad1f25b436b2b1e" + } + ], + "settingsDigestCases": [ + { + "id": "all_null_defaults", + "settings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "expectedCanonicalJson": "{\"version\":1,\"settings\":{\"fastMode\":null,\"multiAgentMode\":null,\"webSearchSidecarEnabled\":true,\"subagentDefaults\":null}}", + "expectedDigestHex": "63f95e6f2b493fce3a38d244d8af53579ef1b90539fa01babdec3f9782fa69e5" + }, + { + "id": "full_v1_sidecar_off", + "settings": { + "fastMode": true, + "multiAgentMode": "v1", + "webSearchSidecarEnabled": false, + "subagentDefaults": { + "model": "gpt-5.6-sol", + "reasoningEffort": "high" + } + }, + "expectedCanonicalJson": "{\"version\":1,\"settings\":{\"fastMode\":true,\"multiAgentMode\":\"v1\",\"webSearchSidecarEnabled\":false,\"subagentDefaults\":{\"model\":\"gpt-5.6-sol\",\"reasoningEffort\":\"high\"}}}", + "expectedDigestHex": "d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8" + }, + { + "id": "v2_null_effort", + "settings": { + "fastMode": false, + "multiAgentMode": "v2", + "webSearchSidecarEnabled": true, + "subagentDefaults": { + "model": "anthropic/claude-sonnet-4-5", + "reasoningEffort": null + } + }, + "expectedCanonicalJson": "{\"version\":1,\"settings\":{\"fastMode\":false,\"multiAgentMode\":\"v2\",\"webSearchSidecarEnabled\":true,\"subagentDefaults\":{\"model\":\"anthropic/claude-sonnet-4-5\",\"reasoningEffort\":null}}}", + "expectedDigestHex": "9aa4434019e0e0e524defe6d89fad38b32f1a0fc828ba96ac428677abfa747ed" + }, + { + "id": "default_mode_no_subagent", + "settings": { + "fastMode": true, + "multiAgentMode": "default", + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "expectedCanonicalJson": "{\"version\":1,\"settings\":{\"fastMode\":true,\"multiAgentMode\":\"default\",\"webSearchSidecarEnabled\":true,\"subagentDefaults\":null}}", + "expectedDigestHex": "15516a1f107ce987a280776c2adc768543a54ac361a38f1d0c7f00fc9fdb92f3" + } + ], + "childParityCases": [ + { + "id": "valid_pre_apply_nulls", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":null,\"runtimeStartedAt\":1760000005000,\"appliedCodexSettingsDigest\":null,\"sequence\":0}", + "expectedValid": true + }, + { + "id": "valid_post_apply", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":1760000001000,\"runtimeStartedAt\":1760000005000,\"appliedCodexSettingsDigest\":\"d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8\",\"sequence\":3}", + "expectedValid": true + }, + { + "id": "reject_mismatched_null_digest", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":null,\"runtimeStartedAt\":1760000005000,\"appliedCodexSettingsDigest\":\"d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8\",\"sequence\":1}", + "expectedValid": false + }, + { + "id": "reject_extra_property", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":1760000001000,\"runtimeStartedAt\":1760000005000,\"appliedCodexSettingsDigest\":\"d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8\",\"sequence\":1,\"extra\":true}", + "expectedValid": false + }, + { + "id": "reject_out_of_range_timestamp", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":999,\"runtimeStartedAt\":1760000005000,\"appliedCodexSettingsDigest\":\"d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8\",\"sequence\":1}", + "expectedValid": false + }, + { + "id": "reject_negative_sequence", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":1760000001000,\"runtimeStartedAt\":1760000005000,\"appliedCodexSettingsDigest\":\"d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8\",\"sequence\":-1}", + "expectedValid": false + }, + { + "id": "valid_integral_decimal_and_exponent", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":1760000001000.0,\"runtimeStartedAt\":1.760000005e12,\"appliedCodexSettingsDigest\":\"d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8\",\"sequence\":3.0}", + "expectedValid": true + }, + { + "id": "reject_fractional_sequence", + "rawJson": "{\"linkId\":\"lnk_0123456789abcdef\",\"lastApplyAt\":1760000001000,\"runtimeStartedAt\":1760000005000,\"appliedCodexSettingsDigest\":\"d2105d1753e9a54d9abba4e3f9cd9863fb79aa3455cfbaec161bb212cae46ce8\",\"sequence\":3.5}", + "expectedValid": false + } + ], + "journalRoundtripCases": [ + { + "id": "clean_absent_config_and_catalog", + "preAttachToml": null, + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "none" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "exact_restore", + "expectedFinalToml": null, + "expectedFinalCatalog": null + }, + { + "id": "preserve_comments_crlf_across_sync", + "preAttachToml": "# User header comment\r\nmodel = \"gpt-5.6\"\r\n\r\n[mcp_servers.git]\r\ncommand = \"git-mcp\"\r\n", + "preAttachCatalog": "{\"models\":[{\"slug\":\"prior-local\"}]}\n", + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": { + "fastMode": true, + "multiAgentMode": "v1", + "webSearchSidecarEnabled": false, + "subagentDefaults": { + "model": "gpt-5.6-sol", + "reasoningEffort": "high" + } + }, + "midSessionMutation": { + "kind": "none" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "exact_restore", + "expectedFinalToml": "# User header comment\r\nmodel = \"gpt-5.6\"\r\n\r\n[mcp_servers.git]\r\ncommand = \"git-mcp\"\r\n", + "expectedFinalCatalog": "{\"models\":[{\"slug\":\"prior-local\"}]}\n" + }, + { + "id": "app_rewrite_1798_comment_strip", + "preAttachToml": "approval_policy = \"on-request\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": true, + "multiAgentMode": "v2", + "webSearchSidecarEnabled": true, + "subagentDefaults": { + "model": "gpt-5.6-sol", + "reasoningEffort": "high" + } + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "strip_comments_and_append_table", + "appendedToml": "\n[projects.\"/work/app\"]\ntrust_level = \"trusted\"\n" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "approval_policy = \"on-request\"\n\n[projects.\"/work/app\"]\ntrust_level = \"trusted\"\n", + "expectedFinalCatalog": null + }, + { + "id": "web_search_disabled_and_restored_after_1798", + "preAttachToml": "web_search = \"live\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": false, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "strip_comments_and_prepend_root", + "prependedRootLine": "approval_policy = \"never\"\n" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "approval_policy = \"never\"\nweb_search = \"live\"\n", + "expectedFinalCatalog": null + }, + { + "id": "user_modified_base_url_preserved_on_detach", + "preAttachToml": "approval_policy = \"on-request\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "replace_root_base_url", + "newBaseUrl": "https://gateway.example.com/v1" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "approval_policy = \"on-request\"\nopenai_base_url = \"https://gateway.example.com/v1\"\n", + "expectedFinalCatalog": null + }, + { + "id": "corrupt_managed_span_refused", + "preAttachToml": "approval_policy = \"on-request\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "corrupt_managed_span", + "rawOverrideToml": "approval_policy = \"on-request\"\nopenai_base_url = \"http://127.0.0.1:10100/v1\n" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "refuse_conflict", + "expectedFinalToml": "approval_policy = \"on-request\"\nopenai_base_url = \"http://127.0.0.1:10100/v1\n", + "expectedFinalCatalog": "{\"models\":[{\"slug\":\"home-m1\"}]}\n" + }, + { + "id": "tampered_catalog_refused", + "preAttachToml": "approval_policy = \"on-request\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "tamper_catalog", + "tamperedCatalog": "{\"models\":[{\"slug\":\"hand-edited\"}]}\n" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "refuse_conflict", + "expectedFinalToml": null, + "expectedFinalCatalog": "{\"models\":[{\"slug\":\"hand-edited\"}]}\n" + }, + { + "id": "refuse_external_model_provider_on_attach", + "preAttachToml": "model_provider = \"openrouter\"\n\n[model_providers.openrouter]\nname = \"OpenRouter\"\nbase_url = \"https://openrouter.ai/api/v1\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "none" + }, + "expectedAttachOk": false, + "expectedDetachOutcome": "not_attached", + "expectedFinalToml": "model_provider = \"openrouter\"\n\n[model_providers.openrouter]\nname = \"OpenRouter\"\nbase_url = \"https://openrouter.ai/api/v1\"\n", + "expectedFinalCatalog": null + }, + { + "id": "multiline_developer_instructions_bracket_line", + "preAttachToml": "developer_instructions = \"\"\"\n[Important]\nKeep literal\n\"\"\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "strip_comments_and_append_table", + "appendedToml": "\n[projects.\"/work/app\"]\ntrust_level = \"trusted\"\n" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "developer_instructions = \"\"\"\n[Important]\nKeep literal\n\"\"\"\n\n[projects.\"/work/app\"]\ntrust_level = \"trusted\"\n", + "expectedFinalCatalog": null + }, + { + "id": "restore_overwritten_user_features_on_surgical_detach", + "preAttachToml": "[features]\nfast_mode = false\nmulti_agent_v2 = true\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": true, + "multiAgentMode": "v1", + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "strip_comments_and_append_table", + "appendedToml": "\n[extra]\nk = 1\n" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "[features]\nfast_mode = false\nmulti_agent_v2 = true\n\n[extra]\nk = 1\n", + "expectedFinalCatalog": null + }, + { + "id": "multiline_features_agents_and_blank_lines_surgical", + "preAttachToml": "developer_instructions = \"\"\"\n[features]\nfast_mode = true\n\n\n[agents]\ndefault_subagent_model = \"keep-inside-string\"\n\"\"\"\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": false, + "multiAgentMode": "v2", + "webSearchSidecarEnabled": true, + "subagentDefaults": { + "model": "gpt-5.6-mini", + "reasoningEffort": "low" + } + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "strip_comments_and_append_table", + "appendedToml": "\n[projects.\"/work/app\"]\ntrust_level = \"trusted\"\n" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "developer_instructions = \"\"\"\n[features]\nfast_mode = true\n\n\n[agents]\ndefault_subagent_model = \"keep-inside-string\"\n\"\"\"\n\n[projects.\"/work/app\"]\ntrust_level = \"trusted\"\n", + "expectedFinalCatalog": null + }, + { + "id": "preserve_comment_only_agents_table_on_surgical_detach", + "preAttachToml": "[agents]\n# keep\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": null, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": { + "model": "gpt-5.6-mini", + "reasoningEffort": null + } + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "replace_root_base_url", + "newBaseUrl": "https://custom.gateway.example/v1" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "openai_base_url = \"https://custom.gateway.example/v1\"\n[agents]\n# keep\n", + "expectedFinalCatalog": null + }, + { + "id": "preserve_comment_only_features_table_on_surgical_detach", + "preAttachToml": "[features]\n# keep\n", + "preAttachCatalog": null, + "listenPort": 10100, + "catalogPath": "/tmp/codex-home/opencodex-catalog.json", + "initialSettings": { + "fastMode": true, + "multiAgentMode": null, + "webSearchSidecarEnabled": true, + "subagentDefaults": null + }, + "syncSettings": null, + "midSessionMutation": { + "kind": "replace_root_base_url", + "newBaseUrl": "https://custom.gateway.example/v1" + }, + "expectedAttachOk": true, + "expectedDetachOutcome": "surgical_restore", + "expectedFinalToml": "openai_base_url = \"https://custom.gateway.example/v1\"\n[features]\n# keep\n", + "expectedFinalCatalog": null + } + ] +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dc905cc69bd..ff8ee808e17 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1625,5 +1625,9 @@ "messages-sse-baseline-contract.test.ts": "claude-integration", "messages-sse-control-fields.test.ts": "claude-integration", "messages-sse-continuation.test.ts": "claude-integration", - "sse-payload-rewrite-continuation.test.ts": "responses" + "sse-payload-rewrite-continuation.test.ts": "responses", + "link-ocx-child-conformance.test.ts": "clients", + "link-ephemeral-child-discovery.test.ts": "clients", + "link-enrollment-ephemeral-child.test.ts": "server", + "link-ocx-child-e2e.test.ts": "clients" } diff --git a/tests/server/link-enrollment-ephemeral-child.test.ts b/tests/server/link-enrollment-ephemeral-child.test.ts new file mode 100644 index 00000000000..61101c19e32 --- /dev/null +++ b/tests/server/link-enrollment-ephemeral-child.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import type { LinkStore } from "../../src/link/store"; +import type { SshRunner, SshRunResult } from "../../src/link/ssh-runner"; +import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let previousHome: string | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-ephemeral-enroll-")); + process.env.OPENCODEX_HOME = root; +}); +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(root); +}); + +const fingerprint = "SHA256:abcdefghijklmnop"; +const ok = (stdout = "", code = 0, stderr = ""): SshRunResult => ({ code, stdout, stderr }); +const http200 = (body: unknown) => ok(`${JSON.stringify(body)}\n200`); +type Step = "port" | "config" | "curl" | "before" | "connect" | "restart" | "health" | "catalog" | "disconnect"; + +function buildHarness() { + const remoteOcx = (args: string[]) => quoteRemote(remoteOcxArgv(args, "posix")); + const config: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + runtimeRole: "standalone", + defaultProvider: "mock", + providers: {}, + apiKeys: [], + }; + const knownHosts = join(root, "known_hosts"); + let store: LinkStore = { version: 1, listenerPort: 18181, links: [] }; + const clock = 1_800_000_000_000; + let issued = 0; + let restarted = false; + let enrollmentKeyId = ""; + const events: string[] = []; + const callbacks = new Set<(id: string) => void>(); + const overrides: Partial SshRunResult>> = {}; + + const state: LinkRouteState = { + pendingHosts: new Map(), + confirmedHosts: new Map(), + operations: new Set(), + listener: { + ensureStarted: async () => { events.push("listener"); }, + status: () => ({ state: "listening", port: 18181, reason: null }), + close: async () => { events.push("close"); }, + onAuthenticatedCatalog: cb => { + callbacks.add(cb); + return () => { callbacks.delete(cb); }; + }, + }, + supervisor: { + start() {}, + ensureStarted: async () => { events.push("supervisor"); }, + reload: async () => { events.push("reload"); }, + holdLink: id => { events.push(`hold:${id}`); return () => { events.push(`unhold:${id}`); }; }, + stopLink: async id => { events.push(`stop:${id}`); }, + reconnect: async () => true, + status: () => [], + stop: async () => {}, + }, + }; + + const runner: SshRunner = { + async run(argv, options) { + const command = argv.at(-1) ?? ""; + const step: Step = command === remoteOcx(["link", "port"]) ? "port" + : command === remoteOcx(["config", "show", "--json"]) ? "config" + : command.includes("ocx-curl-preflight") ? "curl" + : command.includes("'connect' '--link' '--key-stdin'") ? "connect" + : command === remoteOcx(["restart"]) ? "restart" + : command === remoteOcx(["disconnect"]) ? "disconnect" + : command.includes("'ocx-child-http' '10100' 'healthz'") ? (restarted ? "health" : "before") + : command.includes("'ocx-child-http' '10100' 'v1/catalog'") ? "catalog" + : (() => { throw new Error(`Unexpected SSH command: ${command}`); })(); + events.push(step); + if (step === "restart") restarted = true; + if (step === "connect") { + const body = JSON.parse(new TextDecoder().decode(options?.stdin as Uint8Array)); + enrollmentKeyId = body.apiKeyId; + } + if (overrides[step]) return overrides[step]!(); + switch (step) { + case "port": return ok('{"port":24500}'); + case "config": return ok('{"port":10100,"runtimeRole":"standalone","ephemeralChild":true}\n'); + case "curl": return ok(); + case "before": return ok("", 7, "curl: (7) Failed to connect to 127.0.0.1 port 10100"); + case "connect": return ok(); + case "restart": return ok(); + case "health": return http200({ service: "opencodex", version: "2.98.0", role: "client", ephemeralChild: true, pid: 7777, port: 10100, startedAt: "2026-11-07T00:00:00.000Z" }); + case "catalog": { + for (const cb of callbacks) cb(enrollmentKeyId); + return http200({ models: [{ slug: "home-m1" }] }); + } + case "disconnect": return ok(); + } + }, + spawnTunnel() { throw new Error("unused"); }, + }; + + const deps: ManagementContext["deps"] = { + now: () => clock, + sshRunner: runner, + readLinkStore: () => store, + writeLinkStore: next => { store = next; events.push("store"); }, + linkKnownHostsPath: () => knownHosts, + liveListenPort: () => 10100, + issueApiKey: (cfg, name) => { + const key = { id: `fixture-key-${++issued}`, name, key: "ocx_data_" + "a".repeat(40), createdAt: "2026-11-07T00:00:00Z" }; + cfg.apiKeys = [...(cfg.apiKeys ?? []), key]; + events.push("issue"); + return key; + }, + revokeApiKey: (cfg, id) => { + events.push(`revoke:${id}`); + cfg.apiKeys = (cfg.apiKeys ?? []).filter(k => k.id !== id); + return true; + }, + }; + + state.confirmedHosts!.set("devbox", { + alias: "devbox", + fingerprint, + keyType: "ED25519", + knownHostLine: "devbox ssh-ed25519 AAAA", + probedAt: clock, + sshLatencyMs: 5, + remotePlatform: "posix", + ocxVersion: "2.98.0", + }); + + async function request(method: string, path: string, body?: unknown) { + const url = new URL(`http://127.0.0.1:10100${path}`); + const req = new Request(url, { + method, + ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), + }); + const ctx: ManagementContext = { + req, + url, + config, + deps, + version: "2.98.0", + principal: "admin-token", + trustedLoopbackIngress: true, + guiSessionIssuance: null, + sessionControl: { isCurrent: () => true, isPaired: () => false, revokeCurrent: () => true }, + convergeCodexCatalog: async () => { throw new Error("unused"); }, + syncClaudeAgentDefsBestEffort: async () => {}, + }; + const resp = await handleLinkRoutes(ctx, state); + if (!resp) throw new Error("unhandled"); + return resp; + } + + return { events, overrides, request, get store() { return store; } }; +} + +describe("Home Remote Link enrollment and removal with ephemeral ocx-child", () => { + test("enrolls a clean detached ocx-child without a pre-running standalone daemon and detaches cleanly on DELETE", async () => { + const h = buildHarness(); + const res = await h.request("POST", "/api/link/apply", { alias: "devbox" }); + expect(res.status).toBe(202); + const body = await res.json() as { linkId: string }; + expect(body.linkId).toMatch(/^lnk_[0-9a-f]{16}$/); + expect(h.store.links).toHaveLength(1); + + const del = await h.request("DELETE", `/api/link/${body.linkId}`); + expect(del.status).toBe(200); + expect(h.events).toContain("disconnect"); + expect(h.store.links).toHaveLength(0); + }); + + test("refuses enrollment with 409 before issuing a key when a foreign listener occupies childPort", async () => { + const h = buildHarness(); + h.overrides.before = () => http200({ service: "other" }); + const res = await h.request("POST", "/api/link/apply", { alias: "devbox" }); + expect(res.status).toBe(409); + const body = await res.json() as { error: { code: string } }; + expect(body.error.code).toBe("child_restart_unsupported"); + expect(h.events).not.toContain("issue"); + }); + + test("refuses enrollment with 502 child_preflight_failed when SSH drops (code 255) during before probe", async () => { + const h = buildHarness(); + h.overrides.before = () => ok("", 255, "Connection reset by peer"); + const res = await h.request("POST", "/api/link/apply", { alias: "devbox" }); + expect(res.status).toBe(502); + const body = await res.json() as { error: { code: string } }; + expect(body.error.code).toBe("child_preflight_failed"); + expect(h.events).not.toContain("issue"); + }); + + test("refuses already-attached ocx-child (runtimeRole: client) before probing loopback", async () => { + const h = buildHarness(); + h.overrides.config = () => ok('{"port":10100,"runtimeRole":"client","ephemeralChild":true}\n'); + const res = await h.request("POST", "/api/link/apply", { alias: "devbox" }); + expect(res.status).toBe(409); + expect(h.events).not.toContain("before"); + expect(h.events).not.toContain("issue"); + }); + + test("emits ephemeralChild-specific detach recovery guidance on post-connect failure without telling operator to run ocx restart", async () => { + const h = buildHarness(); + h.overrides.restart = () => ok("", 1, "bind error"); + const res = await h.request("POST", "/api/link/apply", { alias: "devbox" }); + expect(res.status).toBe(502); + const body = await res.json() as { residual: boolean; recovery: string }; + expect(body.residual).toBe(true); + expect(body.recovery).toBe("Run ocx-child detach (or ocx disconnect) on the Child before retrying."); + expect(body.recovery).not.toContain("ocx restart"); + }); +});