From a5c605fafcd297c322d12e11fe9c9bedc0eeab6c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 01:10:26 +0900 Subject: [PATCH 01/34] docs(devlog): add docs-first roadmap and PRDs for ocx-child ephemeral link --- .../000_roadmap.md | 140 ++++++++++++++++++ .../001_audit_and_status.md | 26 ++++ .../010_wp1_conformance_contract.md | 122 +++++++++++++++ .../020_wp2_rust_ocx_child.md | 122 +++++++++++++++ .../030_wp3_home_link_integration.md | 123 +++++++++++++++ .../040_wp4_docs_gates_and_pr.md | 50 +++++++ 6 files changed, 583 insertions(+) create mode 100644 devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md create mode 100644 devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md create mode 100644 devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md create mode 100644 devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md create mode 100644 devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md create mode 100644 devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md 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..d9c21c9193c --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md @@ -0,0 +1,140 @@ +# 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. [Threat Model & Security Controls (`SEC-THREAT-01`)](#2-threat-model--security-controls-sec-threat-01) +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. [Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`)](#6-early-warning--boundary-guard-disclosure-plan-bypass-named-01) +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. Threat Model & Security Controls (`SEC-THREAT-01`) + +1. **Assets**: + - Per-Child Home link API key (`ocx_data_<40hex>`) and its SHA-256 fingerprint. + - Child local caller credentials sent by Codex CLI/App or Claude Code (`Authorization`, `api-key`, `x-api-key`, `x-goog-api-key`, `x-opencodex-api-key`, `chatgpt-account-id`, `cookie`). + - User's native `~/.codex/config.toml`, `opencodex-catalog.json`, and `models_cache.json`. + - Home loopback `hub-link` listener and SSH reverse tunnel (`127.0.0.1:`). +2. **Entrypoints & Trust Boundaries**: + - Local Child callers -> `127.0.0.1:` (`ocx-child serve` HTTP/1.1 ingress). + - `ocx-child` -> `127.0.0.1:` (reverse SSH tunnel into Home `hub-link` listener; untrusted until `link-relay-auth` v2 listener proof verifies on the same TCP socket). + - Home SSH orchestrator -> Child shell (`remoteOcxArgv` / `remotePinnedSyncArgv` over pinned `known_hosts` with `StrictHostKeyChecking=yes` and `BatchMode=yes`). + - Child filesystem (`$OPENCODEX_HOME/child/*` and `$CODEX_HOME/*` across concurrent processes and Codex Desktop App `#1798` comment-stripping rewrites). +3. **Attacker Capabilities**: + - Unprivileged local process or browser page probing non-loopback interfaces or sending cross-origin / WebSocket `Upgrade` / `Content-Length` + `Transfer-Encoding` smuggling payloads. + - Spoofed local tunnel listener on `` attempting to harvest the Child's `ocx_data_*` key without knowing its SHA-256 fingerprint. + - Concurrent `ocx sync` / `detach` invocations or post-attach user/app edits to `config.toml`. +4. **Controls**: + - **Loopback-only bind**: `ocx-child serve` binds strictly to `127.0.0.1:` (refusing `0.0.0.0` or `::`). + - **Default-deny route allowlist & framing**: Exact `linkRouteAllowed` + `linkManagementRouteAllowed` match; reject any `Upgrade` (`426` for `/v1/responses` WebSocket upgrade with Origin check, `404`/`400` elsewhere), reject `CL+TE`, duplicate/non-digit `CL`, non-`chunked` `TE`, CR/LF in headers, headers > 64 KiB (`431`), and request/non-SSE response bodies > 256 MiB (`413`/`502`). + - **Caller credential stripping**: Strip all `CALLER_CREDENTIAL_HEADERS`, hop-by-hop headers, and `Connection`-nominated headers before forwarding. + - **Same-socket HMAC-SHA256 v2 mutual authentication**: Derive 32-byte raw key `SHA-256(token)`, send credential-free `GET /.well-known/opencodex/link-relay-auth?version=2&...` on a fresh `TcpStream`, verify `204` + 0-byte body + constant-time `x-opencodex-link-proof` (and optional `x-opencodex-link-connection-proof`), then send the single data request with `x-opencodex-link-session: ` and link key on that **exact `TcpStream`** (never pooling or silently reconnecting). + - **Secret & journal hygiene**: Read `{apiKeyId, key}` from `stdin` (4 KiB cap) and volatile-zero buffer immediately; create `$OPENCODEX_HOME/child` (`0700`) and `link-key` / `codex-journal.json` / `state.json` (`0600` at open on Unix via `OpenOptionsExt::mode(0o600)`, atomic temp-write + `fsync` + `rename`); never log keys, headers, URLs, or bodies. + - **SSH argv & Windows Job containment**: Reuse `quoteRemote` single-quoting on POSIX and deflated base64 JSON data + `OCX_JOB_SOURCE` kill-on-close Job (`<= 8000` UTF-16LE base64 chars) on Windows. + +## 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` §2–§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/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/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. Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`) + +| Mechanism | Tier | Executing Surface | Known Bypass Path | Residual Risk | Wording Downgrade & Final Layer | +|---|---|---|---|---|---| +| Fail-closed full-`ocx` / external-provider check on `ocx-child attach` | E2 (runtime check) | `native/ocx-child/src/journal.rs` under `lifecycle.lock` + `.opencodex-write.lock` | A privileged local user manually deleting `runtime-port.json` or editing `~/.codex/config.toml` out-of-band without holding `.opencodex-write.lock` | Local user already owns `~/.codex`; out-of-band manual edits caught later by `DetachConflictError` | Called "preflight guard" (not unbypassable sandbox); `final layer: OS user account boundary + Tier-2 value-matched detach` | +| Detached `ocx-child` port-free check during Home `POST /api/link/apply` | E2 (runtime check) | `src/server/management/link-enrollment.ts` (`remoteChildHttpArgv` before `connect`) | Narrow TOCTOU between SSH port probe and `ocx-child restart` binding `127.0.0.1:` | `ocx-child serve` `TcpListener::bind` fails closed if another process grabs the port in between, and Home `child_relay` authenticated `/v1/catalog` callback refuses enrollment unless Home receives its own issued key | Called "preflight check"; `final layer: TcpListener::bind + Home onAuthenticatedCatalog(issued.id)` | +| Shared TS/Rust golden conformance suite | E4 (CI/test gate) | `bun test tests/clients/ocx-child-conformance.test.ts` and `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` | Fast Ubuntu PR lane runs `test changed` (Bun) unless Rust tests also run; mitigated by running `cargo test` in E2E/verification and `ci-workflows` version/conformance checks | Behavioral bugs outside the covered vector classes require real-binary E2E tests (`ocx-child-e2e.test.ts`) | Called "conformance regression suite"; `final layer: none (human + CI review)` | + +## 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..aeea77ebe4e --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md @@ -0,0 +1,26 @@ +# `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` | Pending | Shared golden conformance fixture (`ocx-child-conformance.json`), `src/link/ephemeral-child.ts`, and `ocx-child-conformance.test.ts` | +| `wp2` | Pending | Standalone Rust crate `native/ocx-child` (HTTP/1.1+SSE relay, HMAC v2, OCX-6 parity, `#1798` offline journal) | +| `wp3` | Pending | Home discovery, detached enrollment preflight, `package.json` scripts, and compiled-binary E2E lifecycle tests | +| `wp4` | Pending | `structure/` + `docs-site/` sync, full gate matrix, branch push, and unmerged PR targeting `dev` | 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..724dd82c2db --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md @@ -0,0 +1,122 @@ +# `wp1` — Shared Golden Conformance Vectors & Ephemeral Child Contract Helpers + +- **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`), `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/ocx-child-conformance.test.ts` (NEW), `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` -> `reject_426_websocket`; `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/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 `"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/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..e9e517719f0 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md @@ -0,0 +1,122 @@ +# `wp2` — Standalone Lightweight Rust Child Crate (`native/ocx-child`) + +- **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 = "2.98.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`). + - `OCX_COMPAT_VERSION: &str = env!("CARGO_PKG_VERSION")` (`2.98.0`, matching `package.json`, `>= 2.93.0`). +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..8db6211235c --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md @@ -0,0 +1,123 @@ +# `wp3` — Home-Side Ephemeral Child Discovery, Enrollment, Sync Fan-Out, & E2E Integration + +- **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 `native/ocx-child/Cargo.toml` == `package.json` version lock. +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/ocx-child-e2e.test.ts` (`"clients"`): 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/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..1161057df4b --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md @@ -0,0 +1,50 @@ +# `wp4` — Source-of-Truth Documentation, Full Verification Matrix, & PR Publication + +- **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/ocx-child-conformance.test.ts`, and `tests/clients/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/ocx-child-conformance.test.ts`, and `tests/clients/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/ocx-child-conformance.test.ts\`` and include `// INV-LINK-CHILD-01` in `tests/clients/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. From 07f50076e597c1b082bd023727f94ea0947befdb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 01:21:18 +0900 Subject: [PATCH 02/34] feat(link): add ephemeral-child contract helpers and golden conformance suite --- .../000_roadmap.md | 4 +- .../001_audit_and_status.md | 2 +- .../010_wp1_conformance_contract.md | 8 +- .../040_wp4_docs_gates_and_pr.md | 6 +- scripts/test-layout/layout-clients.json | 3 +- src/link/ephemeral-child.ts | 45 + .../link-ocx-child-conformance.test.ts | 501 +++++++++ .../fixtures/link/ocx-child-conformance.json | 975 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- 9 files changed, 1536 insertions(+), 11 deletions(-) create mode 100644 src/link/ephemeral-child.ts create mode 100644 tests/clients/link-ocx-child-conformance.test.ts create mode 100644 tests/fixtures/link/ocx-child-conformance.json diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md index d9c21c9193c..f4df76b220d 100644 --- a/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md @@ -84,7 +84,7 @@ Phases follow strict dependency order (shared wire/golden contracts -> standalon | 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/ocx-child-conformance.test.ts` registered in dual test-layout maps; passes `bun test` + `bun run typecheck` | +| `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` | @@ -99,7 +99,7 @@ Single-PR delivery decision (`DEV-STACK-01`): Delivered as a single focused PR t | `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/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 | +| `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. Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`) 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 index aeea77ebe4e..c513fa1434e 100644 --- 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 @@ -20,7 +20,7 @@ Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents | 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` | Pending | Shared golden conformance fixture (`ocx-child-conformance.json`), `src/link/ephemeral-child.ts`, and `ocx-child-conformance.test.ts` | +| `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` | Pending | Standalone Rust crate `native/ocx-child` (HTTP/1.1+SSE relay, HMAC v2, OCX-6 parity, `#1798` offline journal) | | `wp3` | Pending | Home discovery, detached enrollment preflight, `package.json` scripts, and compiled-binary E2E lifecycle tests | | `wp4` | Pending | `structure/` + `docs-site/` sync, full gate matrix, branch push, and unmerged PR targeting `dev` | 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 index 724dd82c2db..6eca008d030 100644 --- 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 @@ -1,11 +1,13 @@ # `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`), `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`). + - 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/ocx-child-conformance.test.ts` (NEW), `scripts/test-layout/layout-clients.json` (MODIFY), `tests/fixtures/test-layout-expected.json` (MODIFY). + - **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 @@ -71,7 +73,7 @@ Deterministic JSON document (`"schema": "ocx.child.conformance/1"`) containing 6 - `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` -> `reject_426_websocket`; `GET /v1/catalog` with `Upgrade: h2c` -> `reject_404`; `GET /api/config` & `DELETE /v1/responses` -> `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`. 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 index 1161057df4b..2146c053965 100644 --- 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 @@ -2,7 +2,7 @@ - **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/ocx-child-conformance.test.ts`, and `tests/clients/ocx-child-e2e.test.ts`. + - 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. @@ -12,8 +12,8 @@ ### 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/ocx-child-conformance.test.ts`, and `tests/clients/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/ocx-child-conformance.test.ts\`` and include `// INV-LINK-CHILD-01` in `tests/clients/ocx-child-conformance.test.ts`. +- 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` diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 8934d90bd52..9c14093a1d4 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -172,5 +172,6 @@ "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" } diff --git a/src/link/ephemeral-child.ts b/src/link/ephemeral-child.ts new file mode 100644 index 00000000000..46481cee43b --- /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" | "client"; + 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/tests/clients/link-ocx-child-conformance.test.ts b/tests/clients/link-ocx-child-conformance.test.ts new file mode 100644 index 00000000000..f76039a9aba --- /dev/null +++ b/tests/clients/link-ocx-child-conformance.test.ts @@ -0,0 +1,501 @@ +// 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 { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import { fixturePath } 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 { forwardLinkRequestHeaders } 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 { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "../../src/codex/subagent-defaults"; + +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 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 = content.split("\n"); + const featIdx = lines.findIndex(line => /^\s*\[\s*features\s*\]\s*(?:#.*)?$/.test(line)); + 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 && /^\s*\[/.test(line)); + if (endIdx === -1) endIdx = lines.length; + for (let i = featIdx + 1; i < endIdx; i++) { + if (/^\s*multi_agent_v2\s*=/.test(lines[i]!)) { + const priorLine = lines[i]!; + lines[i] = `multi_agent_v2 = ${desired}`; + return { content: lines.join("\n"), createdKey: false, priorLine }; + } + } + let insertAt = endIdx; + while (insertAt > featIdx + 1 && lines[insertAt - 1]!.trim() === "") insertAt--; + lines.splice(insertAt, 0, `multi_agent_v2 = ${desired}`); + return { content: lines.join("\n"), createdKey: true, priorLine: null }; +} + +function stripSurgicalTableFeatures( + content: string, + wroteFastMode: boolean | null, + wroteMultiAgentV2: boolean | null, +): string { + if (wroteFastMode === null && wroteMultiAgentV2 === null) return content; + const lines = content.split("\n"); + const featIdx = lines.findIndex(line => /^\s*\[\s*features\s*\]\s*(?:#.*)?$/.test(line)); + if (featIdx === -1) return content; + let endIdx = lines.findIndex((line, i) => i > featIdx && /^\s*\[/.test(line)); + if (endIdx === -1) endIdx = lines.length; + const body = lines.slice(featIdx + 1, endIdx).filter(line => { + if (wroteFastMode !== null && new RegExp(`^\\s*fast_mode\\s*=\\s*${wroteFastMode}\\s*(?:#.*)?$`).test(line)) return false; + if (wroteMultiAgentV2 !== null && new RegExp(`^\\s*multi_agent_v2\\s*=\\s*${wroteMultiAgentV2}\\s*(?:#.*)?$`).test(line)) return false; + return true; + }); + if (body.every(line => line.trim() === "" || line.trim().startsWith("#"))) { + const next = [...lines.slice(0, featIdx), ...lines.slice(endIdx)]; + return next.join("\n").replace(/\n{3,}/g, "\n\n"); + } + return [...lines.slice(0, featIdx + 1), ...body, ...lines.slice(endIdx)].join("\n"); +} + +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 = next.split("\n"); + const agIdx = lines.findIndex(line => /^\s*\[\s*agents\s*\]\s*(?:#.*)?$/.test(line)); + if (agIdx === -1) return next; + let endIdx = lines.findIndex((line, i) => i > agIdx && /^\s*\[/.test(line)); + if (endIdx === -1) endIdx = lines.length; + const body = lines.slice(agIdx + 1, endIdx).filter(line => { + if (written.default_subagent_model && line.trim() === `default_subagent_model = ${JSON.stringify(written.default_subagent_model)}`) return false; + if (written.default_subagent_reasoning_effort && line.trim() === `default_subagent_reasoning_effort = ${JSON.stringify(written.default_subagent_reasoning_effort)}`) return false; + return true; + }); + if (body.every(line => line.trim() === "" || line.trim().startsWith("#"))) { + next = [...lines.slice(0, agIdx), ...lines.slice(endIdx)].join("\n").replace(/\n{3,}/g, "\n\n"); + } else { + next = [...lines.slice(0, agIdx + 1), ...body, ...lines.slice(endIdx)].join("\n"); + } + 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 wroteMultiAgentV2: boolean | 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) { + lf = ensureFastModeFeature(lf, settings.fastMode); + wroteFastMode = settings.fastMode; + } + if (settings.multiAgentMode !== null) { + lf = applyMultiAgentFeature(lf, settings.multiAgentMode).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, + }; + } + + for (const line of applyEol(currentToml, "\n").split("\n")) { + if (/^\s*(?:openai_base_url|experimental_realtime_ws_base_url|model_catalog_json|web_search)\s*=/.test(line)) { + try { + Bun.TOML.parse(line); + } catch { + return { + attachOk: true, + detachOutcome: "refuse_conflict", + finalToml: currentToml, + finalCatalog: row.preAttachCatalog, + }; + } + } + } + + 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, wroteMultiAgentV2); + surgical = stripSurgicalSubagentDefaults(surgical, wroteSubagent); + surgical = surgical.replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n"); + 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", () => { + for (const row of suite.routeCases) { + const actual = classifyChildIngressCase(row); + expect(actual, row.id).toEqual({ + action: row.expectedAction, + authHeader: row.expectedAuthHeader, + }); + } + }); + + test("framingCases match link header smuggling validation and CALLER_CREDENTIAL_HEADERS stripping", () => { + 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, "ocx_data_" + "1".repeat(40), "/v1/responses"); + forwarded.delete("authorization"); + expect([...forwarded.keys()].sort(), row.id).toEqual(row.expectedSurvivedHeaders); + } + }); + + 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: "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); + }); +}); diff --git a/tests/fixtures/link/ocx-child-conformance.json b/tests/fixtures/link/ocx-child-conformance.json new file mode 100644 index 00000000000..624826c96a2 --- /dev/null +++ b/tests/fixtures/link/ocx-child-conformance.json @@ -0,0 +1,975 @@ +{ + "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 + } + ], + "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": null + }, + { + "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 + } + ] +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a6eefe6bb2e..f7e073f5bcf 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1618,5 +1618,6 @@ "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" } From 072d4917718e416d59847dbbc4ebf0f300b0ad7f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 02:47:30 +0900 Subject: [PATCH 03/34] feat(ocx-child): add standalone lightweight Rust Child link relay and offline journal --- .../001_audit_and_status.md | 2 +- .../020_wp2_rust_ocx_child.md | 6 +- .../030_wp3_home_link_integration.md | 6 +- native/ocx-child/Cargo.lock | 215 ++++ native/ocx-child/Cargo.toml | 31 + native/ocx-child/src/cli.rs | 976 ++++++++++++++++++ native/ocx-child/src/codex_settings.rs | 111 ++ native/ocx-child/src/daemon.rs | 209 ++++ native/ocx-child/src/http_wire.rs | 579 +++++++++++ native/ocx-child/src/journal.rs | 434 ++++++++ native/ocx-child/src/lib.rs | 20 + native/ocx-child/src/main.rs | 9 + native/ocx-child/src/parity.rs | 79 ++ native/ocx-child/src/platform.rs | 543 ++++++++++ native/ocx-child/src/relay.rs | 868 ++++++++++++++++ native/ocx-child/src/relay_auth.rs | 140 +++ native/ocx-child/src/routes.rs | 236 +++++ native/ocx-child/src/state.rs | 148 +++ native/ocx-child/src/toml_surgeon.rs | 896 ++++++++++++++++ native/ocx-child/tests/conformance.rs | 439 ++++++++ native/ocx-child/tests/relay_and_lifecycle.rs | 566 ++++++++++ 21 files changed, 6507 insertions(+), 6 deletions(-) create mode 100644 native/ocx-child/Cargo.lock create mode 100644 native/ocx-child/Cargo.toml create mode 100644 native/ocx-child/src/cli.rs create mode 100644 native/ocx-child/src/codex_settings.rs create mode 100644 native/ocx-child/src/daemon.rs create mode 100644 native/ocx-child/src/http_wire.rs create mode 100644 native/ocx-child/src/journal.rs create mode 100644 native/ocx-child/src/lib.rs create mode 100644 native/ocx-child/src/main.rs create mode 100644 native/ocx-child/src/parity.rs create mode 100644 native/ocx-child/src/platform.rs create mode 100644 native/ocx-child/src/relay.rs create mode 100644 native/ocx-child/src/relay_auth.rs create mode 100644 native/ocx-child/src/routes.rs create mode 100644 native/ocx-child/src/state.rs create mode 100644 native/ocx-child/src/toml_surgeon.rs create mode 100644 native/ocx-child/tests/conformance.rs create mode 100644 native/ocx-child/tests/relay_and_lifecycle.rs 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 index c513fa1434e..d4ba39fa23c 100644 --- 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 @@ -21,6 +21,6 @@ Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents |---|---|---| | `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` | Pending | Standalone Rust crate `native/ocx-child` (HTTP/1.1+SSE relay, HMAC v2, OCX-6 parity, `#1798` offline journal) | +| `wp2` | Completed | Standalone Rust crate `native/ocx-child` (878 KiB / 899,216 B stripped release binary; `cargo test --locked` 6 pass, 0 fail; `clippy -D warnings` & `fmt --check` clean) | | `wp3` | Pending | Home discovery, detached enrollment preflight, `package.json` scripts, and compiled-binary E2E lifecycle tests | | `wp4` | Pending | `structure/` + `docs-site/` sync, full gate matrix, branch push, and unmerged PR targeting `dev` | 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 index e9e517719f0..fd2d5da1a63 100644 --- 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 @@ -1,5 +1,7 @@ # `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`). @@ -15,7 +17,7 @@ ```toml [package] name = "opencodex-child" -version = "2.98.0" +version = "0.1.0" edition = "2024" rust-version = "1.90" license = "MIT" @@ -68,7 +70,7 @@ All modules set `#![deny(unsafe_op_in_unsafe_fn)]` at crate root (`lib.rs` / `ma 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`). - - `OCX_COMPAT_VERSION: &str = env!("CARGO_PKG_VERSION")` (`2.98.0`, matching `package.json`, `>= 2.93.0`). + - `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`). 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 index 8db6211235c..5f93867637e 100644 --- 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 @@ -105,9 +105,9 @@ Add `native/ocx-child` source artifacts to `"files"` and add scripts `build:ocx- 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 `native/ocx-child/Cargo.toml` == `package.json` version lock. +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/ocx-child-e2e.test.ts` (`"clients"`): 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. +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`) @@ -118,6 +118,6 @@ Register all three new test files in `scripts/test-layout/layout-clients.json` a ## 3. Phase Verifiers -- `bun test tests/clients/link-ephemeral-child-discovery.test.ts tests/server/link-enrollment-ephemeral-child.test.ts tests/clients/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 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/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..5e1e60a105b --- /dev/null +++ b/native/ocx-child/src/cli.rs @@ -0,0 +1,976 @@ +use std::fs; +use std::io::{self, Read}; +use std::time::Duration; + +use serde::Deserialize; + +use crate::codex_settings::{ + HomeCodexSettings, HomeCodexSettingsDocument, HomeIdentity, codex_settings_digest, +}; +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}; +use crate::parity::LinkChildParityReport; +use crate::platform::{ + FileLock, LockAcquireError, SecretBytes, format_iso8601_utc, now_epoch_ms, random_hex, + resolve_codex_home, resolve_opencodex_home, +}; +use crate::relay::{ + RelayCredentials, 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_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)) +} + +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| 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(format!("Home codex-settings returned HTTP {}", head.status)); + } + let doc: HomeCodexSettingsDocument = + serde_json::from_slice(&body).map_err(|e| format!("invalid codex-settings JSON: {e}"))?; + if !doc.validate() { + return Err("Home codex-settings failed schema validation".to_owned()); + } + Ok(doc) +} + +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 paths = current_paths(); + let _lock = match FileLock::acquire_blocking(&paths.lock_file) { + Ok(l) => l, + Err(_) => { + eprintln!("attach failed: could not acquire child lifecycle lock"); + return 1; + } + }; + + let existing_pid = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) => st.daemon_pid, + _ => None, + }; + if let Err(e) = ensure_port_available_for_attach(listen_port, existing_pid) { + eprintln!("{e}"); + return 1; + } + + let raw_key_str = match std::str::from_utf8(raw_key.as_slice()) { + Ok(s) => s, + Err(_) => { + eprintln!("invalid credential encoding"); + return 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 1; + } + Err(e) => { + eprintln!("Home /readyz unreachable over tunnel: {e:?}"); + return 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 1; + } + Err(e) => { + eprintln!("Home /v1/catalog failed: {e:?}"); + return 1; + } + }; + + let settings_doc = match fetch_home_settings_doc(tunnel_port, &creds) { + Ok(d) => d, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + + let journal = match apply_attach_or_sync( + &paths, + &link_id, + &api_key_id, + listen_port, + &catalog_bytes, + &settings_doc.settings, + ) { + Ok(j) => j, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + + if let Err(e) = write_link_key(&paths.key_file, raw_key_str) { + eprintln!("{e}"); + return 1; + } + + 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) => { + eprintln!("{e}"); + return 1; + } + }; + 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, + parity_sequence: 0, + pending_operation: 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) { + eprintln!("{e}"); + return 1; + } + + if start_daemon { + match start_or_rollover_daemon(&paths, &mut state, Some(now_ms)) { + 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!("{e}"); + return 1; + } + } + } + + println!("Connected to http://127.0.0.1:{tunnel_port} as key {api_key_id}."); + println!("Local Codex CLI: ready (ephemeral ocx-child Design B attached)."); + 0 +} + +fn cmd_serve(args: &[String]) -> u8 { + let mut foreground = 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; + } + "--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 port = listen_port.unwrap_or_else(|| match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) => st.listen_port, + _ => default_listen_port(), + }); + return match serve_foreground(paths, port) { + 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; + } + }; + 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; + } + }; + 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) { + 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" { + 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 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 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, + _ => 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; + } + }, + }; + + 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(_) if stale && 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( + &paths, + &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)) { + 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 paths = current_paths(); + let _lock = match FileLock::acquire_blocking(&paths.lock_file) { + Ok(l) => l, + Err(_) => { + eprintln!("detach failed: child lifecycle lock busy"); + return 1; + } + }; + if let StateReadOutcome::Valid(mut st) = read_child_state(&paths.state_file) { + st.pending_operation = Some("disconnect".to_owned()); + let _ = write_child_state(&paths.state_file, &st); + if let Err(e) = stop_owned_daemon(&st) { + eprintln!("{e}"); + return 1; + } + } + let outcome = match offline_detach(&paths, 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; + } + }; + let _ = fs::remove_file(&paths.key_file); + let _ = fs::remove_file(&paths.state_file); + if json_mode { + println!("{{\"restored\":true,\"outcome\":\"{outcome:?}\",\"daemonRunning\":false}}"); + } else { + println!( + "Disconnected ephemeral Remote Link Child ({outcome:?}); zero background daemon running." + ); + } + 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 running = 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": 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_settings.rs b/native/ocx-child/src/codex_settings.rs new file mode 100644 index 00000000000..79d2729b003 --- /dev/null +++ b/native/ocx-child/src/codex_settings.rs @@ -0,0 +1,111 @@ +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, +} + +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 sub.model.trim().is_empty() || sub.model.len() > 256 { + return false; + } + if let Some(eff) = &sub.reasoning_effort + && (eff.trim().is_empty() || eff.len() > 64) + { + return false; + } + } + true + } +} + +impl HomeCodexSettingsDocument { + pub fn validate(&self) -> bool { + self.version == HOME_CODEX_SETTINGS_VERSION + && !self.home.name.trim().is_empty() + && self.home.name.len() <= 255 + && !self.home.version.trim().is_empty() + && self.home.version.len() <= 64 + && 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..09f0f3cf900 --- /dev/null +++ b/native/ocx-child/src/daemon.rs @@ -0,0 +1,209 @@ +use std::io::{BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +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::{ + force_terminate_pid, now_epoch_ms, process_is_alive, random_hex, spawn_detached_serve, +}; +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, +} + +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 { + if shutdown_secret.is_empty() { + 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: {shutdown_secret}\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; + } + let mut reader = BufReader::new(stream); + 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 verified_pid = match (state.daemon_pid, &live_health) { + (_, Some(h)) + if h.service == "opencodex" + && h.ephemeral_child == Some(true) + && (state.daemon_pid.is_none() || state.daemon_pid == Some(h.pid)) => + { + Some(h.pid) + } + (Some(pid), None) if !is_port_bound(port) && !process_is_alive(pid) => return Ok(()), + (None, None) => return Ok(()), + _ => None, + }; + + let _ = send_attested_shutdown(port, &state.shutdown_secret); + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + let port_down = !is_port_bound(port); + let pid_down = verified_pid.is_none_or(|p| !process_is_alive(p)); + if port_down && pid_down { + return Ok(()); + } + thread::sleep(Duration::from_millis(40)); + } + + if let Some(pid) = verified_pid { + force_terminate_pid(pid, false); + let term_deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < term_deadline { + if !process_is_alive(pid) && !is_port_bound(port) { + return Ok(()); + } + thread::sleep(Duration::from_millis(40)); + } + force_terminate_pid(pid, true); + let kill_deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < kill_deadline { + if !process_is_alive(pid) && !is_port_bound(port) { + return Ok(()); + } + thread::sleep(Duration::from_millis(40)); + } + if process_is_alive(pid) { + return Err(format!( + "owned ocx-child daemon (pid {pid}) did not terminate" + )); + } + } + Ok(()) +} + +pub fn start_or_rollover_daemon( + paths: &ChildPaths, + state: &mut ChildStateV1, + min_started_at_ms: Option, +) -> Result<(u32, u64), String> { + 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 spawned_pid = spawn_detached_serve( + &exe, + &paths.opencodex_home, + &paths.codex_home, + state.listen_port, + )?; + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + 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)?; + return Ok((spawned_pid, started_ms)); + } + thread::sleep(Duration::from_millis(50)); + } + force_terminate_pid(spawned_pid, true); + 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..7c316dec9f8 --- /dev/null +++ b/native/ocx-child/src/http_wire.rs @@ -0,0 +1,579 @@ +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(); + loop { + let mut size_line = String::new(); + let n = reader.read_line(&mut size_line)?; + if n == 0 { + return Err(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 + loop { + let mut trailer = String::new(); + let tn = reader.read_line(&mut trailer)?; + if tn == 0 || trailer == "\r\n" || trailer == "\n" { + 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]; + loop { + let mut size_line = String::new(); + let n = reader.read_line(&mut size_line)?; + if n == 0 { + break; + } + 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 mut trailer = String::new(); + let tn = reader.read_line(&mut trailer)?; + if tn == 0 || trailer == "\r\n" || trailer == "\n" { + 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)?; + } + } + } + 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..85a30f3643f --- /dev/null +++ b/native/ocx-child/src/journal.rs @@ -0,0 +1,434 @@ +use std::collections::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_settings::HomeCodexSettings; +use crate::platform::{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, + 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, + pub untouched_by_external_writer: bool, + pub pre_attach_catalog_existed: bool, + pub pre_attach_catalog_bytes_b64: Option, + pub latest_catalog_sha256: String, + pub pre_attach_models_cache_existed: bool, + pub pre_attach_models_cache_bytes_b64: Option, + pub latest_models_cache_sha256: Option, + pub ledger: ChildInjectedLedger, +} + +#[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) +} + +/// 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()) +} + +/// Updates `$CODEX_HOME/models_cache.json` matching `invalidateCodexModelsCacheWithPermitOutcome`. +pub fn refresh_codex_models_cache( + cache_path: &Path, + catalog_models: &[serde_json::Value], +) -> Result { + 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}")), + }; + 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 is_obs && !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); + if existing_bytes.as_deref() != Some(rendered.as_slice()) { + write_regular_atomic(cache_path, &rendered)?; + } + Ok(digest) +} + +pub fn apply_attach_or_sync( + paths: &ChildPaths, + link_id: &str, + api_key_id: &str, + listen_port: u16, + catalog_bytes: &[u8], + settings: &HomeCodexSettings, +) -> Result { + 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)?; + 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)?; + } + + let catalog_path_str = catalog_path.to_string_lossy().into_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, untouched) = + if let Some(prev) = &existing_journal { + let cur_sha = current_cfg_bytes.as_ref().map(|b| sha256_hex(b)); + let still_untouched = prev.untouched_by_external_writer + && cur_sha.as_deref() == Some(prev.latest_injected_config_sha256.as_str()); + ( + prev.pre_attach_config_existed, + prev.pre_attach_config_bytes_b64.clone(), + prev.pre_attach_config_sha256.clone(), + prev.first_injected_config_sha256.clone(), + 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(), + true, + ) + }; + + let (pre_cat_existed, pre_cat_b64) = if let Some(prev) = &existing_journal { + ( + prev.pre_attach_catalog_existed, + prev.pre_attach_catalog_bytes_b64.clone(), + ) + } else { + match fs::read(&catalog_path) { + Ok(b) => (true, Some(BASE64.encode(&b))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (false, None), + Err(e) => return Err(format!("failed reading {}: {e}", catalog_path.display())), + } + }; + + let (pre_cache_existed, pre_cache_b64) = if let Some(prev) = &existing_journal { + ( + prev.pre_attach_models_cache_existed, + prev.pre_attach_models_cache_bytes_b64.clone(), + ) + } else { + match fs::read(&cache_path) { + Ok(b) => (true, Some(BASE64.encode(&b))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (false, None), + Err(e) => return Err(format!("failed reading {}: {e}", cache_path.display())), + } + }; + + write_regular_atomic(&catalog_path, catalog_bytes)?; + write_private_atomic(&paths.catalog_backup_file, catalog_bytes)?; + let latest_cache_sha = refresh_codex_models_cache(&cache_path, &catalog_models)?; + write_regular_atomic(&config_path, next_cfg_text.as_bytes())?; + + 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: paths.opencodex_home.to_string_lossy().into_owned(), + codex_home: paths.codex_home.to_string_lossy().into_owned(), + 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, + 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, + 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), + ledger: next_ledger, + }; + write_journal(&paths.journal_file, &journal)?; + Ok(journal) +} + +/// Performs 100% offline two-tier restoration of `config.toml`, `opencodex-catalog.json`, and `models_cache.json`. +pub fn offline_detach( + paths: &ChildPaths, + keep_catalog: bool, +) -> Result { + let Some(journal) = read_journal(&paths.journal_file).map_err(DetachError::Io)? else { + return Ok(DetachOutcome::NotAttached); + }; + 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)?; + + 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); + if cur_cat_sha != journal.latest_catalog_sha256 { + let matches_prior = journal + .pre_attach_catalog_bytes_b64 + .as_ref() + .and_then(|b64| BASE64.decode(b64).ok()) + .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(), + )); + } + } + } + + 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 => DetachOutcome::SurgicalRestore, + Some(bytes) => { + let cur_sha = sha256_hex(&bytes); + if journal.untouched_by_external_writer + && cur_sha == journal.latest_injected_config_sha256 + { + 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}")) + }) + })?; + write_regular_atomic(&config_path, &orig).map_err(DetachError::Io)?; + } else { + let _ = fs::remove_file(&config_path); + } + 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) => { + write_regular_atomic(&config_path, updated.as_bytes()) + .map_err(DetachError::Io)?; + } + None => { + let _ = fs::remove_file(&config_path); + } + } + DetachOutcome::SurgicalRestore + } + } + }; + + if !keep_catalog { + if journal.pre_attach_catalog_existed { + if let Some(b64) = &journal.pre_attach_catalog_bytes_b64 + && let Ok(prior_bytes) = BASE64.decode(b64) + { + write_regular_atomic(catalog_path, &prior_bytes).map_err(DetachError::Io)?; + } + } else { + let _ = fs::remove_file(catalog_path); + } + } + + if cache_path.exists() { + if journal.pre_attach_models_cache_existed { + if let Some(b64) = &journal.pre_attach_models_cache_bytes_b64 + && let Ok(prior_cache) = BASE64.decode(b64) + { + let _ = write_regular_atomic(&cache_path, &prior_cache); + } + } else if let Ok(cur_cache) = fs::read(&cache_path) + && Some(sha256_hex(&cur_cache)) == journal.latest_models_cache_sha256 + { + let _ = fs::remove_file(&cache_path); + } + } + + let _ = fs::remove_file(&paths.catalog_backup_file); + let _ = fs::remove_file(&paths.journal_file); + Ok(outcome) +} diff --git a/native/ocx-child/src/lib.rs b/native/ocx-child/src/lib.rs new file mode 100644 index 00000000000..127650526dd --- /dev/null +++ b/native/ocx-child/src/lib.rs @@ -0,0 +1,20 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +pub mod cli; +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 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..fd73b2f1093 --- /dev/null +++ b/native/ocx-child/src/parity.rs @@ -0,0 +1,79 @@ +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) + } +} + +/// 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; + } + for req_key in [ + "linkId", + "lastApplyAt", + "runtimeStartedAt", + "appliedCodexSettingsDigest", + "sequence", + ] { + if !obj.contains_key(req_key) { + return None; + } + } + let parsed: LinkChildParityReport = serde_json::from_value(val).ok()?; + 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..289de45be4c --- /dev/null +++ b/native/ocx-child/src/platform.rs @@ -0,0 +1,543 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, 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 sha2::{Digest, Sha256}; + +/// 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); + } +} + +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 TerminateProcess(process: *mut std::ffi::c_void, exit_code: u32) -> i32; + 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)) +} + +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())) + } + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("failed to inspect {}: {e}", path.display())), + } +} + +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(not(unix))] + { + fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?; + reject_symlink(dir)?; + } + Ok(()) +} + +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())); + let mut opts = OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = 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() + )); + } + 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() + )); + 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)); + } + let mut f = 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() + )); + } + 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) + } + + pub fn acquire_blocking(path: &Path) -> Result { + Self::acquire_inner(path, true) + } + + fn acquire_inner(path: &Path, blocking: bool) -> Result { + let parent = path.parent().ok_or(LockAcquireError::Io)?; + ensure_private_dir(parent).map_err(|_| LockAcquireError::Io)?; + reject_symlink(path).map_err(|_| LockAcquireError::Io)?; + 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); + } + let file = opts.open(path).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) }; + } + } +} + +pub fn spawn_detached_serve( + exe: &Path, + opencodex_home: &Path, + codex_home: &Path, + listen_port: u16, +) -> Result { + let mut cmd = Command::new(exe); + cmd.arg("serve") + .arg("--foreground") + .arg("--listen-port") + .arg(listen_port.to_string()) + .env("OPENCODEX_HOME", opencodex_home) + .env("CODEX_HOME", codex_home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[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 child = cmd + .spawn() + .map_err(|e| format!("failed to spawn detached ocx-child serve: {e}"))?; + Ok(child.id()) +} + +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 force_terminate_pid(pid: u32, hard_kill: bool) { + if pid == 0 { + return; + } + #[cfg(unix)] + { + let sig = if hard_kill { 9 } else { 15 }; + // SAFETY: Caller verified PID ownership via `/healthz` before invoking termination. + let _ = unsafe { kill(pid as i32, sig) }; + } + #[cfg(windows)] + { + let _ = hard_kill; + const PROCESS_TERMINATE: u32 = 0x0001; + // SAFETY: Caller verified PID ownership before invoking TerminateProcess. + unsafe { + let h = OpenProcess(PROCESS_TERMINATE, 0, pid); + if !h.is_null() { + let _ = TerminateProcess(h, 1); + CloseHandle(h); + } + } + } +} + +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..ce609e3e155 --- /dev/null +++ b/native/ocx-child/src/relay.rs @@ -0,0 +1,868 @@ +use std::io::{BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, 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::parity::LinkChildParityReport; +use crate::platform::{ + 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_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); +const HEADER_AND_SSE_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_CONCURRENT_WORKERS: usize = 32; +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, +} + +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 { + let addr = SocketAddr::from(([127, 0, 0, 1], tunnel_port)); + let stream = TcpStream::connect_timeout(&addr, AUTH_TIMEOUT) + .map_err(|e| UpstreamError::HomeUnreachable(format!("tunnel connect failed: {e}")))?; + let _ = stream.set_nodelay(true); + let _ = stream.set_read_timeout(Some(AUTH_TIMEOUT)); + let _ = stream.set_write_timeout(Some(AUTH_TIMEOUT)); + let mut writer = stream + .try_clone() + .map_err(|e| UpstreamError::HomeUnreachable(format!("socket clone failed: {e}")))?; + let mut reader = BufReader::new(stream); + + 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, + }) +} + +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 mut upstream = open_authenticated_upstream(tunnel_port, creds, false)?; + let _ = upstream + .writer + .set_read_timeout(Some(Duration::from_secs(15))); + let _ = upstream + .writer + .set_write_timeout(Some(Duration::from_secs(15))); + let token_str = std::str::from_utf8(creds.raw_token.as_slice()) + .map_err(|_| UpstreamError::RelayAuthFailed("invalid utf8 token".to_owned()))?; + let mut head = 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 + ); + match auth_kind { + RelayAuthKind::ApiKeyHeader => { + head.push_str(&format!("x-opencodex-api-key: {token_str}\r\n")); + } + RelayAuthKind::Bearer => { + head.push_str(&format!("Authorization: Bearer {token_str}\r\n")); + } + } + if let Some((ct, payload)) = body { + head.push_str(&format!( + "Content-Type: {ct}\r\nContent-Length: {}\r\n", + payload.len() + )); + } + head.push_str("\r\n"); + upstream + .writer + .write_all(head.as_bytes()) + .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 { + let json_bytes = report.to_bounded_json()?; + let (head, _, _) = fetch_home_authenticated( + tunnel_port, + creds, + "POST", + "/v1/link-management/child-parity", + RelayAuthKind::Bearer, + Some(("application/json", &json_bytes)), + 1024, + ) + .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>, +} + +impl SharedServerState { + fn load_live_creds(&self) -> Result<(RelayCredentials, String), &'static str> { + 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, + }, + state.shutdown_secret, + )) + } + + fn signal_shutdown(&self) { + self.stop_flag.store(true, Ordering::SeqCst); + 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), + ); + } +} + +pub fn serve_foreground(paths: ChildPaths, listen_port: u16) -> Result<(), String> { + 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 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), + }); + + 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())); + + 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 (lock, cv) = &*sem_clone; + let mut active = lock.lock().unwrap_or_else(|e| e.into_inner()); + while *active >= MAX_CONCURRENT_WORKERS { + active = cv.wait(active).unwrap_or_else(|e| e.into_inner()); + } + *active += 1; + } + let worker_shared = Arc::clone(&shared); + thread::spawn(move || { + let _ = handle_client_connection(client_stream, &worker_shared); + 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; + } + } + 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_child_parity_report(st.tunnel_port, &creds, &report) { + 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; + } + } + } +} + +fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> std::io::Result<()> { + let _ = stream.set_nodelay(true); + let _ = stream.set_read_timeout(Some(Duration::from_secs(30))); + let _ = stream.set_write_timeout(Some(HEADER_AND_SSE_TIMEOUT)); + let mut writer = stream.try_clone()?; + let mut reader = BufReader::new(stream); + + 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"), + ); + + match decision { + RouteDecision::LocalShutdown => { + let supplied = req.header("x-ocx-child-shutdown").unwrap_or(""); + if let StateReadOutcome::Valid(st) = read_child_state(&shared.paths.state_file) + && !st.shutdown_secret.is_empty() + && constant_time_eq(supplied.as_bytes(), st.shutdown_secret.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, + })) + .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 gen_opt = up + .connection_generation + .or_else(|| shared.last_generation.lock().ok().and_then(|g| g.clone())); + let link_obj = match gen_opt { + 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() + .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)); + + let Ok(token_str) = std::str::from_utf8(creds.raw_token.as_slice()) else { + return write_json_response( + client_writer, + 503, + "Service Unavailable", + &[], + br#"{"error":"link_credential_unavailable"}"#, + ); + }; + + let mut out_head = 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 + ); + match auth_kind { + RelayAuthKind::ApiKeyHeader => { + out_head.push_str(&format!("x-opencodex-api-key: {token_str}\r\n")); + } + RelayAuthKind::Bearer => { + out_head.push_str(&format!("Authorization: Bearer {token_str}\r\n")); + } + } + for (k, v) in &validated.sanitized { + if k.eq_ignore_ascii_case("accept-encoding") { + continue; + } + out_head.push_str(&format!("{k}: {v}\r\n")); + } + if req.method != "GET" && req.method != "HEAD" { + out_head.push_str(&format!("Content-Length: {}\r\n", req_body.len())); + } + out_head.push_str("\r\n"); + + if upstream.writer.write_all(out_head.as_bytes()).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..7ec94d2d775 --- /dev/null +++ b/native/ocx-child/src/relay_auth.rs @@ -0,0 +1,140 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::platform::{constant_time_eq, hex_decode_lower, hex_encode, random_hex, sha256_hex}; + +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 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..e5657bc0c5c --- /dev/null +++ b/native/ocx-child/src/state.rs @@ -0,0 +1,148 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::codex_settings::HomeCodexSettingsDocument; +use crate::platform::{SecretBytes, reject_symlink, 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 catalog_backup_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"), + catalog_backup_file: child_dir.join("last-catalog.json"), + 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") + } +} + +#[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: String, + pub parity_sequence: u64, + pub pending_operation: Option, + pub applied_settings: Option, +} + +#[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) => 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) { + Ok(st) if st.schema == CHILD_STATE_SCHEMA => StateReadOutcome::Valid(Box::new(st)), + Ok(st) => StateReadOutcome::Invalid(format!("unsupported schema: {}", st.schema)), + 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'); + write_private_atomic(path, &bytes) +} + +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/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs new file mode 100644 index 00000000000..4d833c840c1 --- /dev/null +++ b/native/ocx-child/src/toml_surgeon.rs @@ -0,0 +1,896 @@ +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, + pub wrote_multi_agent_v2: 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("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + 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('"'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + _ => 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()) + } +} + +fn root_end_index(lines: &[&str]) -> usize { + lines + .iter() + .position(|l| is_table_header(l)) + .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 root_end = root_end_index(&lines); + let mut provider: Option = None; + for &line in &lines[..root_end] { + 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 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 &line in &lines[start + 1..] { + if is_table_header(line) { + break; + } + if let Ok(Some(_)) = parse_single_line_string_assignment(line, "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 root_end = root_end_index(&lines); + for i in 0..root_end { + 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 && 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 root_end = lines + .iter() + .position(|l| is_table_header(l)) + .unwrap_or(lines.len()); + if root_end == lines.len() { + while 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 && 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 root_end = root_end_index(&lines); + let mut drop = vec![false; lines.len()]; + for i in 0..root_end { + if let Ok(Some(val)) = parse_single_line_string_assignment(lines[i], "openai_base_url") { + let marker_above = i > 0 && 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 && 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 root_end = root_end_index(&lines); + let mut drop = vec![false; lines.len()]; + for i in 0..root_end { + if let Ok(Some(val)) = parse_single_line_string_assignment(lines[i], "web_search") { + let marker_above = i > 0 && is_ocx_routing_marker_line(lines[i - 1]); + let value_match = injected_val == Some(val.as_str()); + if marker_above || value_match { + drop[i] = true; + if marker_above { + 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 root_end = root_end_index(&lines); + if !disable { + let mut has_user_ws = false; + for &line in &lines[..root_end] { + if parse_single_line_string_assignment(line, "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 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 root_end = lines + .iter() + .position(|l| is_table_header(l)) + .unwrap_or(lines.len()); + let mut owned_indices = Vec::new(); + for (i, line) in lines[..root_end].iter().enumerate() { + 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 root_end = root_end_index(&lines); + lines + .into_iter() + .enumerate() + .filter_map(|(i, l)| { + if i < root_end + && 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 { + 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 table_idx = lines + .iter() + .position(|l| 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() + .position(|l| is_table_header(l)) + .map(|rel| start + 1 + rel) + .unwrap_or(lines.len()); + for line in &mut lines[start + 1..end] { + if let Some((k, _)) = parse_key_value_line(line) + && k == key + { + 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; + } + } + let mut insert_at = end; + while insert_at > start + 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; + } + let trimmed = content_lf.trim_end_matches('\n'); + if trimmed.is_empty() { + format!("[{table_name}]\n{assign_line}\n") + } else { + format!("{trimmed}\n\n[{table_name}]\n{assign_line}\n") + } +} + +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 ag_idx = lines + .iter() + .position(|l| 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() + .position(|l| is_table_header(l)) + .map(|rel| start + 1 + rel) + .unwrap_or(lines.len()); + // Respect user-owned existing keys in [agents] + for line in &lines[start + 1..end] { + 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 && 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 Some(ag_idx) = lines + .iter() + .position(|l| parse_simple_table_name(l).as_deref() == Some("agents")) + else { + return content_lf.to_owned(); + }; + let end_idx = lines[ag_idx + 1..] + .iter() + .position(|l| 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 { + 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_keys = (ag_idx + 1..end_idx) + .filter(|&i| !drop[i]) + .any(|i| !lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')); + if !remaining_body_has_keys { + for d in &mut drop[ag_idx..end_idx] { + *d = true; + } + if ag_idx > 0 && 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, + wrote_multi_agent_v2: Option, +) -> String { + if wrote_fast_mode.is_none() && wrote_multi_agent_v2.is_none() { + return content_lf.to_owned(); + } + let lines: Vec<&str> = content_lf.split('\n').collect(); + let Some(feat_idx) = lines + .iter() + .position(|l| parse_simple_table_name(l).as_deref() == Some("features")) + else { + return content_lf.to_owned(); + }; + let end_idx = lines[feat_idx + 1..] + .iter() + .position(|l| 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 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" }) + { + drop[i] = true; + } + if let Some(v2) = wrote_multi_agent_v2 + && k == "multi_agent_v2" + && val_clean == (if v2 { "true" } else { "false" }) + { + drop[i] = true; + } + } + } + let remaining_has_keys = (feat_idx + 1..end_idx) + .filter(|&i| !drop[i]) + .any(|i| !lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')); + 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); + if let Some(fm) = settings.fast_mode { + lf = upsert_table_bool(&lf, "features", "fast_mode", fm); + wrote_fast = Some(fm); + } + let mut wrote_v2 = prior_ledger.and_then(|l| l.wrote_multi_agent_v2); + if let Some(mode) = &settings.multi_agent_mode { + let v2_bool = mode == "v2"; + lf = upsert_table_bool(&lf, "features", "multi_agent_v2", v2_bool); + 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, + wrote_multi_agent_v2: wrote_v2, + 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 root_end = root_end_index(&lines); + for &line in &lines[..root_end] { + 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 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.wrote_multi_agent_v2); + 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 mut out = String::with_capacity(lf.len()); + let mut consecutive_nl = 0usize; + for ch in lf.trim_start_matches('\n').chars() { + if ch == '\n' { + consecutive_nl += 1; + if consecutive_nl <= 2 { + out.push('\n'); + } + } else { + consecutive_nl = 0; + out.push(ch); + } + } + while out.ends_with("\n\n") { + out.pop(); + } + out +} diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs new file mode 100644 index 00000000000..e94fa0aa6b6 --- /dev/null +++ b/native/ocx-child/tests/conformance.rs @@ -0,0 +1,439 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +use ocx_child::codex_settings::{ + HomeCodexSettings, codex_settings_digest, codex_settings_digest_payload, +}; +use ocx_child::http_wire::{FramingError, validate_and_sanitize_request_headers}; +use ocx_child::journal::{DetachError, DetachOutcome, apply_attach_or_sync, offline_detach}; +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; + +#[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( + &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(&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( + &paths, + "lnk_0123456789abcdef", + "link:test", + row.listen_port, + sample_catalog, + sync_set, + ) + .unwrap(); + } + + 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}"), + } + + match offline_detach(&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 + ); + } + 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); + } +} 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..a2d94e82744 --- /dev/null +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -0,0 +1,566 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +use std::collections::HashSet; +use std::fs; +use std::io::{BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +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; +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::platform::{FileLock, process_is_alive}; +use ocx_child::relay_auth::{ + compute_connection_proof, compute_relay_proof, relay_proof_matches, token_fingerprint_hex, +}; +use ocx_child::state::ChildPaths; + +struct MockHomeState { + spoof_listener_proof: AtomicBool, + fast_mode: Mutex>, + catalog_slug: Mutex, + used_nonces: Mutex>, + parity_reports: Mutex>, + observed_data_headers: Mutex>>, + stop: AtomicBool, +} + +struct MockHomeServer { + port: u16, + state: Arc, + handle: Option>, +} + +impl MockHomeServer { + 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()), + used_nonces: Mutex::new(HashSet::new()), + parity_reports: Mutex::new(Vec::new()), + observed_data_headers: Mutex::new(Vec::new()), + stop: AtomicBool::new(false), + }); + 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), + } + } + + 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 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 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"); + 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(); +} + +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(), + ) +} + +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() +} + +#[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")); + 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 (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 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 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(); + 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); +} From a49f471b9892ce4068f7c6ffd50e645ce6d1f19d Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 02:58:28 +0900 Subject: [PATCH 04/34] feat(link): integrate ephemeral ocx-child discovery, enrollment, and sync fan-out --- .../001_audit_and_status.md | 2 +- .../030_wp3_home_link_integration.md | 2 + package.json | 5 + scripts/test-layout/layout-clients.json | 5 +- src/link/remote-windows.ts | 3 + src/link/ssh-argv.ts | 9 + src/server/management/link-enrollment.ts | 18 +- .../link-ephemeral-child-discovery.test.ts | 100 ++++ tests/clients/link-ocx-child-e2e.test.ts | 453 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 5 +- .../link-enrollment-ephemeral-child.test.ts | 225 +++++++++ 11 files changed, 820 insertions(+), 7 deletions(-) create mode 100644 tests/clients/link-ephemeral-child-discovery.test.ts create mode 100644 tests/clients/link-ocx-child-e2e.test.ts create mode 100644 tests/server/link-enrollment-ephemeral-child.test.ts 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 index d4ba39fa23c..f7000e7dc63 100644 --- 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 @@ -22,5 +22,5 @@ Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents | `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` (878 KiB / 899,216 B stripped release binary; `cargo test --locked` 6 pass, 0 fail; `clippy -D warnings` & `fmt --check` clean) | -| `wp3` | Pending | Home discovery, detached enrollment preflight, `package.json` scripts, and compiled-binary E2E lifecycle tests | +| `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` | Pending | `structure/` + `docs-site/` sync, full gate matrix, branch push, and unmerged PR targeting `dev` | 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 index 5f93867637e..95496d62c74 100644 --- 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 @@ -1,5 +1,7 @@ # `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. diff --git a/package.json b/package.json index 74afddd11e8..96c51b55350 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 9c14093a1d4..4db1d11ed7a 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -173,5 +173,8 @@ "cli-sync-expect-link.test.ts": "cli", "link-connection-generation.test.ts": "clients", "ci-pr-hygiene-approvals.test.ts": "ci-workflows", - "link-ocx-child-conformance.test.ts": "clients" + "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/remote-windows.ts b/src/link/remote-windows.ts index b93ff831c10..d9415cbdeb7 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -148,6 +148,9 @@ const OCX_CANDIDATES = String.raw`$u=$env:USERPROFILE;$c=@("$u\.opencodex-deskto 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) `; 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..d8c921c9efb 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -154,6 +154,15 @@ while :; do directory=\${remaining%%:*} try_ocx "\${directory:-.}/ocx" "$@" case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac +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`; export const REMOTE_OCX_SCRIPT = `export PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin" 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/tests/clients/link-ephemeral-child-discovery.test.ts b/tests/clients/link-ephemeral-child-discovery.test.ts new file mode 100644 index 00000000000..85dd7bf800a --- /dev/null +++ b/tests/clients/link-ephemeral-child-discovery.test.ts @@ -0,0 +1,100 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +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 { 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 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")); +} + +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 and preserve OcxJob containment", () => { + 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"); + }); + + test.skipIf(process.platform === "win32")("POSIX remoteOcxArgv prefers runnable full ocx and falls back to ocx-child when full ocx is absent or broken", () => { + const scratch = mkdtempSync(join(tmpdir(), "ocx-child-disc-")); + try { + 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: `/usr/bin:/bin:${pathBin}` }, + stdout: "pipe", + stderr: "pipe", + }); + expect(both.exitCode).toBe(0); + expect(both.stdout.toString().trim()).toBe("selected:full-ocx:config show --json"); + + writeExecScript(join(home, ".local", "bin", "ocx"), "exit 1"); + const fallbackCache = Bun.spawnSync([...argv], { + env: { HOME: home, PATH: `/usr/bin:/bin:${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}:/usr/bin:/bin` }, + 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); + } + }); +}); 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..274d373a197 --- /dev/null +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -0,0 +1,453 @@ +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 } 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" : ""; + const releaseBin = repoPath("native", "ocx-child", "target", "release", `ocx-child${exeSuffix}`); + const debugBin = repoPath("native", "ocx-child", "target", "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(); + 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: "/usr/bin:/bin", + }; + + 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(["/bin/sh", "-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/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f7e073f5bcf..3a72fdca921 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1619,5 +1619,8 @@ "messages-sse-control-fields.test.ts": "claude-integration", "messages-sse-continuation.test.ts": "claude-integration", "sse-payload-rewrite-continuation.test.ts": "responses", - "link-ocx-child-conformance.test.ts": "clients" + "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"); + }); +}); From fec8761583bf6e9d2982c516ebc97c067534910b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 03:03:45 +0900 Subject: [PATCH 05/34] docs(link): document ephemeral ocx-child in structure SSOT and docs-site --- .../001_audit_and_status.md | 2 +- .../040_wp4_docs_gates_and_pr.md | 2 ++ docs-site/src/content/docs/guides/remote-link.md | 6 ++++++ docs-site/src/content/docs/reference/cli/lifecycle.md | 2 ++ structure/INDEX.md | 2 ++ structure/manifest.json | 2 ++ structure/overview.md | 3 +++ structure/remote-link.md | 8 ++++++++ 8 files changed, 26 insertions(+), 1 deletion(-) 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 index f7000e7dc63..0b7bff2b3f6 100644 --- 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 @@ -23,4 +23,4 @@ Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents | `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` (878 KiB / 899,216 B stripped release binary; `cargo test --locked` 6 pass, 0 fail; `clippy -D warnings` & `fmt --check` clean) | | `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` | Pending | `structure/` + `docs-site/` sync, full gate matrix, branch push, and unmerged PR targeting `dev` | +| `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` | 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 index 2146c053965..f52661835f4 100644 --- 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 @@ -1,5 +1,7 @@ # `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`. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 374ac1cf15a..65c1289e827 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -187,6 +187,12 @@ 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`. Home probes every full `ocx` candidate first and falls back to `ocx-child` only when no runnable full `ocx` is present. + +Unlike a full `ocx` Child, a clean detached `ocx-child` host needs no pre-running background daemon before `ocx link add`: Home verifies the target loopback port is free, pipes the link credential over SSH stdin, 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`, rolls over `ocx-child serve` when settings change, and reports OCX-6 parity back to Home. Removing the link (`ocx link revoke` on Home, or offline `ocx-child detach` / `ocx-child disconnect` on the Child) stops `ocx-child serve`, restores native `~/.codex/config.toml` and catalog files (including after Codex Desktop App comment-stripping rewrites), and exits with zero leftover background daemon. + ### 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. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 841ac656659..a757ac7eeb7 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1325,6 +1325,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 speak the same `sync --expect-link [--json]`, `status [--json]`, and `disconnect` / `detach` contracts: `sync` rolls over `ocx-child serve` when settings change and posts a fresh parity report, while `disconnect` / `detach` restores `~/.codex` files offline and stops `ocx-child serve` with zero background daemon left running. + ## Remote Link Child attestations A peer that sends work through a Remote Link Child can check two more facts in the Child's diff --git a/structure/INDEX.md b/structure/INDEX.md index ef5209c0a4c..57c0409d1bf 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -203,6 +203,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) | @@ -434,6 +435,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/model-routes.ts` | [`pricing.md`](pricing.md) | | `src/server/management/quota-pool-routes.ts` | [`quota-pool.md`](quota-pool.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 437280a4f19..2559ddab237 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1329,6 +1329,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 75dbd235f5a..4ebb9155448 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -183,3 +183,11 @@ 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 every full `ocx` path first and falls 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` if any listener already occupies `` (`502 child_preflight_failed` on an SSH transport drop), 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. Shared golden vectors in `tests/fixtures/link/ocx-child-conformance.json` lock TypeScript and Rust parity. 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`. From 2cce483e629096c1fa5960ebf8cd9b6a8e6f2247 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 03:27:45 +0900 Subject: [PATCH 06/34] fix(ocx-child): persist write-ahead journal, track multiline TOML, and restore user features --- .../001_audit_and_status.md | 1 + .../050_wp5_pr_review_fixes.md | 37 ++ native/ocx-child/src/journal.rs | 65 ++- native/ocx-child/src/toml_surgeon.rs | 403 ++++++++++++++---- native/ocx-child/tests/conformance.rs | 64 +++ .../link-ocx-child-conformance.test.ts | 37 +- .../fixtures/link/ocx-child-conformance.json | 44 ++ 7 files changed, 543 insertions(+), 108 deletions(-) create mode 100644 devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md 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 index 0b7bff2b3f6..13356e42e4e 100644 --- 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 @@ -24,3 +24,4 @@ Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents | `wp2` | Completed | Standalone Rust crate `native/ocx-child` (878 KiB / 899,216 B stripped release binary; `cargo test --locked` 6 pass, 0 fail; `clippy -D warnings` & `fmt --check` clean) | | `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 | Fixed all 3 PR #8237 Codex review findings (write-ahead recovery journal, multiline TOML structural scanner, and overwritten user `[features]` surgical restoration) with shared golden & Rust crash-recovery tests | 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..ebc3d5f68b9 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md @@ -0,0 +1,37 @@ +# `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`). + - In `offline_detach`, treat `Some(cur_sha.as_str()) == journal.pre_attach_config_sha256.as_deref()` (or `!config_path.exists() && !journal.pre_attach_config_existed`) under `untouched_by_external_writer` as `ExactRestore` as well, so a crash immediately after `write_journal` (before `config.toml` or after partial `catalog.json` write) detaches cleanly back to exact pre-attach state. +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 a Rust unit/integration test simulating a crash after `write_journal` + `catalog.json` write (before `config.toml` write) and asserting `offline_detach` restores exact pre-attach state. + +## 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/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index 85a30f3643f..8a6e7686abb 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -30,10 +30,14 @@ pub struct ChildJournalV1 { 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, @@ -143,11 +147,11 @@ pub fn validate_catalog_json(bytes: &[u8]) -> Result, Str Ok(models.clone()) } -/// Updates `$CODEX_HOME/models_cache.json` matching `invalidateCodexModelsCacheWithPermitOutcome`. -pub fn refresh_codex_models_cache( +/// 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 { +) -> Result<(Option>, String), String> { reject_symlink(cache_path)?; let existing_bytes = match fs::read(cache_path) { Ok(b) => Some(b), @@ -189,7 +193,16 @@ pub fn refresh_codex_models_cache( .map_err(|e| format!("failed serializing models_cache.json: {e}"))?; rendered.push(b'\n'); let digest = sha256_hex(&rendered); - if existing_bytes.as_deref() != Some(rendered.as_slice()) { + let to_write = (existing_bytes.as_deref() != 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) @@ -240,16 +253,19 @@ pub fn apply_attach_or_sync( 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, untouched) = + 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 still_untouched = prev.untouched_by_external_writer - && cur_sha.as_deref() == Some(prev.latest_injected_config_sha256.as_str()); + && (cur_sha.as_deref() == Some(prev.latest_injected_config_sha256.as_str()) + || cur_sha == prev.pre_attach_config_sha256 + || (cur_sha.is_some() && cur_sha == prev.previous_injected_config_sha256)); ( prev.pre_attach_config_existed, prev.pre_attach_config_bytes_b64.clone(), prev.pre_attach_config_sha256.clone(), prev.first_injected_config_sha256.clone(), + Some(prev.latest_injected_config_sha256.clone()), still_untouched, ) } else { @@ -260,19 +276,21 @@ pub fn apply_attach_or_sync( b64, sha, next_cfg_sha.clone(), + None, true, ) }; - let (pre_cat_existed, pre_cat_b64) = if let Some(prev) = &existing_journal { + let (pre_cat_existed, pre_cat_b64, prev_cat_sha) = if let Some(prev) = &existing_journal { ( prev.pre_attach_catalog_existed, prev.pre_attach_catalog_bytes_b64.clone(), + Some(prev.latest_catalog_sha256.clone()), ) } else { match fs::read(&catalog_path) { - Ok(b) => (true, Some(BASE64.encode(&b))), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => (false, None), + 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())), } }; @@ -290,10 +308,8 @@ pub fn apply_attach_or_sync( } }; - write_regular_atomic(&catalog_path, catalog_bytes)?; - write_private_atomic(&paths.catalog_backup_file, catalog_bytes)?; - let latest_cache_sha = refresh_codex_models_cache(&cache_path, &catalog_models)?; - write_regular_atomic(&config_path, next_cfg_text.as_bytes())?; + let (prepared_cache, latest_cache_sha) = + prepare_codex_models_cache(&cache_path, &catalog_models)?; let journal = ChildJournalV1 { schema: CHILD_JOURNAL_SCHEMA.to_owned(), @@ -307,16 +323,25 @@ pub fn apply_attach_or_sync( 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), ledger: next_ledger, }; + // Persist recovery journal BEFORE mutating any file in $CODEX_HOME (write-ahead durability). write_journal(&paths.journal_file, &journal)?; + write_regular_atomic(&catalog_path, catalog_bytes)?; + write_private_atomic(&paths.catalog_backup_file, catalog_bytes)?; + if let Some(cache_bytes) = prepared_cache { + write_regular_atomic(&cache_path, &cache_bytes)?; + } + write_regular_atomic(&config_path, next_cfg_text.as_bytes())?; Ok(journal) } @@ -339,7 +364,9 @@ pub fn offline_detach( 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); - if cur_cat_sha != journal.latest_catalog_sha256 { + 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 = journal .pre_attach_catalog_bytes_b64 .as_ref() @@ -360,12 +387,16 @@ pub fn offline_detach( }; 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); - if journal.untouched_by_external_writer - && cur_sha == journal.latest_injected_config_sha256 - { + 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 diff --git a/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs index 4d833c840c1..20cfb66b09a 100644 --- a/native/ocx-child/src/toml_surgeon.rs +++ b/native/ocx-child/src/toml_surgeon.rs @@ -24,7 +24,11 @@ pub struct ChildInjectedLedger { 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, } @@ -192,10 +196,114 @@ fn parse_simple_table_name(line: &str) -> Option { } } -fn root_end_index(lines: &[&str]) -> usize { +#[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() - .position(|l| is_table_header(l)) + .enumerate() + .position(|(i, l)| structural[i] && is_table_header(l.as_ref())) .unwrap_or(lines.len()) } @@ -208,9 +316,14 @@ fn is_opencodex_catalog_path(path: &str) -> bool { pub fn external_codex_model_provider(content: &str) -> Option { let lf = apply_eol(content, "\n"); let lines: Vec<&str> = lf.split('\n').collect(); - let root_end = root_end_index(&lines); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); let mut provider: Option = None; - for &line in &lines[..root_end] { + 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; @@ -225,6 +338,9 @@ pub fn external_codex_model_provider(content: &str) -> Option { 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 { @@ -235,11 +351,14 @@ pub fn external_codex_model_provider(content: &str) -> Option { } if let Some(start) = table_start { let mut has_base_url = false; - for &line in &lines[start + 1..] { - if is_table_header(line) { + 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(line, "base_url") { + if let Ok(Some(_)) = parse_single_line_string_assignment(lines[idx], "base_url") { has_base_url = true; break; } @@ -260,13 +379,21 @@ pub fn detect_unowned_root_routing_conflict(content: &str) -> Result<(), String> } let lf = apply_eol(content, "\n"); let lines: Vec<&str> = lf.split('\n').collect(); - let root_end = root_end_index(&lines); + 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 && is_ocx_routing_marker_line(lines[i - 1]); + 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" @@ -307,12 +434,13 @@ fn insert_root_lines(content_lf: &str, added: &[String]) -> String { if had_trailing_nl && lines.last().is_some_and(String::is_empty) { lines.pop(); } - let root_end = lines - .iter() - .position(|l| is_table_header(l)) - .unwrap_or(lines.len()); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); if root_end == lines.len() { - while lines.last().is_some_and(|l| l.trim().is_empty()) { + 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()); @@ -321,7 +449,7 @@ fn insert_root_lines(content_lf: &str, added: &[String]) -> String { return out; } let mut at = root_end; - while at > 0 && lines[at - 1].trim().is_empty() { + while at > 0 && structural[at - 1] && lines[at - 1].trim().is_empty() { at -= 1; } for (offset, item) in added.iter().enumerate() { @@ -340,11 +468,16 @@ fn strip_owned_root_urls( journaled_ws: Option<&str>, ) -> String { let lines: Vec<&str> = content_lf.split('\n').collect(); - let root_end = root_end_index(&lines); + 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 && is_ocx_routing_marker_line(lines[i - 1]); + 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; @@ -357,7 +490,8 @@ fn strip_owned_root_urls( } else if let Ok(Some(val)) = parse_single_line_string_assignment(lines[i], "experimental_realtime_ws_base_url") { - let marker_above = i > 0 && is_ocx_routing_marker_line(lines[i - 1]); + 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; @@ -377,11 +511,16 @@ fn strip_owned_root_urls( fn strip_owned_web_search(content_lf: &str, injected_val: Option<&str>) -> String { let lines: Vec<&str> = content_lf.split('\n').collect(); - let root_end = root_end_index(&lines); + 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 && is_ocx_routing_marker_line(lines[i - 1]); + 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 marker_above || value_match { drop[i] = true; @@ -407,11 +546,15 @@ fn ensure_root_web_search( ) -> Result<(String, Option, Option), String> { let stripped = strip_owned_web_search(content_lf, prior_injected); let lines: Vec<&str> = stripped.split('\n').collect(); - let root_end = root_end_index(&lines); + let (structural, _) = mark_structural_lines(&lines); + let root_end = root_end_index(&lines, &structural); if !disable { let mut has_user_ws = false; - for &line in &lines[..root_end] { - if parse_single_line_string_assignment(line, "web_search")?.is_some() { + 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; } @@ -429,6 +572,9 @@ fn ensure_root_web_search( 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()); @@ -459,12 +605,13 @@ fn set_root_catalog_path(content_lf: &str, catalog_path: &str) -> String { toml_escape_basic_string(catalog_path) ); let mut lines: Vec = content_lf.split('\n').map(str::to_owned).collect(); - let root_end = lines - .iter() - .position(|l| is_table_header(l)) - .unwrap_or(lines.len()); + 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) { @@ -486,12 +633,14 @@ fn set_root_catalog_path(content_lf: &str, catalog_path: &str) -> String { fn strip_opencodex_catalog_path(content_lf: &str) -> String { let lines: Vec<&str> = content_lf.split('\n').collect(); - let root_end = root_end_index(&lines); + 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) { @@ -503,48 +652,64 @@ fn strip_opencodex_catalog_path(content_lf: &str) -> String { .join("\n") } -fn upsert_table_bool(content_lf: &str, table_name: &str, key: &str, val: bool) -> String { +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 table_idx = lines - .iter() - .position(|l| parse_simple_table_name(l).as_deref() == Some(table_name)); + 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() - .position(|l| is_table_header(l)) + .enumerate() + .position(|(rel, l)| structural[start + 1 + rel] && is_table_header(l)) .map(|rel| start + 1 + rel) .unwrap_or(lines.len()); - for line in &mut lines[start + 1..end] { + 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; + return (out, Some(prior_line)); } } let mut insert_at = end; - while insert_at > start + 1 && lines[insert_at - 1].trim().is_empty() { + 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; + return (out, None); } let trimmed = content_lf.trim_end_matches('\n'); - if trimmed.is_empty() { + 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( @@ -560,9 +725,10 @@ fn apply_subagent_defaults( if stripped.ends_with('\n') && lines.last().is_some_and(String::is_empty) { lines.pop(); } - let ag_idx = lines - .iter() - .position(|l| parse_simple_table_name(l).as_deref() == Some("agents")); + 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!( @@ -584,11 +750,16 @@ fn apply_subagent_defaults( if let Some(start) = ag_idx { let end = lines[start + 1..] .iter() - .position(|l| is_table_header(l)) + .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 line in &lines[start + 1..end] { + 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") { @@ -596,7 +767,10 @@ fn apply_subagent_defaults( } } let mut insert_at = end; - while insert_at > start + 1 && lines[insert_at - 1].trim().is_empty() { + 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() { @@ -622,19 +796,23 @@ fn apply_subagent_defaults( fn strip_subagent_defaults(content_lf: &str, written: Option<&WrittenSubagentDefaults>) -> String { let lines: Vec<&str> = content_lf.split('\n').collect(); - let Some(ag_idx) = lines - .iter() - .position(|l| parse_simple_table_name(l).as_deref() == Some("agents")) - else { + 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() - .position(|l| is_table_header(l)) + .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; @@ -659,14 +837,17 @@ fn strip_subagent_defaults(content_lf: &str, written: Option<&WrittenSubagentDef } } } - let remaining_body_has_keys = (ag_idx + 1..end_idx) - .filter(|&i| !drop[i]) - .any(|i| !lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')); + let remaining_body_has_keys = (ag_idx + 1..end_idx).filter(|&i| !drop[i]).any(|i| { + !structural[i] || (!lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')) + }); if !remaining_body_has_keys { for d in &mut drop[ag_idx..end_idx] { *d = true; } - if ag_idx > 0 && lines[ag_idx - 1].trim() == MANAGED_AGENTS_TABLE_MARKER { + if ag_idx > 0 + && structural[ag_idx - 1] + && lines[ag_idx - 1].trim() == MANAGED_AGENTS_TABLE_MARKER + { drop[ag_idx - 1] = true; } } @@ -681,44 +862,59 @@ fn strip_subagent_defaults(content_lf: &str, written: Option<&WrittenSubagentDef 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 lines: Vec<&str> = content_lf.split('\n').collect(); - let Some(feat_idx) = lines - .iter() - .position(|l| parse_simple_table_name(l).as_deref() == Some("features")) - else { + 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() - .position(|l| is_table_header(l)) + .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 let Some((k, rhs)) = parse_key_value_line(lines[i]) { + 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" }) { - drop[i] = true; + 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" }) { - drop[i] = true; + 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| !lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')); + let remaining_has_keys = (feat_idx + 1..end_idx).filter(|&i| !drop[i]).any(|i| { + !structural[i] || (!lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')) + }); if !remaining_has_keys { for d in &mut drop[feat_idx..end_idx] { *d = true; @@ -780,14 +976,24 @@ pub fn apply_design_b_config( ], ); 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 { - lf = upsert_table_bool(&lf, "features", "fast_mode", fm); + 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"; - lf = upsert_table_bool(&lf, "features", "multi_agent_v2", v2_bool); + 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( @@ -803,7 +1009,9 @@ pub fn apply_design_b_config( 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)) @@ -818,8 +1026,16 @@ pub fn surgical_detach_config( 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 root_end = root_end_index(&lines); - for &line in &lines[..root_end] { + 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", @@ -836,7 +1052,9 @@ pub fn surgical_detach_config( // 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 is_ocx_routing_marker_line(lines[i - 1]) + 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") { @@ -862,7 +1080,13 @@ pub fn surgical_detach_config( ledger.replaced_user_web_search_line.as_deref(), )?; work = ws_restored; - work = strip_features_ledger(&work, ledger.wrote_fast_mode, ledger.wrote_multi_agent_v2); + 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() { @@ -876,21 +1100,32 @@ pub fn surgical_detach_config( } fn collapse_excess_blank_lines(lf: &str) -> String { - let mut out = String::with_capacity(lf.len()); - let mut consecutive_nl = 0usize; - for ch in lf.trim_start_matches('\n').chars() { - if ch == '\n' { - consecutive_nl += 1; - if consecutive_nl <= 2 { - out.push('\n'); + 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 { - consecutive_nl = 0; - out.push(ch); + blank_run = 0; } + out_lines.push(line); } - while out.ends_with("\n\n") { - out.pop(); + while out_lines.len() > 1 + && out_lines.last() == Some(&"") + && out_lines[out_lines.len() - 2].is_empty() + { + out_lines.pop(); } - out + out_lines.join("\n") } diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index e94fa0aa6b6..12548aff0c3 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -437,3 +437,67 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { let _ = fs::remove_dir_all(&scratch); } } + +#[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 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"; + fs::write(paths.codex_config_path(), pre_toml.as_bytes()).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( + &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\"")); + + // 2. Simulate crash during a second sync right after write_journal (config & catalog still hold v1) + let cat_v2 = b"{\"models\":[{\"slug\":\"m-v2\"}]}\n"; + let settings_v2 = HomeCodexSettings { + fast_mode: Some(false), + ..settings.clone() + }; + apply_attach_or_sync( + &paths, + "lnk_0123456789abcdef", + "link:t", + 10100, + cat_v2, + &settings_v2, + ) + .unwrap(); + // Roll back config.toml and catalog.json on disk to v1 as if process crashed right after write_journal + fs::write(paths.codex_config_path(), injected.as_bytes()).unwrap(); + fs::write(paths.codex_catalog_path(), cat_v1).unwrap(); + + let outcome = offline_detach(&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()); + let _ = fs::remove_dir_all(&scratch); +} diff --git a/tests/clients/link-ocx-child-conformance.test.ts b/tests/clients/link-ocx-child-conformance.test.ts index f76039a9aba..f48194781ab 100644 --- a/tests/clients/link-ocx-child-conformance.test.ts +++ b/tests/clients/link-ocx-child-conformance.test.ts @@ -170,7 +170,9 @@ function applyMultiAgentFeature(content: string, mode: HomeCodexSettings["multiA 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 = content.split("\n"); @@ -178,11 +180,18 @@ function stripSurgicalTableFeatures( if (featIdx === -1) return content; let endIdx = lines.findIndex((line, i) => i > featIdx && /^\s*\[/.test(line)); if (endIdx === -1) endIdx = lines.length; - const body = lines.slice(featIdx + 1, endIdx).filter(line => { - if (wroteFastMode !== null && new RegExp(`^\\s*fast_mode\\s*=\\s*${wroteFastMode}\\s*(?:#.*)?$`).test(line)) return false; - if (wroteMultiAgentV2 !== null && new RegExp(`^\\s*multi_agent_v2\\s*=\\s*${wroteMultiAgentV2}\\s*(?:#.*)?$`).test(line)) return false; - return true; - }); + const body: string[] = []; + for (const line of lines.slice(featIdx + 1, endIdx)) { + if (wroteFastMode !== null && new RegExp(`^\\s*fast_mode\\s*=\\s*${wroteFastMode}\\s*(?:#.*)?$`).test(line)) { + if (priorUserFastModeLine !== null) body.push(priorUserFastModeLine); + continue; + } + if (wroteMultiAgentV2 !== null && new RegExp(`^\\s*multi_agent_v2\\s*=\\s*${wroteMultiAgentV2}\\s*(?:#.*)?$`).test(line)) { + if (priorUserMultiAgentV2Line !== null) body.push(priorUserMultiAgentV2Line); + continue; + } + body.push(line); + } if (body.every(line => line.trim() === "" || line.trim().startsWith("#"))) { const next = [...lines.slice(0, featIdx), ...lines.slice(endIdx)]; return next.join("\n").replace(/\n{3,}/g, "\n\n"); @@ -230,7 +239,9 @@ function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][ 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 => { @@ -247,11 +258,17 @@ function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][ lf = setRootOpenaiBaseUrlForTarget(lf, target).content; lf = setRootRealtimeWsBaseUrl(lf, target).content; if (settings.fastMode !== null) { + if (wroteFastMode === null) { + const fmMatch = lf.split("\n").find(l => /^\s*fast_mode\s*=/.test(l)); + priorUserFastModeLine = fmMatch ?? null; + } lf = ensureFastModeFeature(lf, settings.fastMode); wroteFastMode = settings.fastMode; } if (settings.multiAgentMode !== null) { - lf = applyMultiAgentFeature(lf, settings.multiAgentMode).content; + const v2Res = applyMultiAgentFeature(lf, settings.multiAgentMode); + if (wroteMultiAgentV2 === null) priorUserMultiAgentV2Line = v2Res.priorLine; + lf = v2Res.content; wroteMultiAgentV2 = settings.multiAgentMode === "v2"; } const desiredSub = settings.subagentDefaults @@ -363,7 +380,13 @@ function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][ injectedValue: wroteWebSearch, replacedUserLine: replacedUserWebSearch ? replacedUserWebSearch.replace(/(?:\r?\n)+$/, "") : null, }).content; - surgical = stripSurgicalTableFeatures(surgical, wroteFastMode, wroteMultiAgentV2); + surgical = stripSurgicalTableFeatures( + surgical, + wroteFastMode, + priorUserFastModeLine, + wroteMultiAgentV2, + priorUserMultiAgentV2Line, + ); surgical = stripSurgicalSubagentDefaults(surgical, wroteSubagent); surgical = surgical.replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n"); if (surgical.length > 0 && !surgical.endsWith("\n")) surgical += "\n"; diff --git a/tests/fixtures/link/ocx-child-conformance.json b/tests/fixtures/link/ocx-child-conformance.json index 624826c96a2..120d44b1cf3 100644 --- a/tests/fixtures/link/ocx-child-conformance.json +++ b/tests/fixtures/link/ocx-child-conformance.json @@ -970,6 +970,50 @@ "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 } ] } From f8a5ba8d30df77fb0c3acb5ae5cb49ea720fd6fb Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 04:10:01 +0900 Subject: [PATCH 07/34] fix(link): harden ocx-child Windows ACLs, strict wire/settings bounds, and discovery precedence --- .../010_wp1_conformance_contract.md | 7 +- .../src/content/docs/guides/remote-link.md | 6 +- .../content/docs/reference/cli/lifecycle.md | 6 +- native/ocx-child/src/cli.rs | 43 ++- native/ocx-child/src/codex_settings.rs | 124 ++++++- native/ocx-child/src/daemon.rs | 9 +- native/ocx-child/src/http_wire.rs | 80 +++-- native/ocx-child/src/journal.rs | 130 ++++++- native/ocx-child/src/parity.rs | 47 ++- native/ocx-child/src/platform.rs | 338 +++++++++++++++++- native/ocx-child/src/relay.rs | 5 +- native/ocx-child/src/toml_surgeon.rs | 31 +- native/ocx-child/tests/conformance.rs | 120 ++++++- src/link/ephemeral-child.ts | 2 +- src/link/remote-windows.ts | 13 +- src/link/ssh-argv.ts | 26 +- structure/remote-link.md | 4 +- .../link-ephemeral-child-discovery.test.ts | 55 ++- .../link-ocx-child-conformance.test.ts | 72 +++- tests/clients/link-ocx-child-e2e.test.ts | 14 +- .../fixtures/link/ocx-child-conformance.json | 12 +- 21 files changed, 1036 insertions(+), 108 deletions(-) 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 index 6eca008d030..3f6edc602dd 100644 --- 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 @@ -119,6 +119,11 @@ Deterministic JSON document (`"schema": "ocx.child.conformance/1"`) containing 6 ## 3. Phase Verifiers -- `bun test tests/clients/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 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`) +- 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`) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 65c1289e827..8fb6e85d98f 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -189,13 +189,13 @@ Home-initiated connections now restart the Child after saving its connection, in ### 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`. Home probes every full `ocx` candidate first and falls back to `ocx-child` only when no runnable full `ocx` is present. +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 verifies the target loopback port is free, pipes the link credential over SSH stdin, 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`, rolls over `ocx-child serve` when settings change, and reports OCX-6 parity back to Home. Removing the link (`ocx link revoke` on Home, or offline `ocx-child detach` / `ocx-child disconnect` on the Child) stops `ocx-child serve`, restores native `~/.codex/config.toml` and catalog files (including after Codex Desktop App comment-stripping rewrites), and exits with zero leftover background daemon. +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. ### 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/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index a757ac7eeb7..f8bcdb74353 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1325,7 +1325,7 @@ 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 speak the same `sync --expect-link [--json]`, `status [--json]`, and `disconnect` / `detach` contracts: `sync` rolls over `ocx-child serve` when settings change and posts a fresh parity report, while `disconnect` / `detach` restores `~/.codex` files offline and stops `ocx-child serve` with zero background daemon left running. +Lightweight `ocx-child` Child hosts speak the same `sync --expect-link [--json]`, `status [--json]`, and `disconnect` / `detach` contracts: on an attached `ocx-child` host (which runs without a system service manager), a successful `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; `disconnect` / `detach` restores `~/.codex` files offline and stops `ocx-child serve` with zero background daemon left running. ## Remote Link Child attestations @@ -1351,8 +1351,8 @@ 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 +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 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. diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 5e1e60a105b..0b49b089a6f 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -6,6 +6,7 @@ use serde::Deserialize; 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, @@ -185,12 +186,8 @@ fn fetch_home_settings_doc( if head.status != 200 { return Err(format!("Home codex-settings returned HTTP {}", head.status)); } - let doc: HomeCodexSettingsDocument = - serde_json::from_slice(&body).map_err(|e| format!("invalid codex-settings JSON: {e}"))?; - if !doc.validate() { - return Err("Home codex-settings failed schema validation".to_owned()); - } - Ok(doc) + parse_home_codex_settings_document(&body) + .ok_or_else(|| "Home codex-settings failed strict schema validation".to_owned()) } fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> u8 { @@ -259,10 +256,26 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } }; - let existing_pid = match read_child_state(&paths.state_file) { - StateReadOutcome::Valid(st) => st.daemon_pid, + let prior_state = match read_child_state(&paths.state_file) { + StateReadOutcome::Valid(st) => Some(*st), _ => None, }; + let existing_pid = if let Some(prev) = &prior_state { + if prev.listen_port != listen_port { + if let Err(e) = stop_owned_daemon(prev) { + eprintln!( + "attach failed stopping previous relay on port {}: {e}", + prev.listen_port + ); + return 1; + } + None + } else { + prev.daemon_pid + } + } else { + None + }; if let Err(e) = ensure_port_available_for_attach(listen_port, existing_pid) { eprintln!("{e}"); return 1; @@ -853,7 +866,17 @@ fn cmd_status(args: &[String]) -> u8 { .map(|s| s.listen_port) .unwrap_or_else(default_listen_port); let live = probe_local_healthz(port, Duration::from_millis(500)); - let running = live.is_some(); + 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)), @@ -870,7 +893,7 @@ fn cmd_status(args: &[String]) -> u8 { "runtimeRole": if state_opt.is_some() { "client" } else { "standalone" }, "proxy": { "running": running, - "pid": live.as_ref().map(|h| h.pid), + "pid": verified_live.as_ref().map(|h| h.pid), "health": { "ok": running, "url": format!("http://127.0.0.1:{port}/healthz"), diff --git a/native/ocx-child/src/codex_settings.rs b/native/ocx-child/src/codex_settings.rs index 79d2729b003..6330ba3afa0 100644 --- a/native/ocx-child/src/codex_settings.rs +++ b/native/ocx-child/src/codex_settings.rs @@ -35,6 +35,120 @@ pub struct HomeCodexSettingsDocument { pub settings: HomeCodexSettings, } +pub fn sanitize_safe_label(value: &str, max_utf16_units: usize) -> Option { + let trimmed = value.trim(); + 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 @@ -43,11 +157,11 @@ impl HomeCodexSettings { return false; } if let Some(sub) = &self.subagent_defaults { - if sub.model.trim().is_empty() || sub.model.len() > 256 { + if sanitize_safe_label(&sub.model, 256).is_none() { return false; } if let Some(eff) = &sub.reasoning_effort - && (eff.trim().is_empty() || eff.len() > 64) + && !is_valid_reasoning_effort_token(eff) { return false; } @@ -59,10 +173,8 @@ impl HomeCodexSettings { impl HomeCodexSettingsDocument { pub fn validate(&self) -> bool { self.version == HOME_CODEX_SETTINGS_VERSION - && !self.home.name.trim().is_empty() - && self.home.name.len() <= 255 - && !self.home.version.trim().is_empty() - && self.home.version.len() <= 64 + && sanitize_safe_label(&self.home.name, 255).is_some() + && sanitize_safe_label(&self.home.version, 64).is_some() && self.settings.validate() } } diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index 09f0f3cf900..d1200afba05 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -114,10 +114,10 @@ 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 verified_pid = match (state.daemon_pid, &live_health) { - (_, Some(h)) + (Some(expected_pid), Some(h)) if h.service == "opencodex" && h.ephemeral_child == Some(true) - && (state.daemon_pid.is_none() || state.daemon_pid == Some(h.pid)) => + && expected_pid == h.pid => { Some(h.pid) } @@ -160,6 +160,11 @@ pub fn stop_owned_daemon(state: &ChildStateV1) -> Result<(), String> { )); } } + if is_port_bound(port) { + return Err(format!( + "listener on 127.0.0.1:{port} remained bound; refusing unverified shutdown" + )); + } Ok(()) } diff --git a/native/ocx-child/src/http_wire.rs b/native/ocx-child/src/http_wire.rs index 7c316dec9f8..30cd7280e45 100644 --- a/native/ocx-child/src/http_wire.rs +++ b/native/ocx-child/src/http_wire.rs @@ -411,25 +411,39 @@ pub fn read_bounded_body( } BodyFraming::Chunked => { let mut out = Vec::new(); + let mut meta_budget = HUB_RELAY_HEADER_MAX_BYTES; loop { - let mut size_line = String::new(); - let n = reader.read_line(&mut size_line)?; - if n == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "unexpected chunk eof", - )); - } + 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 + // Consume trailing headers up to blank CRLF within bounded metadata budget loop { - let mut trailer = String::new(); - let tn = reader.read_line(&mut trailer)?; - if tn == 0 || trailer == "\r\n" || trailer == "\n" { + 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; } } @@ -509,21 +523,41 @@ pub fn stream_upstream_to_chunked_client( } BodyFraming::Chunked => { let mut buf = [0u8; 16 * 1024]; + let mut meta_budget = HUB_RELAY_HEADER_MAX_BYTES; loop { - let mut size_line = String::new(); - let n = reader.read_line(&mut size_line)?; - if n == 0 { - break; - } + 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 mut trailer = String::new(); - let tn = reader.read_line(&mut trailer)?; - if tn == 0 || trailer == "\r\n" || trailer == "\n" { + 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; } } @@ -552,6 +586,12 @@ pub fn stream_upstream_to_chunked_client( } 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", + )); + } } } } diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index 8a6e7686abb..d7c435e2055 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -282,6 +282,24 @@ pub fn apply_attach_or_sync( }; let (pre_cat_existed, pre_cat_b64, prev_cat_sha) = if let Some(prev) = &existing_journal { + 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_ours = existing_cat_sha == prev.latest_catalog_sha256 + || Some(existing_cat_sha.as_str()) == prev.previous_catalog_sha256.as_deref() + || prev + .pre_attach_catalog_bytes_b64 + .as_ref() + .and_then(|b64| BASE64.decode(b64).ok()) + .is_some_and(|p| p == existing_cat); + if !matches_ours { + return Err( + "client_catalog_ownership_changed: opencodex-catalog.json was modified externally" + .to_owned(), + ); + } + } ( prev.pre_attach_catalog_existed, prev.pre_attach_catalog_bytes_b64.clone(), @@ -446,16 +464,48 @@ pub fn offline_detach( } 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(); if journal.pre_attach_models_cache_existed { - if let Some(b64) = &journal.pre_attach_models_cache_bytes_b64 - && let Ok(prior_cache) = BASE64.decode(b64) - { - let _ = write_regular_atomic(&cache_path, &prior_cache); + 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 { + write_regular_atomic(&cache_path, &prior_cache).map_err(DetachError::Io)?; + } + } else if let Some(reconciled) = reconcile_modified_models_cache( + &cur_cache, + Some(&prior_cache), + &paths.catalog_backup_file, + ) { + write_regular_atomic(&cache_path, &reconciled).map_err(DetachError::Io)?; + } + } else if cache_untouched { + match fs::remove_file(&cache_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(DetachError::Io(format!( + "failed removing models_cache.json: {e}" + ))); + } } - } else if let Ok(cur_cache) = fs::read(&cache_path) - && Some(sha256_hex(&cur_cache)) == journal.latest_models_cache_sha256 + } else if let Some(reconciled) = + reconcile_modified_models_cache(&cur_cache, None, &paths.catalog_backup_file) { - let _ = fs::remove_file(&cache_path); + write_regular_atomic(&cache_path, &reconciled).map_err(DetachError::Io)?; } } @@ -463,3 +513,69 @@ pub fn offline_detach( let _ = fs::remove_file(&paths.journal_file); Ok(outcome) } + +/// Preserves newly observed native models (`opencodex_account_observed_native`) and prior cache +/// entries while stripping Child-injected Home catalog slugs when `models_cache.json` changed +/// externally while attached. +fn reconcile_modified_models_cache( + cur_cache_bytes: &[u8], + prior_cache_bytes: Option<&[u8]>, + last_child_catalog_path: &Path, +) -> Option> { + let mut cur_val: serde_json::Value = serde_json::from_slice(cur_cache_bytes).ok()?; + let cur_models = cur_val.get("models")?.as_array()?.clone(); + let child_slugs: HashSet = fs::read(last_child_catalog_path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| { + v.get("models") + .and_then(serde_json::Value::as_array) + .cloned() + }) + .unwrap_or_default() + .into_iter() + .filter_map(|m| { + m.get("slug") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .collect(); + if child_slugs.is_empty() { + return None; + } + let prior_slugs: HashSet = prior_cache_bytes + .and_then(|b| serde_json::from_slice::(b).ok()) + .and_then(|v| { + v.get("models") + .and_then(serde_json::Value::as_array) + .cloned() + }) + .unwrap_or_default() + .into_iter() + .filter_map(|m| { + m.get("slug") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .collect(); + let kept: Vec = cur_models + .into_iter() + .filter(|entry| { + let slug = entry + .get("slug") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let is_obs = entry + .get("opencodex_account_observed_native") + .and_then(serde_json::Value::as_bool) + == Some(true); + is_obs || prior_slugs.contains(slug) || !child_slugs.contains(slug) + }) + .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).ok()?; + out.push(b'\n'); + Some(out) +} diff --git a/native/ocx-child/src/parity.rs b/native/ocx-child/src/parity.rs index fd73b2f1093..3d5be5ad6a1 100644 --- a/native/ocx-child/src/parity.rs +++ b/native/ocx-child/src/parity.rs @@ -53,6 +53,22 @@ impl LinkChildParityReport { } } +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 { @@ -63,17 +79,24 @@ pub fn parse_link_child_parity_report(raw_json: &str) -> Option 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 index 289de45be4c..bdbb6e98450 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -216,12 +216,329 @@ pub fn reject_symlink(path: &Path) -> Result<(), String> { 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::os::windows::ffi::OsStrExt; + 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, + } + + #[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; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Ptr; + fn CloseHandle(handle: Ptr) -> i32; + fn LocalFree(memory: 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) + } + + pub fn harden_and_verify(path: &Path, directory: bool) -> Result<(), String> { + let user = current_user_sid()?; + let mut system = well_known_sid(22)?; + let mut admins = well_known_sid(26)?; + let sids: [Ptr; 3] = [ + user[0] as Ptr, + system.as_mut_ptr().cast(), + admins.as_mut_ptr().cast(), + ]; + // 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()); + } + } + 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) + } + + 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 + && (!root || 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(()) + } +} + pub fn ensure_private_dir(dir: &Path) -> Result<(), String> { reject_symlink(dir)?; #[cfg(unix)] @@ -238,7 +555,13 @@ pub fn ensure_private_dir(dir: &Path) -> Result<(), String> { fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) .map_err(|e| format!("failed to chmod 0700 {}: {e}", dir.display()))?; } - #[cfg(not(unix))] + #[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)?; @@ -265,6 +588,12 @@ pub fn write_private_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { let mut f = opts .open(&tmp) .map_err(|e| format!("failed to create temp file {}: {e}", tmp.display()))?; + #[cfg(windows)] + if let Err(e) = windows_private_acl::harden_and_verify(&tmp, false) { + drop(f); + let _ = fs::remove_file(&tmp); + return Err(e); + } 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())); @@ -348,6 +677,8 @@ impl FileLock { let parent = path.parent().ok_or(LockAcquireError::Io)?; ensure_private_dir(parent).map_err(|_| LockAcquireError::Io)?; reject_symlink(path).map_err(|_| LockAcquireError::Io)?; + #[cfg(windows)] + let existed = path.exists(); let mut opts = OpenOptions::new(); opts.read(true).write(true).create(true).truncate(false); #[cfg(unix)] @@ -356,6 +687,11 @@ impl FileLock { opts.mode(0o600); } let file = opts.open(path).map_err(|_| LockAcquireError::Io)?; + #[cfg(windows)] + if !existed { + windows_private_acl::harden_and_verify(path, false) + .map_err(|_| LockAcquireError::Io)?; + } #[cfg(unix)] { use std::os::fd::AsRawFd; diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index ce609e3e155..543758f4dde 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -561,10 +561,7 @@ fn handle_local_readyz( { *g = Some(gen_val.clone()); } - let gen_opt = up - .connection_generation - .or_else(|| shared.last_generation.lock().ok().and_then(|g| g.clone())); - let link_obj = match gen_opt { + let link_obj = match up.connection_generation { Some(g) => serde_json::json!({ "state": "available", "connectionGeneration": g }), None => serde_json::json!({ "state": "available" }), }; diff --git a/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs index 20cfb66b09a..701469e689f 100644 --- a/native/ocx-child/src/toml_surgeon.rs +++ b/native/ocx-child/src/toml_surgeon.rs @@ -63,9 +63,14 @@ pub fn toml_escape_basic_string(value: &str) -> String { 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), } } @@ -91,9 +96,27 @@ fn parse_toml_quoted_string(raw_val: &str) -> Option { 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 == '"' { @@ -837,10 +860,10 @@ fn strip_subagent_defaults(content_lf: &str, written: Option<&WrittenSubagentDef } } } - let remaining_body_has_keys = (ag_idx + 1..end_idx).filter(|&i| !drop[i]).any(|i| { - !structural[i] || (!lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')) - }); - if !remaining_body_has_keys { + 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; } diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 12548aff0c3..5ba2ac2b5a6 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -7,8 +7,12 @@ use serde::Deserialize; 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::http_wire::{FramingError, validate_and_sanitize_request_headers}; use ocx_child::journal::{DetachError, DetachOutcome, apply_attach_or_sync, offline_detach}; use ocx_child::parity::parse_link_child_parity_report; use ocx_child::relay_auth::{ @@ -313,6 +317,22 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { ) .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" => {} @@ -379,6 +399,10 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { 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(&paths, false) { Ok(DetachOutcome::ExactRestore) => { assert_eq!( @@ -407,6 +431,24 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { "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), } @@ -501,3 +543,79 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { assert!(!paths.codex_catalog_path().exists()); let _ = fs::remove_dir_all(&scratch); } + +#[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()); + + // 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( + &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( + &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(&paths, false), + Err(DetachError::Conflict(_)) + )); + assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), user_cat); + let _ = fs::remove_dir_all(&scratch); +} diff --git a/src/link/ephemeral-child.ts b/src/link/ephemeral-child.ts index 46481cee43b..ad0035f7683 100644 --- a/src/link/ephemeral-child.ts +++ b/src/link/ephemeral-child.ts @@ -8,7 +8,7 @@ export interface EphemeralChildConfigProjection { readonly port: number; - readonly runtimeRole: "standalone" | "client"; + readonly runtimeRole?: "standalone" | null | undefined; readonly ephemeralChild: true; } diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index d9415cbdeb7..c24e81e8356 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -144,13 +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) -$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) +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) +$h=if($env:OPENCODEX_HOME){$env:OPENCODEX_HOME}else{"$u\.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 d8c921c9efb..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 @@ -155,15 +169,7 @@ while :; do try_ocx "\${directory:-.}/ocx" "$@" case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac 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`; +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/structure/remote-link.md b/structure/remote-link.md index 4ebb9155448..4be3ea0af0b 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -186,8 +186,8 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src ## 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 every full `ocx` path first and falls 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`). +`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` if any listener already occupies `` (`502 child_preflight_failed` on an SSH transport drop), enrolls through `connect --link --key-stdin`, starts `ocx-child serve` via `restart`, and verifies `role: "client"` `/healthz` plus an authenticated `/v1/catalog` callback. +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. Shared golden vectors in `tests/fixtures/link/ocx-child-conformance.json` lock TypeScript and Rust parity. 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`. diff --git a/tests/clients/link-ephemeral-child-discovery.test.ts b/tests/clients/link-ephemeral-child-discovery.test.ts index 85dd7bf800a..395cd4ea838 100644 --- a/tests/clients/link-ephemeral-child-discovery.test.ts +++ b/tests/clients/link-ephemeral-child-discovery.test.ts @@ -1,6 +1,7 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +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"; @@ -14,6 +15,21 @@ function writeExecScript(path: string, body: string): void { 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")) @@ -22,6 +38,13 @@ function sandboxRemoteScript(script: string, root: string): string { .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"); @@ -30,18 +53,26 @@ describe("ephemeral ocx-child SSH discovery and sync fan-out argv", () => { expect(classifyVersionProbe(`opencodex ${match![1]!}`)).toBeNull(); }); - test("Windows remoteOcxArgv and windowsPinnedSyncArgv stay below POWERSHELL_ENCODED_MAX and preserve OcxJob containment", () => { + 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("[IO.File]::Exists(\"$h\\child\\state.json\")){$k+$f}else{$f+$k}"); + } }); - test.skipIf(process.platform === "win32")("POSIX remoteOcxArgv prefers runnable full ocx and falls back to ocx-child when full ocx is absent or broken", () => { + 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 }); @@ -58,16 +89,28 @@ describe("ephemeral ocx-child SSH discovery and sync fan-out argv", () => { const argv = remoteOcxArgv(["config", "show", "--json"], "posix"); argv[2] = sandboxRemoteScript(argv[2]!, scratch); const both = Bun.spawnSync([...argv], { - env: { HOME: home, PATH: `/usr/bin:/bin:${pathBin}` }, + 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, PATH: `/usr/bin:/bin:${pathBin}` }, + env: { HOME: home, OPENCODEX_HOME: join(scratch, "empty-ocx"), PATH: `${utilDir}:${pathBin}` }, stdout: "pipe", stderr: "pipe", }); @@ -83,7 +126,7 @@ describe("ephemeral ocx-child SSH discovery and sync fan-out argv", () => { const syncCmd = remotePinnedSyncArgv("lnk_0123456789abcdef", "posix"); syncCmd[2] = sandboxRemoteScript(syncCmd[2]!, scratch); const syncRun = Bun.spawnSync([...syncCmd], { - env: { HOME: home2, PATH: `${pathBin}:/usr/bin:/bin` }, + env: { HOME: home2, PATH: `${pathBin}:${utilDir}` }, stdout: "pipe", stderr: "pipe", }); diff --git a/tests/clients/link-ocx-child-conformance.test.ts b/tests/clients/link-ocx-child-conformance.test.ts index f48194781ab..921463246bb 100644 --- a/tests/clients/link-ocx-child-conformance.test.ts +++ b/tests/clients/link-ocx-child-conformance.test.ts @@ -1,8 +1,10 @@ // 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 { readFileSync } from "node:fs"; +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 } from "../helpers/repo-root"; +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"; @@ -12,7 +14,8 @@ 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 { forwardLinkRequestHeaders } from "../../src/client/link-relay"; +import { handleLinkIngress } from "../../src/client/link-ingress"; +import { forwardLinkRequestHeaders, HOME_INITIATED_LINK_TUNNEL } from "../../src/client/link-relay"; import { applyEol, dominantEol, @@ -366,7 +369,7 @@ function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][ attachOk: true, detachOutcome: "refuse_conflict", finalToml: currentToml, - finalCatalog: row.preAttachCatalog, + finalCatalog: currentCatalog, }; } } @@ -411,16 +414,28 @@ describe("ocx-child shared golden conformance vectors (INV-LINK-CHILD-01)", () = }); 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 and CALLER_CREDENTIAL_HEADERS stripping", () => { + 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) { @@ -447,9 +462,31 @@ describe("ocx-child shared golden conformance vectors (INV-LINK-CHILD-01)", () = 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, "ocx_data_" + "1".repeat(40), "/v1/responses"); + 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}"); + } } }); @@ -500,6 +537,8 @@ describe("ocx-child shared golden conformance vectors (INV-LINK-CHILD-01)", () = 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); @@ -521,4 +560,25 @@ describe("ocx-child shared golden conformance vectors (INV-LINK-CHILD-01)", () = startedAt: "2026-11-07T00:00:00.000Z", }, 10100)).toBe(false); }); + + test("Rust conformance runner passes against the identical shared fixture when cargo is installed", () => { + const exeSuffix = process.platform === "win32" ? ".exe" : ""; + const realHome = process.env.OCX_REAL_HOME?.trim() || userInfo().homedir; + const cargo = Bun.which("cargo") ?? join(realHome, ".cargo", "bin", `cargo${exeSuffix}`); + if (!existsSync(cargo)) return; + const run = Bun.spawnSync( + [cargo, "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, + }, + ); + 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 index 274d373a197..ac97ed84150 100644 --- a/tests/clients/link-ocx-child-e2e.test.ts +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -200,12 +200,24 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- const tunnelPort = homeHubLinkServer.port!; writeLinkStore(linkStorePath(), { version: 1, listenerPort: tunnelPort, links: [] }); + 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"]) { + for (const sysDir of ["/bin", "/usr/bin"]) { + const p = join(sysDir, util); + if (existsSync(p)) { + symlinkSync(p, join(privateUtils, util)); + break; + } + } + } + const childEnv = { HOME: childUserHome, OPENCODEX_HOME: childOcxHome, CODEX_HOME: childCodexHome, OCX_CHILD_LISTEN_PORT: String(childListenPort), - PATH: "/usr/bin:/bin", + PATH: privateUtils, }; const runShellAsync = async (argv: string[], stdin?: Uint8Array): Promise => { diff --git a/tests/fixtures/link/ocx-child-conformance.json b/tests/fixtures/link/ocx-child-conformance.json index 120d44b1cf3..a6deeddf0e8 100644 --- a/tests/fixtures/link/ocx-child-conformance.json +++ b/tests/fixtures/link/ocx-child-conformance.json @@ -784,6 +784,16 @@ "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": [ @@ -926,7 +936,7 @@ "expectedAttachOk": true, "expectedDetachOutcome": "refuse_conflict", "expectedFinalToml": "approval_policy = \"on-request\"\nopenai_base_url = \"http://127.0.0.1:10100/v1\n", - "expectedFinalCatalog": null + "expectedFinalCatalog": "{\"models\":[{\"slug\":\"home-m1\"}]}\n" }, { "id": "tampered_catalog_refused", From bd7a68c31ace3662ca8b81bd2706dd3f9aaabc93 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 04:39:07 +0900 Subject: [PATCH 08/34] fix(link): preserve interrupted-sync digests, rollback failed reattach, and align locale/conformance parity --- .../010_wp1_conformance_contract.md | 9 +- .../050_wp5_pr_review_fixes.md | 5 +- .../docs/fr/reference/cli/lifecycle.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 2 +- .../docs/ru/reference/cli/lifecycle.md | 2 +- .../docs/tr/reference/cli/lifecycle.md | 2 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- native/ocx-child/src/cli.rs | 115 ++++++- native/ocx-child/src/codex_settings.rs | 10 +- native/ocx-child/src/journal.rs | 316 ++++++++++++------ native/ocx-child/src/platform.rs | 3 +- native/ocx-child/src/toml_surgeon.rs | 12 +- native/ocx-child/tests/conformance.rs | 164 ++++++++- native/ocx-child/tests/relay_and_lifecycle.rs | 38 +++ src/link/remote-windows.ts | 2 +- .../link-ephemeral-child-discovery.test.ts | 1 + .../link-ocx-child-conformance.test.ts | 162 ++++++--- tests/clients/link-ocx-child-e2e.test.ts | 32 +- .../fixtures/link/ocx-child-conformance.json | 50 +++ 21 files changed, 730 insertions(+), 203 deletions(-) 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 index 3f6edc602dd..085eb40623f 100644 --- 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 @@ -102,11 +102,11 @@ Deterministic JSON document (`"schema": "ocx.child.conformance/1"`) containing 6 - `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/ocx-child-conformance.test.ts` & Test-Layout Shards +### 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 `"ocx-child-conformance.test.ts": "clients"` in BOTH: +- Registers `"link-ocx-child-conformance.test.ts": "clients"` in BOTH: - `scripts/test-layout/layout-clients.json` - `tests/fixtures/test-layout-expected.json` @@ -122,8 +122,3 @@ Deterministic JSON document (`"schema": "ocx.child.conformance/1"`) containing 6 - `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`) -- 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`) 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 index ebc3d5f68b9..e9e695064f1 100644 --- 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 @@ -14,7 +14,7 @@ 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`). - - In `offline_detach`, treat `Some(cur_sha.as_str()) == journal.pre_attach_config_sha256.as_deref()` (or `!config_path.exists() && !journal.pre_attach_config_existed`) under `untouched_by_external_writer` as `ExactRestore` as well, so a crash immediately after `write_journal` (before `config.toml` or after partial `catalog.json` write) detaches cleanly back to exact pre-attach state. + - 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. @@ -28,7 +28,8 @@ - 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 a Rust unit/integration test simulating a crash after `write_journal` + `catalog.json` write (before `config.toml` write) and asserting `offline_detach` restores exact pre-attach state. +- 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 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 6571c54fb29..d8b88409a26 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -169,7 +169,7 @@ Sur un Home Remote Link, après la validation et l’enregistrement d’un catal 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. -> **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 :** `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 (sur un hôte léger `ocx-child`, un `sync` réussi démarre `ocx-child serve` s'il est absent, le relance lorsque `runtimeStartedAt < lastApplyAt` et publie la parité au mieux hors mode `stale`). 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). 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/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 9598af61cd3..2c01f299bab 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -176,7 +176,7 @@ Remote Link の Home では、最新のカタログが正常にコミットさ 伝播は Child の Codex アプリや app-server の再起動を要求しません。必要な場合は GOAT を使うか、その Child で `ocx sync --restart-codex` を実行してください。ただし、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 sync` は Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は、再起動フラグなしでもランタイムを再起動します(軽量 `ocx-child` ホストでは未起動時に `ocx-child serve` を起動し、`runtimeStartedAt < lastApplyAt` のときにローテーションして非 stale 同期時にベストエフォートで parity を報告します)。再起動に失敗した場合は以前のランタイムが動作し続け、コマンドは 0 以外で終了します。`ocx status --json` と `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 42a69851190..aa7a80501cf 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -264,7 +264,7 @@ Remote Link Home에서는 최신 카탈로그가 성공적으로 커밋된 뒤 ` 전파는 Child의 Codex 앱이나 app-server 재시작을 요청하지 않습니다. 재시작이 필요하면 GOAT를 사용하거나 해당 Child에서 `ocx sync --restart-codex`를 실행하세요. 다만 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 sync`는 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 런타임을 재시작합니다(경량 `ocx-child` 호스트에서는 릴레이가 없을 때 `ocx-child serve`를 시작하고 `runtimeStartedAt < lastApplyAt`이면 교체하며, `stale`이 아닌 동기화에서 최선을 다해 parity를 보고합니다). 재시작이 실패하면 이전 런타임이 계속 서비스하고 명령은 0이 아닌 코드로 끝납니다. `ocx status --json`과 `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/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index ecfdb2803ae..ba3a507b3e7 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -265,7 +265,7 @@ credential'ы и не выполняет repair. Рассылка никогда не запрашивает перезапуск приложения Codex или app-server на дочерней машине. Для этого используйте GOAT или выполните `ocx sync --restart-codex` на самой машине. При этом её синхронизация с закреплённым 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:** `ocx sync` также применяет настройки Codex с Home и, если клиентский рантайм запущен раньше этого применения, перезапускает его даже без флага перезапуска (на легковесном хосте `ocx-child` успешный `sync` запускает `ocx-child serve`, если ретранслятор не запущен, перезапускает его при `runtimeStartedAt < lastApplyAt` и отправляет отчёт о паритете по принципу best-effort при не-`stale` синхронизации). Если перезапуск не удался, прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. Соответствующие поля `ocx status --json` и `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/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index a2af4f761d1..5bab33d1221 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -262,7 +262,7 @@ Remote Link Home üzerinde güncel katalog başarıyla kaydedildikten sonra `ocx 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. -> **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:** `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 (hafif `ocx-child` ana makinelerinde başarılı bir `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). 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. 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 9ea1b29319f..8dbe0a82030 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 @@ -172,7 +172,7 @@ ocx status --json 传播不会请求重启 Child 上的 Codex 应用或 app-server。如需重启,请使用 GOAT 或在该 Child 上运行 `ocx sync --restart-codex`。不过,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 sync` 还会应用 Home 的 Codex 设置;如果客户端运行时在该次应用之前启动,即使没有重启参数也会重启运行时(在轻量级 `ocx-child` 主机上,成功的 `sync` 会在中继未运行时启动 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 时轮换进程,并在非 `stale` 同步时尽力上报 parity)。重启失败时,原有运行时继续提供服务,命令以非零状态退出。`ocx status --json` 和 `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 c47b087fe6b..44eb6d1db6a 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 @@ -160,7 +160,7 @@ ocx status --json 傳播不會要求重新啟動 Child 上的 Codex 應用程式或 app-server。若需要重新啟動,請使用 GOAT 或在該 Child 執行 `ocx sync --restart-codex`。不過,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 sync` 也會套用 Home 的 Codex 設定;如果用戶端執行階段在該次套用之前啟動,即使沒有重新啟動旗標也會重新啟動執行階段(在輕量級 `ocx-child` 主機上,成功的 `sync` 會在中繼未執行時啟動 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 時輪替程序,並在非 `stale` 同步時盡力回報 parity)。重新啟動失敗時,原有執行階段會繼續提供服務,指令以非零狀態結束。`ocx status --json` 與 `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/src/cli.rs b/native/ocx-child/src/cli.rs index 0b49b089a6f..21a7178f2ec 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -16,7 +16,7 @@ use crate::journal::{DetachError, apply_attach_or_sync, offline_detach}; use crate::parity::LinkChildParityReport; use crate::platform::{ FileLock, LockAcquireError, SecretBytes, format_iso8601_utc, now_epoch_ms, random_hex, - resolve_codex_home, resolve_opencodex_home, + resolve_codex_home, resolve_opencodex_home, write_private_atomic, write_regular_atomic, }; use crate::relay::{ RelayCredentials, fetch_home_authenticated, post_child_parity_report, serve_foreground, @@ -190,6 +190,81 @@ fn fetch_home_settings_doc( .ok_or_else(|| "Home codex-settings failed strict schema validation".to_owned()) } +struct AttachRollbackSnapshot { + 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 AttachRollbackSnapshot { + fn capture(paths: &ChildPaths, prior_state: Option) -> Self { + let prior_was_serving = prior_state.as_ref().is_some_and(|st| { + probe_local_healthz(st.listen_port, Duration::from_millis(300)).is_some() + }); + Self { + prior_state, + prior_was_serving, + journal: fs::read(&paths.journal_file).ok(), + config: fs::read(paths.codex_config_path()).ok(), + catalog: fs::read(paths.codex_catalog_path()).ok(), + catalog_backup: fs::read(&paths.catalog_backup_file).ok(), + models_cache: fs::read(paths.codex_models_cache_path()).ok(), + key: fs::read(&paths.key_file).ok().map(SecretBytes::new), + state_bytes: fs::read(&paths.state_file).ok(), + } + } + + fn restore_path(path: &std::path::Path, bytes: Option<&[u8]>, private: bool) { + match bytes { + Some(b) => { + let _ = if private { + write_private_atomic(path, b) + } else { + write_regular_atomic(path, b) + }; + } + None => { + let _ = fs::remove_file(path); + } + } + } + + fn restore(&self, paths: &ChildPaths) { + Self::restore_path(&paths.journal_file, self.journal.as_deref(), true); + Self::restore_path(&paths.codex_config_path(), self.config.as_deref(), false); + Self::restore_path(&paths.codex_catalog_path(), self.catalog.as_deref(), false); + Self::restore_path( + &paths.catalog_backup_file, + self.catalog_backup.as_deref(), + true, + ); + Self::restore_path( + &paths.codex_models_cache_path(), + self.models_cache.as_deref(), + false, + ); + Self::restore_path( + &paths.key_file, + self.key.as_ref().map(SecretBytes::as_slice), + true, + ); + Self::restore_path(&paths.state_file, self.state_bytes.as_deref(), true); + if let Some(mut prev) = self.prior_state.clone() + && self.prior_was_serving + && probe_local_healthz(prev.listen_port, Duration::from_millis(250)).is_none() + && start_or_rollover_daemon(paths, &mut prev, None).is_ok() + { + let _ = write_child_state(&paths.state_file, &prev); + } + } +} + 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; @@ -260,22 +335,10 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> StateReadOutcome::Valid(st) => Some(*st), _ => None, }; - let existing_pid = if let Some(prev) = &prior_state { - if prev.listen_port != listen_port { - if let Err(e) = stop_owned_daemon(prev) { - eprintln!( - "attach failed stopping previous relay on port {}: {e}", - prev.listen_port - ); - return 1; - } - None - } else { - prev.daemon_pid - } - } else { - 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 1; @@ -345,6 +408,8 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } }; + let rollback = AttachRollbackSnapshot::capture(&paths, prior_state.clone()); + let journal = match apply_attach_or_sync( &paths, &link_id, @@ -355,12 +420,14 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> ) { Ok(j) => j, Err(e) => { + rollback.restore(&paths); eprintln!("{e}"); return 1; } }; if let Err(e) = write_link_key(&paths.key_file, raw_key_str) { + rollback.restore(&paths); eprintln!("{e}"); return 1; } @@ -371,10 +438,22 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> let shutdown_secret = match random_hex(24) { Ok(s) => s, Err(e) => { + rollback.restore(&paths); eprintln!("{e}"); return 1; } }; + if let Some(prev) = &prior_state + && prev.listen_port != listen_port + && let Err(e) = stop_owned_daemon(prev) + { + rollback.restore(&paths); + eprintln!( + "attach failed stopping previous relay on port {}: {e}", + prev.listen_port + ); + return 1; + } let mut state = ChildStateV1 { schema: CHILD_STATE_SCHEMA.to_owned(), link_id: link_id.clone(), @@ -400,6 +479,7 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> }), }; if let Err(e) = write_child_state(&paths.state_file, &state) { + rollback.restore(&paths); eprintln!("{e}"); return 1; } @@ -419,6 +499,7 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> let _ = post_child_parity_report(tunnel_port, &creds, &report); } Err(e) => { + rollback.restore(&paths); eprintln!("{e}"); return 1; } diff --git a/native/ocx-child/src/codex_settings.rs b/native/ocx-child/src/codex_settings.rs index 6330ba3afa0..bf64adc775f 100644 --- a/native/ocx-child/src/codex_settings.rs +++ b/native/ocx-child/src/codex_settings.rs @@ -35,8 +35,16 @@ pub struct HomeCodexSettingsDocument { 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(); + 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; diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index d7c435e2055..26ff575c49f 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; @@ -41,9 +41,19 @@ pub struct ChildJournalV1 { 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, 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, @@ -215,6 +225,26 @@ pub fn apply_attach_or_sync( listen_port: u16, catalog_bytes: &[u8], settings: &HomeCodexSettings, +) -> Result { + apply_attach_or_sync_with_fault( + paths, + link_id, + api_key_id, + listen_port, + catalog_bytes, + settings, + None, + ) +} + +pub fn apply_attach_or_sync_with_fault( + paths: &ChildPaths, + link_id: &str, + api_key_id: &str, + listen_port: u16, + catalog_bytes: &[u8], + settings: &HomeCodexSettings, + fault: Option, ) -> Result { assert_no_full_ocx_conflict(paths)?; let catalog_models = validate_catalog_json(catalog_bytes)?; @@ -256,16 +286,24 @@ pub fn apply_attach_or_sync( 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_sha.as_deref() == Some(prev.latest_injected_config_sha256.as_str()) - || cur_sha == prev.pre_attach_config_sha256 - || (cur_sha.is_some() && cur_sha == prev.previous_injected_config_sha256)); + && (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(), - Some(prev.latest_injected_config_sha256.clone()), + carried_prev_cfg, still_untouched, ) } else { @@ -282,28 +320,35 @@ pub fn apply_attach_or_sync( }; 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_ours = existing_cat_sha == prev.latest_catalog_sha256 - || Some(existing_cat_sha.as_str()) == prev.previous_catalog_sha256.as_deref() - || prev - .pre_attach_catalog_bytes_b64 - .as_ref() - .and_then(|b64| BASE64.decode(b64).ok()) - .is_some_and(|p| p == existing_cat); - if !matches_ours { + 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(), - Some(prev.latest_catalog_sha256.clone()), + carried_prev_cat, ) } else { match fs::read(&catalog_path) { @@ -313,15 +358,37 @@ pub fn apply_attach_or_sync( } }; - let (pre_cache_existed, pre_cache_b64) = if let Some(prev) = &existing_journal { + let (pre_cache_existed, 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))), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => (false, None), + 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())), } }; @@ -350,15 +417,25 @@ pub fn apply_attach_or_sync( 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, 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, 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 fault == Some(JournalWriteFault::AfterCacheWrite) { + return Err("injected fault: AfterCacheWrite".to_owned()); + } write_regular_atomic(&config_path, next_cfg_text.as_bytes())?; Ok(journal) } @@ -398,6 +475,58 @@ pub fn offline_detach( } } + 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, @@ -463,48 +592,18 @@ pub fn offline_detach( } } - 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(); - 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 { - write_regular_atomic(&cache_path, &prior_cache).map_err(DetachError::Io)?; - } - } else if let Some(reconciled) = reconcile_modified_models_cache( - &cur_cache, - Some(&prior_cache), - &paths.catalog_backup_file, - ) { - write_regular_atomic(&cache_path, &reconciled).map_err(DetachError::Io)?; - } - } else if cache_untouched { - match fs::remove_file(&cache_path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return Err(DetachError::Io(format!( - "failed removing models_cache.json: {e}" - ))); - } + match cache_plan { + CacheDetachPlan::Noop => {} + CacheDetachPlan::Remove => match fs::remove_file(&cache_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(DetachError::Io(format!( + "failed removing models_cache.json: {e}" + ))); } - } else if let Some(reconciled) = - reconcile_modified_models_cache(&cur_cache, None, &paths.catalog_backup_file) - { + }, + CacheDetachPlan::Write(reconciled) => { write_regular_atomic(&cache_path, &reconciled).map_err(DetachError::Io)?; } } @@ -521,61 +620,84 @@ fn reconcile_modified_models_cache( cur_cache_bytes: &[u8], prior_cache_bytes: Option<&[u8]>, last_child_catalog_path: &Path, -) -> Option> { - let mut cur_val: serde_json::Value = serde_json::from_slice(cur_cache_bytes).ok()?; - let cur_models = cur_val.get("models")?.as_array()?.clone(); - let child_slugs: HashSet = fs::read(last_child_catalog_path) - .ok() - .and_then(|b| serde_json::from_slice::(&b).ok()) - .and_then(|v| { - v.get("models") - .and_then(serde_json::Value::as_array) - .cloned() - }) - .unwrap_or_default() - .into_iter() +) -> 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_by_slug: HashMap = backup_models + .iter() .filter_map(|m| { m.get("slug") .and_then(serde_json::Value::as_str) - .map(str::to_owned) + .map(|s| (s.to_owned(), m.clone())) }) .collect(); - if child_slugs.is_empty() { - return None; - } - let prior_slugs: HashSet = prior_cache_bytes - .and_then(|b| serde_json::from_slice::(b).ok()) - .and_then(|v| { - v.get("models") + 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) - .cloned() - }) - .unwrap_or_default() - .into_iter() - .filter_map(|m| { - m.get("slug") - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - }) - .collect(); + .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(|entry| { + .filter_map(|entry| { let slug = entry .get("slug") .and_then(serde_json::Value::as_str) .unwrap_or(""); - let is_obs = entry - .get("opencodex_account_observed_native") - .and_then(serde_json::Value::as_bool) - == Some(true); - is_obs || prior_slugs.contains(slug) || !child_slugs.contains(slug) + if let Some(injected) = child_by_slug.get(slug) { + if &entry == injected { + prior_by_slug.get(slug).cloned() + } else { + Some(entry) + } + } 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).ok()?; + let mut out = serde_json::to_vec_pretty(&cur_val) + .map_err(|e| format!("client_models_cache_reconcile_failed: {e}"))?; out.push(b'\n'); - Some(out) + Ok(out) } diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index bdbb6e98450..3b6d689d3cd 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -493,7 +493,8 @@ mod windows_private_acl { && !dacl.is_null() && IsValidAcl(dacl) != 0 && IsValidSid(owner) != 0 - && EqualSid(owner, user[0] as Ptr) != 0 + && (EqualSid(owner, user[0] as Ptr) != 0 + || EqualSid(owner, admins.as_mut_ptr().cast()) != 0) && (!root || control & 0x1000 != 0) }; if !ok { diff --git a/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs index 701469e689f..33f93472a76 100644 --- a/native/ocx-child/src/toml_surgeon.rs +++ b/native/ocx-child/src/toml_surgeon.rs @@ -867,12 +867,12 @@ fn strip_subagent_defaults(content_lf: &str, written: Option<&WrittenSubagentDef 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; - } + } + 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() diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 5ba2ac2b5a6..711320701a8 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -13,7 +13,10 @@ use ocx_child::http_wire::{ BodyFraming, FramingError, stream_upstream_to_chunked_client, validate_and_sanitize_request_headers, }; -use ocx_child::journal::{DetachError, DetachOutcome, apply_attach_or_sync, offline_detach}; +use ocx_child::journal::{ + DetachError, DetachOutcome, JournalWriteFault, apply_attach_or_sync, + apply_attach_or_sync_with_fault, offline_detach, +}; 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, @@ -480,10 +483,19 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { } } +struct ScratchGuard(PathBuf); + +impl Drop for ScratchGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + #[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(); @@ -491,7 +503,9 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { 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), @@ -514,25 +528,91 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { 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. Simulate crash during a second sync right after write_journal (config & catalog still hold v1) + // 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( + &paths, + "lnk_0123456789abcdef", + "link:t", + 10100, + cat_v2, + &settings_v2, + Some(JournalWriteFault::AfterJournalWrite), + ) + .is_err() + ); + assert!( + apply_attach_or_sync_with_fault( + &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(&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( &paths, "lnk_0123456789abcdef", "link:t", 10100, - cat_v2, - &settings_v2, + cat_v1, + &settings, ) .unwrap(); - // Roll back config.toml and catalog.json on disk to v1 as if process crashed right after write_journal - fs::write(paths.codex_config_path(), injected.as_bytes()).unwrap(); - fs::write(paths.codex_catalog_path(), cat_v1).unwrap(); + for (cat, set) in [ + (cat_v2.as_slice(), &settings_v2), + (cat_v3.as_slice(), &settings_v3), + ] { + assert!( + apply_attach_or_sync_with_fault( + &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(&paths, false).unwrap(); assert_eq!(outcome, DetachOutcome::ExactRestore); @@ -541,7 +621,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { pre_toml ); assert!(!paths.codex_catalog_path().exists()); - let _ = fs::remove_dir_all(&scratch); + assert!(!paths.codex_models_cache_path().exists()); } #[test] @@ -564,6 +644,27 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { ); 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()); @@ -617,5 +718,52 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { 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( + &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(&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(&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(&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); } diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index a2d94e82744..9a5fce79ee9 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -524,6 +524,44 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac // 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 cred_json = + "{\"apiKeyId\":\"link:e2e\",\"key\":\"ocx_sk_0123456789abcdef0123456789abcdef\"}"; + let (rc_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); + drop(occupied); + let (rc_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); let cfg_now = fs::read_to_string(codex_home.join("config.toml")).unwrap(); let stripped_1798: String = cfg_now .lines() diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index c24e81e8356..ed774ab3035 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -149,7 +149,7 @@ 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) -$h=if($env:OPENCODEX_HOME){$env:OPENCODEX_HOME}else{"$u\.opencodex"};$c=if([IO.File]::Exists("$h\child\state.json")){$k+$f}else{$f+$k} +$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/tests/clients/link-ephemeral-child-discovery.test.ts b/tests/clients/link-ephemeral-child-discovery.test.ts index 395cd4ea838..9ded4da8442 100644 --- a/tests/clients/link-ephemeral-child-discovery.test.ts +++ b/tests/clients/link-ephemeral-child-discovery.test.ts @@ -65,6 +65,7 @@ describe("ephemeral ocx-child SSH discovery and sync fan-out argv", () => { 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}"); } }); diff --git a/tests/clients/link-ocx-child-conformance.test.ts b/tests/clients/link-ocx-child-conformance.test.ts index 921463246bb..a031ffd7e01 100644 --- a/tests/clients/link-ocx-child-conformance.test.ts +++ b/tests/clients/link-ocx-child-conformance.test.ts @@ -28,7 +28,8 @@ import { stripOpencodexCatalogPath, } from "../../src/codex/inject/config-toml"; import { stripJournaledOpenaiBaseUrl } from "../../src/codex/injected-marker"; -import { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "../../src/codex/subagent-defaults"; +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; @@ -145,29 +146,95 @@ function classifyChildIngressCase(row: ConformanceSuite["routeCases"][number]): 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 = content.split("\n"); - const featIdx = lines.findIndex(line => /^\s*\[\s*features\s*\]\s*(?:#.*)?$/.test(line)); + 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 && /^\s*\[/.test(line)); + 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 (/^\s*multi_agent_v2\s*=/.test(lines[i]!)) { - const priorLine = lines[i]!; - lines[i] = `multi_agent_v2 = ${desired}`; - return { content: lines.join("\n"), createdKey: false, priorLine }; + 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]!.trim() === "") insertAt--; - lines.splice(insertAt, 0, `multi_agent_v2 = ${desired}`); - return { content: lines.join("\n"), createdKey: true, priorLine: null }; + 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( @@ -178,48 +245,52 @@ function stripSurgicalTableFeatures( priorUserMultiAgentV2Line: string | null, ): string { if (wroteFastMode === null && wroteMultiAgentV2 === null) return content; - const lines = content.split("\n"); - const featIdx = lines.findIndex(line => /^\s*\[\s*features\s*\]\s*(?:#.*)?$/.test(line)); + 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 && /^\s*\[/.test(line)); + let endIdx = lines.findIndex((line, i) => i > featIdx && line.structural && /^\s*\[/.test(line.text)); if (endIdx === -1) endIdx = lines.length; - const body: string[] = []; + const body: SourceLine[] = []; for (const line of lines.slice(featIdx + 1, endIdx)) { - if (wroteFastMode !== null && new RegExp(`^\\s*fast_mode\\s*=\\s*${wroteFastMode}\\s*(?:#.*)?$`).test(line)) { - if (priorUserFastModeLine !== null) body.push(priorUserFastModeLine); + 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 && new RegExp(`^\\s*multi_agent_v2\\s*=\\s*${wroteMultiAgentV2}\\s*(?:#.*)?$`).test(line)) { - if (priorUserMultiAgentV2Line !== null) body.push(priorUserMultiAgentV2Line); + 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.trim() === "" || line.trim().startsWith("#"))) { - const next = [...lines.slice(0, featIdx), ...lines.slice(endIdx)]; - return next.join("\n").replace(/\n{3,}/g, "\n\n"); + if (body.every(line => line.structural && (line.text.trim() === "" || line.text.trim().startsWith("#")))) { + return collapseStructuralBlankLines(sourceText([...lines.slice(0, featIdx), ...lines.slice(endIdx)])); } - return [...lines.slice(0, featIdx + 1), ...body, ...lines.slice(endIdx)].join("\n"); + 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 = next.split("\n"); - const agIdx = lines.findIndex(line => /^\s*\[\s*agents\s*\]\s*(?:#.*)?$/.test(line)); + 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 && /^\s*\[/.test(line)); + 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 (written.default_subagent_model && line.trim() === `default_subagent_model = ${JSON.stringify(written.default_subagent_model)}`) return false; - if (written.default_subagent_reasoning_effort && line.trim() === `default_subagent_reasoning_effort = ${JSON.stringify(written.default_subagent_reasoning_effort)}`) return false; + 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.trim() === "" || line.trim().startsWith("#"))) { - next = [...lines.slice(0, agIdx), ...lines.slice(endIdx)].join("\n").replace(/\n{3,}/g, "\n\n"); + if (body.every(line => line.structural && line.text.trim() === "")) { + next = collapseStructuralBlankLines(sourceText([...prefixLines, ...lines.slice(endIdx)])); } else { - next = [...lines.slice(0, agIdx + 1), ...body, ...lines.slice(endIdx)].join("\n"); + next = sourceText([...prefixLines, lines[agIdx]!, ...body, ...lines.slice(endIdx)]); } return next; } @@ -262,8 +333,7 @@ function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][ lf = setRootRealtimeWsBaseUrl(lf, target).content; if (settings.fastMode !== null) { if (wroteFastMode === null) { - const fmMatch = lf.split("\n").find(l => /^\s*fast_mode\s*=/.test(l)); - priorUserFastModeLine = fmMatch ?? null; + priorUserFastModeLine = findStructuralFeatureLine(lf, "fast_mode"); } lf = ensureFastModeFeature(lf, settings.fastMode); wroteFastMode = settings.fastMode; @@ -360,10 +430,12 @@ function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][ }; } - for (const line of applyEol(currentToml, "\n").split("\n")) { - if (/^\s*(?:openai_base_url|experimental_realtime_ws_base_url|model_catalog_json|web_search)\s*=/.test(line)) { + 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); + Bun.TOML.parse(line.text); } catch { return { attachOk: true, @@ -391,7 +463,7 @@ function runReferenceJournalCase(row: ConformanceSuite["journalRoundtripCases"][ priorUserMultiAgentV2Line, ); surgical = stripSurgicalSubagentDefaults(surgical, wroteSubagent); - surgical = surgical.replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n"); + surgical = collapseStructuralBlankLines(surgical); if (surgical.length > 0 && !surgical.endsWith("\n")) surgical += "\n"; const finalToml = surgical.trim().length === 0 ? null : applyEol(surgical, eol); return { @@ -561,13 +633,14 @@ describe("ocx-child shared golden conformance vectors (INV-LINK-CHILD-01)", () = }, 10100)).toBe(false); }); - test("Rust conformance runner passes against the identical shared fixture when cargo is installed", () => { - const exeSuffix = process.platform === "win32" ? ".exe" : ""; - const realHome = process.env.OCX_REAL_HOME?.trim() || userInfo().homedir; - const cargo = Bun.which("cargo") ?? join(realHome, ".cargo", "bin", `cargo${exeSuffix}`); - if (!existsSync(cargo)) return; + 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( - [cargo, "test", "--locked", "--manifest-path", repoPath("native", "ocx-child", "Cargo.toml"), "--test", "conformance"], + [cargoBin, "test", "--locked", "--manifest-path", repoPath("native", "ocx-child", "Cargo.toml"), "--test", "conformance"], { env: { ...process.env, @@ -579,6 +652,9 @@ describe("ocx-child shared golden conformance vectors (INV-LINK-CHILD-01)", () = 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 index ac97ed84150..6ab05b7d68f 100644 --- a/tests/clients/link-ocx-child-e2e.test.ts +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -109,6 +109,24 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- ); 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> = []; @@ -200,18 +218,6 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- const tunnelPort = homeHubLinkServer.port!; writeLinkStore(linkStorePath(), { version: 1, listenerPort: tunnelPort, links: [] }); - 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"]) { - for (const sysDir of ["/bin", "/usr/bin"]) { - const p = join(sysDir, util); - if (existsSync(p)) { - symlinkSync(p, join(privateUtils, util)); - break; - } - } - } - const childEnv = { HOME: childUserHome, OPENCODEX_HOME: childOcxHome, @@ -246,7 +252,7 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- if (patched.includes("'link' 'port'")) { return { code: 0, stdout: JSON.stringify({ port: tunnelPort }) + "\n", stderr: "" }; } - return runShellAsync(["/bin/sh", "-c", patched], stdin); + return runShellAsync([resolvedSh, "-c", patched], stdin); }; const sshRunner: SshRunner = { diff --git a/tests/fixtures/link/ocx-child-conformance.json b/tests/fixtures/link/ocx-child-conformance.json index a6deeddf0e8..1554259520f 100644 --- a/tests/fixtures/link/ocx-child-conformance.json +++ b/tests/fixtures/link/ocx-child-conformance.json @@ -1024,6 +1024,56 @@ "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 } ] } From 5e26d6660e3096df42536fb2bc185fa680d41b52 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 04:54:03 +0900 Subject: [PATCH 09/34] fix(link): fail fast on unreadable attach snapshots, preserve feature comments, and clarify lifecycle docs --- .../docs/fr/reference/cli/lifecycle.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 5 +- .../docs/ru/reference/cli/lifecycle.md | 2 +- .../docs/tr/reference/cli/lifecycle.md | 2 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- native/ocx-child/src/cli.rs | 125 +++++++++++++----- native/ocx-child/src/toml_surgeon.rs | 6 +- native/ocx-child/tests/conformance.rs | 18 +++ native/ocx-child/tests/relay_and_lifecycle.rs | 14 +- .../link-ephemeral-child-discovery.test.ts | 40 +++++- .../link-ocx-child-conformance.test.ts | 2 +- .../fixtures/link/ocx-child-conformance.json | 22 +++ 15 files changed, 197 insertions(+), 49 deletions(-) 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 d8b88409a26..7ef4c4e6ebe 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -169,7 +169,7 @@ Sur un Home Remote Link, après la validation et l’enregistrement d’un catal 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. -> **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 (sur un hôte léger `ocx-child`, un `sync` réussi démarre `ocx-child serve` s'il est absent, le relance lorsque `runtimeStartedAt < lastApplyAt` et publie la parité au mieux hors mode `stale`). 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/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 2c01f299bab..132bb521e4d 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -176,7 +176,7 @@ Remote Link の Home では、最新のカタログが正常にコミットさ 伝播は Child の Codex アプリや app-server の再起動を要求しません。必要な場合は GOAT を使うか、その Child で `ocx sync --restart-codex` を実行してください。ただし、Child の固定先同期は、変更された Home 設定を適用した後、自身の OpenCodex クライアントランタイムを再起動することがあります。Home がタイムアウトしてローカルの SSH プロセスを停止しても、すでに開始されたリモート同期は Child 上で続くことがあります。 -> **Remote Link Child:** `ocx sync` は Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は、再起動フラグなしでもランタイムを再起動します(軽量 `ocx-child` ホストでは未起動時に `ocx-child serve` を起動し、`runtimeStartedAt < lastApplyAt` のときにローテーションして非 stale 同期時にベストエフォートで parity を報告します)。再起動に失敗した場合は以前のランタイムが動作し続け、コマンドは 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 aa7a80501cf..5dcd8313390 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -264,7 +264,7 @@ Remote Link Home에서는 최신 카탈로그가 성공적으로 커밋된 뒤 ` 전파는 Child의 Codex 앱이나 app-server 재시작을 요청하지 않습니다. 재시작이 필요하면 GOAT를 사용하거나 해당 Child에서 `ocx sync --restart-codex`를 실행하세요. 다만 Child의 고정 대상 동기화는 변경된 Home 설정을 적용한 뒤 자체 OpenCodex 클라이언트 런타임을 재시작할 수 있습니다. Home이 시간 초과로 로컬 SSH 프로세스를 중지하더라도 이미 시작된 원격 동기화는 Child에서 계속될 수 있습니다. -> **Remote Link Child:** `ocx sync`는 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 런타임을 재시작합니다(경량 `ocx-child` 호스트에서는 릴레이가 없을 때 `ocx-child serve`를 시작하고 `runtimeStartedAt < lastApplyAt`이면 교체하며, `stale`이 아닌 동기화에서 최선을 다해 parity를 보고합니다). 재시작이 실패하면 이전 런타임이 계속 서비스하고 명령은 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 f8bcdb74353..59a2f1cd0a0 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1325,7 +1325,7 @@ 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 speak the same `sync --expect-link [--json]`, `status [--json]`, and `disconnect` / `detach` contracts: on an attached `ocx-child` host (which runs without a system service manager), a successful `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; `disconnect` / `detach` restores `~/.codex` files offline and stops `ocx-child serve` with zero background daemon left running. +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. ## Remote Link Child attestations @@ -1352,8 +1352,7 @@ 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, 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 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 +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: a second `ocx sync` while one is applying fails fast and changes nothing. ## 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 ba3a507b3e7..046187d8e27 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -265,7 +265,7 @@ credential'ы и не выполняет repair. Рассылка никогда не запрашивает перезапуск приложения Codex или app-server на дочерней машине. Для этого используйте GOAT или выполните `ocx sync --restart-codex` на самой машине. При этом её синхронизация с закреплённым Home может перезапустить собственный клиентский рантайм OpenCodex после применения изменённых настроек Home. Если Home завершит локальный процесс SSH по тайм-ауту, уже начатая удалённая синхронизация может продолжиться на дочерней машине. -> **Дочерняя машина Remote Link:** `ocx sync` также применяет настройки Codex с Home и, если клиентский рантайм запущен раньше этого применения, перезапускает его даже без флага перезапуска (на легковесном хосте `ocx-child` успешный `sync` запускает `ocx-child serve`, если ретранслятор не запущен, перезапускает его при `runtimeStartedAt < lastApplyAt` и отправляет отчёт о паритете по принципу best-effort при не-`stale` синхронизации). Если перезапуск не удался, прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. Соответствующие поля `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 5bab33d1221..8377fc94852 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -262,7 +262,7 @@ Remote Link Home üzerinde güncel katalog başarıyla kaydedildikten sonra `ocx 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. -> **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 (hafif `ocx-child` ana makinelerinde başarılı bir `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). 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 8dbe0a82030..13c73b4a6a7 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 @@ -172,7 +172,7 @@ ocx status --json 传播不会请求重启 Child 上的 Codex 应用或 app-server。如需重启,请使用 GOAT 或在该 Child 上运行 `ocx sync --restart-codex`。不过,Child 的固定目标同步在应用已更改的 Home 设置后,仍可能重启它自己的 OpenCodex 客户端运行时。Home 超时并停止本地 SSH 进程后,已开始的远程同步仍可能在 Child 上继续。 -> **Remote Link Child:** `ocx sync` 还会应用 Home 的 Codex 设置;如果客户端运行时在该次应用之前启动,即使没有重启参数也会重启运行时(在轻量级 `ocx-child` 主机上,成功的 `sync` 会在中继未运行时启动 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 时轮换进程,并在非 `stale` 同步时尽力上报 parity)。重启失败时,原有运行时继续提供服务,命令以非零状态退出。`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 44eb6d1db6a..6f885fc3601 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 @@ -160,7 +160,7 @@ ocx status --json 傳播不會要求重新啟動 Child 上的 Codex 應用程式或 app-server。若需要重新啟動,請使用 GOAT 或在該 Child 執行 `ocx sync --restart-codex`。不過,Child 的固定目標同步在套用變更後的 Home 設定時,仍可能重新啟動它自己的 OpenCodex 用戶端執行階段。Home 逾時並停止本機 SSH 程序後,已開始的遠端同步仍可能在 Child 上繼續。 -> **Remote Link Child:** `ocx sync` 也會套用 Home 的 Codex 設定;如果用戶端執行階段在該次套用之前啟動,即使沒有重新啟動旗標也會重新啟動執行階段(在輕量級 `ocx-child` 主機上,成功的 `sync` 會在中繼未執行時啟動 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 時輪替程序,並在非 `stale` 同步時盡力回報 parity)。重新啟動失敗時,原有執行階段會繼續提供服務,指令以非零狀態結束。`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/src/cli.rs b/native/ocx-child/src/cli.rs index 21a7178f2ec..ba48fe6913a 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -190,7 +190,7 @@ fn fetch_home_settings_doc( .ok_or_else(|| "Home codex-settings failed strict schema validation".to_owned()) } -struct AttachRollbackSnapshot { +pub struct AttachRollbackSnapshot { prior_state: Option, prior_was_serving: bool, journal: Option>, @@ -203,64 +203,123 @@ struct AttachRollbackSnapshot { } impl AttachRollbackSnapshot { - fn capture(paths: &ChildPaths, prior_state: Option) -> Self { + 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(paths: &ChildPaths, prior_state: Option) -> Result { let prior_was_serving = prior_state.as_ref().is_some_and(|st| { probe_local_healthz(st.listen_port, Duration::from_millis(300)).is_some() }); - Self { + Ok(Self { prior_state, prior_was_serving, - journal: fs::read(&paths.journal_file).ok(), - config: fs::read(paths.codex_config_path()).ok(), - catalog: fs::read(paths.codex_catalog_path()).ok(), - catalog_backup: fs::read(&paths.catalog_backup_file).ok(), - models_cache: fs::read(paths.codex_models_cache_path()).ok(), - key: fs::read(&paths.key_file).ok().map(SecretBytes::new), - state_bytes: fs::read(&paths.state_file).ok(), - } + 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)?, + }) } - fn restore_path(path: &std::path::Path, bytes: Option<&[u8]>, private: bool) { + fn restore_path( + path: &std::path::Path, + bytes: Option<&[u8]>, + private: bool, + errors: &mut Vec, + ) { match bytes { Some(b) => { - let _ = if private { + let res = if private { write_private_atomic(path, b) } else { write_regular_atomic(path, b) }; + if let Err(e) = res { + errors.push(format!("restore {}: {e}", path.display())); + } } - None => { - let _ = fs::remove_file(path); - } + None => match fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => errors.push(format!("remove {}: {e}", path.display())), + }, } } - fn restore(&self, paths: &ChildPaths) { - Self::restore_path(&paths.journal_file, self.journal.as_deref(), true); - Self::restore_path(&paths.codex_config_path(), self.config.as_deref(), false); - Self::restore_path(&paths.codex_catalog_path(), self.catalog.as_deref(), false); + pub fn restore(&self, paths: &ChildPaths) -> Result<(), String> { + let mut errors = Vec::new(); + Self::restore_path( + &paths.journal_file, + self.journal.as_deref(), + true, + &mut errors, + ); + Self::restore_path( + &paths.codex_config_path(), + self.config.as_deref(), + false, + &mut errors, + ); + Self::restore_path( + &paths.codex_catalog_path(), + self.catalog.as_deref(), + false, + &mut errors, + ); Self::restore_path( &paths.catalog_backup_file, self.catalog_backup.as_deref(), true, + &mut errors, ); Self::restore_path( &paths.codex_models_cache_path(), self.models_cache.as_deref(), false, + &mut errors, ); Self::restore_path( &paths.key_file, self.key.as_ref().map(SecretBytes::as_slice), true, + &mut errors, + ); + Self::restore_path( + &paths.state_file, + self.state_bytes.as_deref(), + true, + &mut errors, ); - Self::restore_path(&paths.state_file, self.state_bytes.as_deref(), true); if let Some(mut prev) = self.prior_state.clone() && self.prior_was_serving && probe_local_healthz(prev.listen_port, Duration::from_millis(250)).is_none() - && start_or_rollover_daemon(paths, &mut prev, None).is_ok() { - let _ = write_child_state(&paths.state_file, &prev); + match start_or_rollover_daemon(paths, &mut prev, None) { + Ok(_) => { + if let Err(e) = write_child_state(&paths.state_file, &prev) { + errors.push(format!("restore daemon state: {e}")); + } + } + Err(e) => errors.push(format!("restart prior daemon: {e}")), + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(format!("attach rollback incomplete: {}", errors.join("; "))) + } + } + + fn restore_and_report(&self, paths: &ChildPaths) { + if let Err(rb_err) = self.restore(paths) { + eprintln!("{rb_err}"); } } } @@ -408,7 +467,13 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } }; - let rollback = AttachRollbackSnapshot::capture(&paths, prior_state.clone()); + let rollback = match AttachRollbackSnapshot::capture(&paths, prior_state.clone()) { + Ok(r) => r, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; let journal = match apply_attach_or_sync( &paths, @@ -420,14 +485,14 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> ) { Ok(j) => j, Err(e) => { - rollback.restore(&paths); + rollback.restore_and_report(&paths); eprintln!("{e}"); return 1; } }; if let Err(e) = write_link_key(&paths.key_file, raw_key_str) { - rollback.restore(&paths); + rollback.restore_and_report(&paths); eprintln!("{e}"); return 1; } @@ -438,7 +503,7 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> let shutdown_secret = match random_hex(24) { Ok(s) => s, Err(e) => { - rollback.restore(&paths); + rollback.restore_and_report(&paths); eprintln!("{e}"); return 1; } @@ -447,7 +512,7 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> && prev.listen_port != listen_port && let Err(e) = stop_owned_daemon(prev) { - rollback.restore(&paths); + rollback.restore_and_report(&paths); eprintln!( "attach failed stopping previous relay on port {}: {e}", prev.listen_port @@ -479,7 +544,7 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> }), }; if let Err(e) = write_child_state(&paths.state_file, &state) { - rollback.restore(&paths); + rollback.restore_and_report(&paths); eprintln!("{e}"); return 1; } @@ -499,7 +564,7 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> let _ = post_child_parity_report(tunnel_port, &creds, &report); } Err(e) => { - rollback.restore(&paths); + rollback.restore_and_report(&paths); eprintln!("{e}"); return 1; } diff --git a/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs index 33f93472a76..075fda85490 100644 --- a/native/ocx-child/src/toml_surgeon.rs +++ b/native/ocx-child/src/toml_surgeon.rs @@ -935,9 +935,9 @@ fn strip_features_ledger( } } } - let remaining_has_keys = (feat_idx + 1..end_idx).filter(|&i| !drop[i]).any(|i| { - !structural[i] || (!lines[i].trim().is_empty() && !lines[i].trim().starts_with('#')) - }); + 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; diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 711320701a8..756d9aa8234 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use serde::Deserialize; +use ocx_child::cli::AttachRollbackSnapshot; use ocx_child::codex_settings::{ HomeCodexSettings, codex_settings_digest, codex_settings_digest_payload, parse_home_codex_settings_document, @@ -766,4 +767,21 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { 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(&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(&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(&paths).unwrap_err(); + assert!( + rb_err.contains("attach rollback incomplete"), + "err was: {rb_err}" + ); } diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 9a5fce79ee9..45f3ebee069 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -527,9 +527,7 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac // 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 cred_json = - "{\"apiKeyId\":\"link:e2e\",\"key\":\"ocx_sk_0123456789abcdef0123456789abcdef\"}"; - let (rc_occ, _, _) = run_bin( + let (rc_occ, _, err_occ) = run_bin( &ocx_home, &codex_home, occupied_port, @@ -545,8 +543,12 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac 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, _, _) = run_bin( + let (rc_unreach, _, err_unreach) = run_bin( &ocx_home, &codex_home, occupied_port, @@ -562,6 +564,10 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac 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() diff --git a/tests/clients/link-ephemeral-child-discovery.test.ts b/tests/clients/link-ephemeral-child-discovery.test.ts index 9ded4da8442..db691839ccd 100644 --- a/tests/clients/link-ephemeral-child-discovery.test.ts +++ b/tests/clients/link-ephemeral-child-discovery.test.ts @@ -7,7 +7,7 @@ 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 { POWERSHELL_ENCODED_MAX, windowsOcxArgv, windowsPinnedSyncArgv, windowsScriptHelperSource } from "../../src/link/remote-windows"; +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 }); @@ -141,4 +141,42 @@ describe("ephemeral ocx-child SSH discovery and sync fan-out argv", () => { 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 index a031ffd7e01..d431528fbac 100644 --- a/tests/clients/link-ocx-child-conformance.test.ts +++ b/tests/clients/link-ocx-child-conformance.test.ts @@ -263,7 +263,7 @@ function stripSurgicalTableFeatures( } body.push(line); } - if (body.every(line => line.structural && (line.text.trim() === "" || line.text.trim().startsWith("#")))) { + 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)]); diff --git a/tests/fixtures/link/ocx-child-conformance.json b/tests/fixtures/link/ocx-child-conformance.json index 1554259520f..1b03996f82a 100644 --- a/tests/fixtures/link/ocx-child-conformance.json +++ b/tests/fixtures/link/ocx-child-conformance.json @@ -1074,6 +1074,28 @@ "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 } ] } From c4d4d5d6311905bea62f065d7d04b9de7cdb5a86 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 05:06:19 +0900 Subject: [PATCH 10/34] fix(link): verify restored credentials before relay restart and clarify fan-out restart docs --- .../docs/fr/reference/cli/lifecycle.md | 2 +- .../docs/ja/reference/cli/lifecycle.md | 2 +- .../docs/ko/reference/cli/lifecycle.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 4 +-- .../docs/ru/reference/cli/lifecycle.md | 2 +- .../docs/tr/reference/cli/lifecycle.md | 2 +- .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- native/ocx-child/src/cli.rs | 26 +++++++++++++++---- 9 files changed, 30 insertions(+), 14 deletions(-) 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 7ef4c4e6ebe..2b69772f2f9 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -167,7 +167,7 @@ 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 en échec ou en délai dépassé, ou un registre de liens illisible, entraîne le code de sortie 6 ; sinon, la commande se termine avec le code 0, y compris lorsque des Enfants sont ignorés. 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 :** 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). 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 132bb521e4d..dc7e3057c26 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -174,7 +174,7 @@ ocx status --json Remote Link の Home では、最新のカタログが正常にコミットされた後、`ocx sync` は対象となるリンク済み Child にも同期を伝播します。`--local-only` はこの伝播を無効にします。`--json` は Home の結果と各 Child の状態を含む単一の `ocx.sync.fanout/1` 文書を出力し、スキップされた Child の理由も示します。Home 自体の終了コードが 0 以外の場合は、そのコードを変更せずに返します。Home が成功した場合、Child の失敗やタイムアウト、またはリンクストアの読み取り不能は終了コード 6 となり、それ以外は Child がスキップされた場合も含めて 0 で終了します。リンクストアを読み取れない場合、テキスト表示ではパスを含まないエラーを出します。コード 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` 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)(英語)を参照してください。 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 5dcd8313390..3acfe5761fa 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -262,7 +262,7 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또 Remote Link Home에서는 최신 카탈로그가 성공적으로 커밋된 뒤 `ocx sync`가 조건을 충족하는 연결된 Child에도 동기화를 전파합니다. `--local-only`는 이 전파를 끕니다. `--json`은 Home 결과와 각 Child의 상태를 담은 단일 `ocx.sync.fanout/1` 문서를 출력하며, 건너뛴 Child의 이유도 표시합니다. Home 자체의 종료 코드가 0이 아니면 그 코드를 그대로 반환합니다. Home이 성공한 경우 Child 실패나 시간 초과 또는 링크 저장소를 읽을 수 없는 경우 종료 코드 6을 반환하며, 그 외에는 Child를 건너뛴 경우를 포함해 0으로 종료합니다. 링크 저장소를 읽을 수 없으면 텍스트 모드에서 경로가 없는 오류를 출력합니다. 코드 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` 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)(영문)를 참고하세요. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 59a2f1cd0a0..7b188c60488 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -568,8 +568,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. 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 046187d8e27..11e01e7055b 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -263,7 +263,7 @@ credential'ы и не выполняет repair. На Home Remote Link после успешной фиксации актуального каталога `ocx sync` также синхронизирует подходящие связанные дочерние машины. `--local-only` отключает эту рассылку. `--json` выводит один документ `ocx.sync.fanout/1` с результатом Home и состоянием каждой дочерней машины; для пропущенной машины указана причина. Ненулевой код выхода самого Home возвращается без изменений. Если Home завершился успешно, сбой или тайм-аут дочерней машины либо недоступное хранилище связей дают код выхода 6; в остальных случаях команда завершается с кодом 0, в том числе когда дочерние машины пропущены. В текстовом режиме сообщение о недоступном хранилище не содержит путь. Код 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:** на полном 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) (на английском). 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 8377fc94852..38f9a9e2db1 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -260,7 +260,7 @@ 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 bir Child hatası veya zaman aşımı ya da okunamayan bağlantı deposu çıkış kodu 6 üretir; diğer durumlarda, Child makineler atlandığında da komut 0 koduyla çıkar. 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:** 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. 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 13c73b4a6a7..c2e02b533ec 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,7 +170,7 @@ ocx status --json 在 Remote Link Home 上,成功提交最新目录后,`ocx sync` 也会向符合条件的已连接 Child 同步。`--local-only` 可关闭此传播。`--json` 输出一个 `ocx.sync.fanout/1` 文档,包含 Home 的结果和每个 Child 的状态;被跳过的 Child 会注明原因。Home 自身的非零退出码会原样返回。Home 成功时,若 Child 失败、超时或链接存储不可读,命令以退出码 6 结束;其他情况均以 0 结束,包括 Child 被跳过时。文本模式下,链接存储不可读的错误不包含路径。退出码 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` 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)(英文)。 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 6f885fc3601..aaaab4b9b3a 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 @@ -158,7 +158,7 @@ ocx status --json 在 Remote Link Home 上成功提交最新目錄後,`ocx sync` 也會向符合條件的已連結 Child 同步。`--local-only` 可停用這個傳播。`--json` 會輸出單一 `ocx.sync.fanout/1` 文件,包含 Home 的結果與各 Child 的狀態;遭略過的 Child 也會顯示原因。Home 自身的非零結束碼會原樣回傳。Home 成功時,若 Child 失敗、逾時或連結儲存區無法讀取,指令以結束碼 6 結束;其他情況均以 0 結束,包括 Child 遭略過時。文字模式下,連結儲存區無法讀取的錯誤不包含路徑。結束碼 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` 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)(英文)。 diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index ba48fe6913a..ddac8270618 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -301,13 +301,29 @@ impl AttachRollbackSnapshot { && self.prior_was_serving && probe_local_healthz(prev.listen_port, Duration::from_millis(250)).is_none() { - match start_or_rollover_daemon(paths, &mut prev, None) { - Ok(_) => { - if let Err(e) = write_child_state(&paths.state_file, &prev) { - errors.push(format!("restore daemon state: {e}")); + 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 { + let had_file_errors = !errors.is_empty(); + match start_or_rollover_daemon(paths, &mut prev, None) { + Ok(_) => { + if let Err(e) = write_child_state(&paths.state_file, &prev) { + errors.push(format!("restore daemon state: {e}")); + } else if had_file_errors { + errors.push("prior relay restart attempted and succeeded".to_owned()); + } } + Err(e) => errors.push(format!("restart prior daemon: {e}")), } - Err(e) => errors.push(format!("restart prior daemon: {e}")), } } if errors.is_empty() { From e40ab37bb94b249247b9fc2741581a000933fbf6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:14:28 +0900 Subject: [PATCH 11/34] style(ocx-child): remove trailing whitespace --- native/ocx-child/src/relay.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index 543758f4dde..95977b7039a 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -479,14 +479,14 @@ fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> st 403, "Forbidden", &[], - br#"{"error":{"message":"cross-origin data-plane request blocked","type":"invalid_request_error","code":"origin_rejected"}}"#, + 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"}}"#, + 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"}"#) @@ -709,7 +709,7 @@ fn relay_request( ("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"}"#, + br#"{"error":"Home is unreachable over Remote Link","code":"home_unreachable","message":"Home is unreachable over Remote Link"}"#, ); } }; @@ -772,7 +772,7 @@ fn relay_request( ("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"}"#, + br#"{"error":"Home is unreachable over Remote Link","code":"home_unreachable","message":"Home is unreachable over Remote Link"}"#, ); } @@ -796,7 +796,7 @@ fn relay_request( ("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"}"#, + br#"{"error":"Home is unreachable over Remote Link","code":"home_unreachable","message":"Home is unreachable over Remote Link"}"#, ); } }; From 28a6886701945d462b02ded7ef9cafd6e51bd926 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 00:16:02 +0900 Subject: [PATCH 12/34] fix(ocx-child): preserve recovery and verify shutdown --- .../000_roadmap.md | 40 +- .../src/content/docs/guides/remote-link.md | 2 + native/ocx-child/src/cli.rs | 412 ++++++++++++++++-- native/ocx-child/src/daemon.rs | 128 ++++-- native/ocx-child/src/journal.rs | 95 +++- native/ocx-child/src/platform.rs | 286 +++++++++--- native/ocx-child/src/relay.rs | 58 ++- native/ocx-child/src/relay_auth.rs | 25 +- native/ocx-child/src/state.rs | 17 +- native/ocx-child/tests/conformance.rs | 272 ++++++++++++ native/ocx-child/tests/relay_and_lifecycle.rs | 338 ++++++++++++++ structure/remote-link.md | 2 + 12 files changed, 1481 insertions(+), 194 deletions(-) diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md index f4df76b220d..79f9ec451c9 100644 --- a/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md @@ -7,11 +7,11 @@ Remote Link today requires every Child machine (`src/server/management/link-enro ## Contents 1. [Loop Specification & HOTL Bounds](#1-loop-specification--hotl-bounds) -2. [Threat Model & Security Controls (`SEC-THREAT-01`)](#2-threat-model--security-controls-sec-threat-01) +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. [Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`)](#6-early-warning--boundary-guard-disclosure-plan-bypass-named-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) @@ -30,29 +30,9 @@ Remote Link today requires every Child machine (`src/server/management/link-enro | 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. Threat Model & Security Controls (`SEC-THREAT-01`) - -1. **Assets**: - - Per-Child Home link API key (`ocx_data_<40hex>`) and its SHA-256 fingerprint. - - Child local caller credentials sent by Codex CLI/App or Claude Code (`Authorization`, `api-key`, `x-api-key`, `x-goog-api-key`, `x-opencodex-api-key`, `chatgpt-account-id`, `cookie`). - - User's native `~/.codex/config.toml`, `opencodex-catalog.json`, and `models_cache.json`. - - Home loopback `hub-link` listener and SSH reverse tunnel (`127.0.0.1:`). -2. **Entrypoints & Trust Boundaries**: - - Local Child callers -> `127.0.0.1:` (`ocx-child serve` HTTP/1.1 ingress). - - `ocx-child` -> `127.0.0.1:` (reverse SSH tunnel into Home `hub-link` listener; untrusted until `link-relay-auth` v2 listener proof verifies on the same TCP socket). - - Home SSH orchestrator -> Child shell (`remoteOcxArgv` / `remotePinnedSyncArgv` over pinned `known_hosts` with `StrictHostKeyChecking=yes` and `BatchMode=yes`). - - Child filesystem (`$OPENCODEX_HOME/child/*` and `$CODEX_HOME/*` across concurrent processes and Codex Desktop App `#1798` comment-stripping rewrites). -3. **Attacker Capabilities**: - - Unprivileged local process or browser page probing non-loopback interfaces or sending cross-origin / WebSocket `Upgrade` / `Content-Length` + `Transfer-Encoding` smuggling payloads. - - Spoofed local tunnel listener on `` attempting to harvest the Child's `ocx_data_*` key without knowing its SHA-256 fingerprint. - - Concurrent `ocx sync` / `detach` invocations or post-attach user/app edits to `config.toml`. -4. **Controls**: - - **Loopback-only bind**: `ocx-child serve` binds strictly to `127.0.0.1:` (refusing `0.0.0.0` or `::`). - - **Default-deny route allowlist & framing**: Exact `linkRouteAllowed` + `linkManagementRouteAllowed` match; reject any `Upgrade` (`426` for `/v1/responses` WebSocket upgrade with Origin check, `404`/`400` elsewhere), reject `CL+TE`, duplicate/non-digit `CL`, non-`chunked` `TE`, CR/LF in headers, headers > 64 KiB (`431`), and request/non-SSE response bodies > 256 MiB (`413`/`502`). - - **Caller credential stripping**: Strip all `CALLER_CREDENTIAL_HEADERS`, hop-by-hop headers, and `Connection`-nominated headers before forwarding. - - **Same-socket HMAC-SHA256 v2 mutual authentication**: Derive 32-byte raw key `SHA-256(token)`, send credential-free `GET /.well-known/opencodex/link-relay-auth?version=2&...` on a fresh `TcpStream`, verify `204` + 0-byte body + constant-time `x-opencodex-link-proof` (and optional `x-opencodex-link-connection-proof`), then send the single data request with `x-opencodex-link-session: ` and link key on that **exact `TcpStream`** (never pooling or silently reconnecting). - - **Secret & journal hygiene**: Read `{apiKeyId, key}` from `stdin` (4 KiB cap) and volatile-zero buffer immediately; create `$OPENCODEX_HOME/child` (`0700`) and `link-key` / `codex-journal.json` / `state.json` (`0600` at open on Unix via `OpenOptionsExt::mode(0o600)`, atomic temp-write + `fsync` + `rename`); never log keys, headers, URLs, or bodies. - - **SSH argv & Windows Job containment**: Reuse `quoteRemote` single-quoting on POSIX and deflated base64 JSON data + `OCX_JOB_SOURCE` kill-on-close Job (`<= 8000` UTF-16LE base64 chars) on Windows. +## 2. Scope + +Security working analysis is kept outside the tracked repository. ## 3. Architect Consultation & Design Dispositions @@ -72,7 +52,7 @@ Remote Link today requires every Child machine (`src/server/management/link-enro ### 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` §2–§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. +- **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. @@ -102,13 +82,9 @@ Single-PR delivery decision (`DEV-STACK-01`): Delivered as a single focused PR t | `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. Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`) +## 6. Verification scope -| Mechanism | Tier | Executing Surface | Known Bypass Path | Residual Risk | Wording Downgrade & Final Layer | -|---|---|---|---|---|---| -| Fail-closed full-`ocx` / external-provider check on `ocx-child attach` | E2 (runtime check) | `native/ocx-child/src/journal.rs` under `lifecycle.lock` + `.opencodex-write.lock` | A privileged local user manually deleting `runtime-port.json` or editing `~/.codex/config.toml` out-of-band without holding `.opencodex-write.lock` | Local user already owns `~/.codex`; out-of-band manual edits caught later by `DetachConflictError` | Called "preflight guard" (not unbypassable sandbox); `final layer: OS user account boundary + Tier-2 value-matched detach` | -| Detached `ocx-child` port-free check during Home `POST /api/link/apply` | E2 (runtime check) | `src/server/management/link-enrollment.ts` (`remoteChildHttpArgv` before `connect`) | Narrow TOCTOU between SSH port probe and `ocx-child restart` binding `127.0.0.1:` | `ocx-child serve` `TcpListener::bind` fails closed if another process grabs the port in between, and Home `child_relay` authenticated `/v1/catalog` callback refuses enrollment unless Home receives its own issued key | Called "preflight check"; `final layer: TcpListener::bind + Home onAuthenticatedCatalog(issued.id)` | -| Shared TS/Rust golden conformance suite | E4 (CI/test gate) | `bun test tests/clients/ocx-child-conformance.test.ts` and `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` | Fast Ubuntu PR lane runs `test changed` (Bun) unless Rust tests also run; mitigated by running `cargo test` in E2E/verification and `ci-workflows` version/conformance checks | Behavioral bugs outside the covered vector classes require real-binary E2E tests (`ocx-child-e2e.test.ts`) | Called "conformance regression suite"; `final layer: none (human + CI review)` | +The work packages record implementation and conformance-test status. ## 7. Grounded Verifiers (`PLAN-VERIFIER-REAL-01`) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 2bbeb79ffff..6715ea81b1d 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -201,6 +201,8 @@ For disposable SSH devboxes, containers and lightweight cloud VMs where installi 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 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. diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index ddac8270618..dc3959cfc78 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -2,7 +2,7 @@ use std::fs; use std::io::{self, Read}; use std::time::Duration; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::codex_settings::{ HomeCodexSettings, HomeCodexSettingsDocument, HomeIdentity, codex_settings_digest, @@ -12,11 +12,15 @@ 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}; +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::{ - FileLock, LockAcquireError, SecretBytes, format_iso8601_utc, now_epoch_ms, random_hex, - resolve_codex_home, resolve_opencodex_home, write_private_atomic, write_regular_atomic, + FileLock, LockAcquireError, SecretBytes, constant_time_eq, 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, fetch_home_authenticated, post_child_parity_report, serve_foreground, @@ -202,6 +206,95 @@ pub struct AttachRollbackSnapshot { 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, + 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, + 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) { + if let Some(state) = &mut self.prior_state { + let mut secret = std::mem::take(&mut state.shutdown_secret).into_bytes(); + volatile_zero_bytes(&mut secret); + } + 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) { + if let Some(state) = &mut self.prior_state { + let mut secret = std::mem::take(&mut state.shutdown_secret).into_bytes(); + volatile_zero_bytes(&mut secret); + } + 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) { @@ -215,7 +308,7 @@ impl AttachRollbackSnapshot { let prior_was_serving = prior_state.as_ref().is_some_and(|st| { probe_local_healthz(st.listen_port, Duration::from_millis(300)).is_some() }); - Ok(Self { + let snapshot = Self { prior_state, prior_was_serving, journal: Self::read_optional(&paths.journal_file)?, @@ -225,7 +318,112 @@ impl AttachRollbackSnapshot { 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.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 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}")), + } + let (opencodex_home, codex_home) = Self::canonical_homes(paths)?; + let record = AttachRollbackRecordRef { + schema: "ocx.child.attach-rollback/1", + opencodex_home, + codex_home, + 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(paths: &ChildPaths) -> Result { + 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 + { + 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 { + 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(paths)?; + Ok(true) } fn restore_path( @@ -245,22 +443,16 @@ impl AttachRollbackSnapshot { errors.push(format!("restore {}: {e}", path.display())); } } - None => match fs::remove_file(path) { - Ok(()) => {} - Err(e) if e.kind() == io::ErrorKind::NotFound => {} - Err(e) => errors.push(format!("remove {}: {e}", path.display())), - }, + None => { + if let Err(e) = remove_optional_file(path) { + errors.push(e); + } + } } } pub fn restore(&self, paths: &ChildPaths) -> Result<(), String> { let mut errors = Vec::new(); - Self::restore_path( - &paths.journal_file, - self.journal.as_deref(), - true, - &mut errors, - ); Self::restore_path( &paths.codex_config_path(), self.config.as_deref(), @@ -273,32 +465,43 @@ impl AttachRollbackSnapshot { false, &mut errors, ); - Self::restore_path( - &paths.catalog_backup_file, - self.catalog_backup.as_deref(), - true, - &mut errors, - ); Self::restore_path( &paths.codex_models_cache_path(), self.models_cache.as_deref(), false, &mut errors, ); - Self::restore_path( - &paths.key_file, - self.key.as_ref().map(SecretBytes::as_slice), - true, - &mut errors, - ); - Self::restore_path( - &paths.state_file, - self.state_bytes.as_deref(), - true, - &mut errors, - ); + if errors.is_empty() { + Self::restore_path( + &paths.catalog_backup_file, + self.catalog_backup.as_deref(), + true, + &mut errors, + ); + Self::restore_path( + &paths.key_file, + self.key.as_ref().map(SecretBytes::as_slice), + true, + &mut errors, + ); + Self::restore_path( + &paths.state_file, + self.state_bytes.as_deref(), + true, + &mut errors, + ); + } + if errors.is_empty() { + Self::restore_path( + &paths.journal_file, + self.journal.as_deref(), + true, + &mut errors, + ); + } 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) { @@ -327,6 +530,7 @@ impl AttachRollbackSnapshot { } } if errors.is_empty() { + remove_optional_file(&paths.rollback_file)?; Ok(()) } else { Err(format!("attach rollback incomplete: {}", errors.join("; "))) @@ -406,6 +610,10 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } }; + if !matches!(rollback_pending(&paths), Ok(false)) { + eprintln!("attach refused: incomplete attach requires detach recovery"); + return 1; + } let prior_state = match read_child_state(&paths.state_file) { StateReadOutcome::Valid(st) => Some(*st), _ => None, @@ -587,6 +795,10 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } } + if let Err(e) = remove_optional_file(&paths.rollback_file) { + eprintln!("attach failed to clear rollback snapshot: {e}"); + return 1; + } println!("Connected to http://127.0.0.1:{tunnel_port} as key {api_key_id}."); println!("Local Codex CLI: ready (ephemeral ocx-child Design B attached)."); 0 @@ -611,10 +823,51 @@ fn cmd_serve(args: &[String]) -> u8 { } let paths = current_paths(); if foreground { - let port = listen_port.unwrap_or_else(|| match read_child_state(&paths.state_file) { - StateReadOutcome::Valid(st) => st.listen_port, - _ => default_listen_port(), - }); + 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) => { + let mut supplied = Vec::new(); + let read = io::stdin().take(129).read_to_end(&mut supplied); + let authorized = read.is_ok() + && supplied.len() <= 128 + && constant_time_eq(&supplied, state.shutdown_secret.as_bytes()); + volatile_zero_bytes(&mut supplied); + if !authorized { + eprintln!("serve refused: incomplete attach requires lifecycle authorization"); + return 1; + } + } + Ok(false) => {} + Err(e) => { + eprintln!("serve refused: {e}"); + 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; + } return match serve_foreground(paths, port) { Ok(()) => 0, Err(e) => { @@ -635,6 +888,10 @@ fn cmd_restart() -> u8 { return 1; } }; + if !matches!(rollback_pending(&paths), Ok(false)) { + eprintln!("restart refused: incomplete attach requires detach recovery"); + return 1; + } let mut state = match read_child_state(&paths.state_file) { StateReadOutcome::Valid(st) if st.pending_operation.is_none() => *st, StateReadOutcome::Absent => { @@ -646,6 +903,10 @@ fn cmd_restart() -> u8 { 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) { Ok(v) => v, @@ -801,6 +1062,16 @@ fn cmd_sync(args: &[String]) -> u8 { } 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" { @@ -825,6 +1096,13 @@ fn cmd_sync(args: &[String]) -> u8 { { return emit_expectation_mismatch("child_link_mismatch", "other_link"); } + 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, @@ -981,15 +1259,52 @@ fn cmd_detach(args: &[String]) -> u8 { return 1; } }; - if let StateReadOutcome::Valid(mut st) = read_child_state(&paths.state_file) { + if let Err(e) = AttachRollbackSnapshot::recover(&paths) { + 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()); - let _ = write_child_state(&paths.state_file, &st); + 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(&paths, keep_catalog) { + let outcome = match offline_detach_deferred(&paths, keep_catalog) { Ok(o) => o, Err(DetachError::Conflict(msg)) => { eprintln!("detach conflict: {msg}"); @@ -1000,8 +1315,17 @@ fn cmd_detach(args: &[String]) -> u8 { return 1; } }; - let _ = fs::remove_file(&paths.key_file); - let _ = fs::remove_file(&paths.state_file); + for path in [ + &paths.key_file, + &paths.state_file, + &paths.catalog_backup_file, + &paths.journal_file, + ] { + if let Err(e) = remove_optional_file(path) { + eprintln!("detach cleanup failed: {e}"); + return 1; + } + } if json_mode { println!("{{\"restored\":true,\"outcome\":\"{outcome:?}\",\"daemonRunning\":false}}"); } else { diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index d1200afba05..565151f51e2 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -7,8 +7,9 @@ use serde::Deserialize; use crate::http_wire::{read_bounded_body, read_response_head, response_framing}; use crate::platform::{ - force_terminate_pid, now_epoch_ms, process_is_alive, random_hex, spawn_detached_serve, + 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)] @@ -91,9 +92,15 @@ pub fn ensure_port_available_for_attach(port: u16, owned_pid: Option) -> Re } fn send_attested_shutdown(port: u16, shutdown_secret: &str) -> bool { - if shutdown_secret.is_empty() { + let Ok(nonce) = random_hex(32) else { return false; - } + }; + let Some(expected) = compute_child_shutdown_proof(shutdown_secret, "listener", &nonce) else { + return false; + }; + let Some(caller) = compute_child_shutdown_proof(shutdown_secret, "caller", &nonce) 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; @@ -101,12 +108,48 @@ fn send_attested_shutdown(port: u16, shutdown_secret: &str) -> bool { 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: {shutdown_secret}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + "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 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 + || 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; } - let mut reader = BufReader::new(stream); read_response_head(&mut reader).is_ok_and(|h| h.status == 200) } @@ -122,11 +165,13 @@ pub fn stop_owned_daemon(state: &ChildStateV1) -> Result<(), String> { Some(h.pid) } (Some(pid), None) if !is_port_bound(port) && !process_is_alive(pid) => return Ok(()), - (None, None) => return Ok(()), - _ => None, + (None, None) if !is_port_bound(port) => return Ok(()), + _ => return Err("cannot establish that the child daemon has stopped".to_owned()), }; - let _ = send_attested_shutdown(port, &state.shutdown_secret); + 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); @@ -137,35 +182,9 @@ pub fn stop_owned_daemon(state: &ChildStateV1) -> Result<(), String> { thread::sleep(Duration::from_millis(40)); } - if let Some(pid) = verified_pid { - force_terminate_pid(pid, false); - let term_deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < term_deadline { - if !process_is_alive(pid) && !is_port_bound(port) { - return Ok(()); - } - thread::sleep(Duration::from_millis(40)); - } - force_terminate_pid(pid, true); - let kill_deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < kill_deadline { - if !process_is_alive(pid) && !is_port_bound(port) { - return Ok(()); - } - thread::sleep(Duration::from_millis(40)); - } - if process_is_alive(pid) { - return Err(format!( - "owned ocx-child daemon (pid {pid}) did not terminate" - )); - } - } - if is_port_bound(port) { - return Err(format!( - "listener on 127.0.0.1:{port} remained bound; refusing unverified shutdown" - )); - } - Ok(()) + Err(format!( + "child daemon on 127.0.0.1:{port} did not stop; refusing unverified termination" + )) } pub fn start_or_rollover_daemon( @@ -183,15 +202,28 @@ pub fn start_or_rollover_daemon( 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 spawned_pid = spawn_detached_serve( + let pending_attach = match std::fs::symlink_metadata(&paths.rollback_file) { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(format!("cannot inspect rollback snapshot: {e}")), + }; + let mut spawned = spawn_detached_serve( &exe, &paths.opencodex_home, &paths.codex_home, state.listen_port, + pending_attach.then_some(state.shutdown_secret.as_str()), )?; + let spawned_pid = spawned.id(); let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { + if let Some(status) = spawned + .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") @@ -201,12 +233,28 @@ pub fn start_or_rollover_daemon( 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)?; + if let Err(e) = write_child_state(&paths.state_file, state) { + let cleanup = spawned.kill().and_then(|()| spawned.wait().map(|_| ())); + return Err(format!( + "failed to record spawned child: {e}; cleanup: {cleanup:?}" + )); + } return Ok((spawned_pid, started_ms)); } thread::sleep(Duration::from_millis(50)); } - force_terminate_pid(spawned_pid, true); + if spawned + .try_wait() + .map_err(|e| format!("failed to observe spawned child: {e}"))? + .is_none() + { + spawned + .kill() + .map_err(|e| format!("failed to terminate spawned child: {e}"))?; + spawned + .wait() + .map_err(|e| format!("failed to reap spawned child: {e}"))?; + } Err(format!( "ocx-child serve failed to answer /healthz on 127.0.0.1:{}", state.listen_port diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index 26ff575c49f..01460bd607d 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -7,7 +7,9 @@ use base64::engine::general_purpose::STANDARD as BASE64; use serde::{Deserialize, Serialize}; use crate::codex_settings::HomeCodexSettings; -use crate::platform::{reject_symlink, sha256_hex, write_private_atomic, write_regular_atomic}; +use crate::platform::{ + 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, @@ -89,6 +91,40 @@ pub fn write_journal(path: &Path, journal: &ChildJournalV1) -> Result<(), String 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 recorded_ocx = fs::canonicalize(&journal.opencodex_home) + .map_err(|e| format!("cannot resolve recorded OpenCodex home: {e}"))?; + let current_codex = fs::canonicalize(&paths.codex_home) + .map_err(|e| format!("cannot resolve current Codex home: {e}"))?; + let recorded_codex = fs::canonicalize(&journal.codex_home) + .map_err(|e| format!("cannot resolve recorded Codex home: {e}"))?; + let catalog = Path::new(&journal.catalog_path); + let catalog_parent = catalog.parent().ok_or("journal catalog has no parent")?; + let recorded_catalog_parent = fs::canonicalize(catalog_parent) + .map_err(|e| format!("cannot resolve journal catalog parent: {e}"))?; + if current_ocx != recorded_ocx + || current_codex != recorded_codex + || current_codex != recorded_catalog_parent + || catalog.file_name() != Some(std::ffi::OsStr::new("opencodex-catalog.json")) + { + 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 [ @@ -256,6 +292,9 @@ pub fn apply_attach_or_sync_with_fault( 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, @@ -420,6 +459,10 @@ pub fn apply_attach_or_sync_with_fault( previous_models_cache_sha256: prev_cache_sha, ledger: next_ledger, }; + // Create homes before the journal so later validation can resolve their identities. + ensure_private_dir(&paths.child_dir)?; + fs::create_dir_all(&paths.codex_home) + .map_err(|e| format!("failed to create Codex home: {e}"))?; // Persist recovery journal BEFORE mutating any file in $CODEX_HOME (write-ahead durability). write_journal(&paths.journal_file, &journal)?; if fault == Some(JournalWriteFault::AfterJournalWrite) { @@ -444,10 +487,48 @@ pub fn apply_attach_or_sync_with_fault( pub fn offline_detach( paths: &ChildPaths, keep_catalog: bool, +) -> Result { + offline_detach_inner(paths, keep_catalog, false) +} + +/// Restore client files while retaining recovery records until lifecycle cleanup succeeds. +pub fn offline_detach_deferred( + paths: &ChildPaths, + keep_catalog: bool, +) -> Result { + offline_detach_inner(paths, 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( + paths: &ChildPaths, + keep_catalog: bool, + defer_cleanup: bool, ) -> Result { let Some(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(); @@ -558,7 +639,7 @@ pub fn offline_detach( })?; write_regular_atomic(&config_path, &orig).map_err(DetachError::Io)?; } else { - let _ = fs::remove_file(&config_path); + remove_optional_file(&config_path).map_err(DetachError::Io)?; } DetachOutcome::ExactRestore } else { @@ -572,7 +653,7 @@ pub fn offline_detach( .map_err(DetachError::Io)?; } None => { - let _ = fs::remove_file(&config_path); + remove_optional_file(&config_path).map_err(DetachError::Io)?; } } DetachOutcome::SurgicalRestore @@ -588,7 +669,7 @@ pub fn offline_detach( write_regular_atomic(catalog_path, &prior_bytes).map_err(DetachError::Io)?; } } else { - let _ = fs::remove_file(catalog_path); + remove_optional_file(catalog_path).map_err(DetachError::Io)?; } } @@ -608,8 +689,10 @@ pub fn offline_detach( } } - let _ = fs::remove_file(&paths.catalog_backup_file); - let _ = fs::remove_file(&paths.journal_file); + if !defer_cleanup { + remove_optional_file(&paths.catalog_backup_file).map_err(DetachError::Io)?; + remove_optional_file(&paths.journal_file).map_err(DetachError::Io)?; + } Ok(outcome) } diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 3b6d689d3cd..1f8e4800067 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -1,5 +1,7 @@ use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Write}; +#[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}; @@ -159,7 +161,6 @@ unsafe extern "system" { inherit_handle: i32, process_id: u32, ) -> *mut std::ffi::c_void; - fn TerminateProcess(process: *mut std::ffi::c_void, exit_code: u32) -> i32; fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32; fn CloseHandle(object: *mut std::ffi::c_void) -> i32; } @@ -234,7 +235,9 @@ pub fn reject_symlink(path: &Path) -> Result<(), String> { #[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; @@ -263,6 +266,24 @@ mod windows_private_acl { 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; @@ -317,6 +338,14 @@ mod windows_private_acl { 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")] @@ -324,6 +353,15 @@ mod windows_private_acl { 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); @@ -397,15 +435,21 @@ mod windows_private_acl { Ok(sid) } - pub fn harden_and_verify(path: &Path, directory: bool) -> Result<(), String> { + 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: [Ptr; 3] = [ - user[0] as Ptr, - system.as_mut_ptr().cast(), - admins.as_mut_ptr().cast(), - ]; + 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 @@ -431,6 +475,12 @@ mod windows_private_acl { return Err("private-acl-unsafe: AddAccessAllowedAceEx failed".to_owned()); } } + Ok((user, system, admins, acl_buf)) + } + + 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 { @@ -453,6 +503,58 @@ mod windows_private_acl { 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, @@ -495,7 +597,7 @@ mod windows_private_acl { && IsValidSid(owner) != 0 && (EqualSid(owner, user[0] as Ptr) != 0 || EqualSid(owner, admins.as_mut_ptr().cast()) != 0) - && (!root || control & 0x1000 != 0) + && control & 0x1000 != 0 }; if !ok { return Err("private-acl-unsafe: DACL header/owner verification failed".to_owned()); @@ -538,6 +640,57 @@ mod windows_private_acl { } 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> { @@ -570,6 +723,15 @@ pub fn ensure_private_dir(dir: &Path) -> Result<(), String> { 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() @@ -579,22 +741,20 @@ pub fn write_private_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { 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())); - let mut opts = OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let mut f = opts - .open(&tmp) - .map_err(|e| format!("failed to create temp file {}: {e}", tmp.display()))?; #[cfg(windows)] - if let Err(e) = windows_private_acl::harden_and_verify(&tmp, false) { - drop(f); - let _ = fs::remove_file(&tmp); - return Err(e); - } + 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())); @@ -608,6 +768,8 @@ pub fn write_private_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { path.display() )); } + #[cfg(unix)] + sync_parent_directory(parent)?; Ok(()) } @@ -629,16 +791,20 @@ pub fn write_regular_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { ".{file_name}.ocx-child.tmp.{}.{nonce}", std::process::id() )); - 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)); - } - let mut f = opts - .open(&tmp) - .map_err(|e| format!("failed to create temp file {}: {e}", tmp.display()))?; + #[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())); @@ -652,6 +818,8 @@ pub fn write_regular_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { path.display() )); } + #[cfg(unix)] + sync_parent_directory(parent)?; Ok(()) } @@ -762,7 +930,8 @@ pub fn spawn_detached_serve( opencodex_home: &Path, codex_home: &Path, listen_port: u16, -) -> Result { + lifecycle_secret: Option<&str>, +) -> Result { let mut cmd = Command::new(exe); cmd.arg("serve") .arg("--foreground") @@ -770,7 +939,11 @@ pub fn spawn_detached_serve( .arg(listen_port.to_string()) .env("OPENCODEX_HOME", opencodex_home) .env("CODEX_HOME", codex_home) - .stdin(Stdio::null()) + .stdin(if lifecycle_secret.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) .stdout(Stdio::null()) .stderr(Stdio::null()); @@ -794,10 +967,22 @@ pub fn spawn_detached_serve( cmd.creation_flags(CREATE_BREAKAWAY_FROM_JOB | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); } - let child = cmd + let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn detached ocx-child serve: {e}"))?; - Ok(child.id()) + if let Some(secret) = lifecycle_secret { + let write_result = match child.stdin.take() { + Some(mut stdin) => stdin.write_all(secret.as_bytes()), + 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 { @@ -830,31 +1015,6 @@ pub fn process_is_alive(pid: u32) -> bool { } } -pub fn force_terminate_pid(pid: u32, hard_kill: bool) { - if pid == 0 { - return; - } - #[cfg(unix)] - { - let sig = if hard_kill { 9 } else { 15 }; - // SAFETY: Caller verified PID ownership via `/healthz` before invoking termination. - let _ = unsafe { kill(pid as i32, sig) }; - } - #[cfg(windows)] - { - let _ = hard_kill; - const PROCESS_TERMINATE: u32 = 0x0001; - // SAFETY: Caller verified PID ownership before invoking TerminateProcess. - unsafe { - let h = OpenProcess(PROCESS_TERMINATE, 0, pid); - if !h.is_null() { - let _ = TerminateProcess(h, 1); - CloseHandle(h); - } - } - } -} - pub fn resolve_user_home() -> PathBuf { if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) { return PathBuf::from(h); diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index 95977b7039a..9219585aed7 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -19,8 +19,8 @@ use crate::platform::{ 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_connection_proof, create_relay_challenge, - is_valid_connection_generation, relay_proof_matches, + 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}; @@ -448,10 +448,56 @@ fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> st match decision { RouteDecision::LocalShutdown => { - let supplied = req.header("x-ocx-child-shutdown").unwrap_or(""); - if let StateReadOutcome::Valid(st) = read_child_state(&shared.paths.state_file) - && !st.shutdown_secret.is_empty() - && constant_time_eq(supplied.as_bytes(), st.shutdown_secret.as_bytes()) + 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"}"#); + }; + let Some(listener_proof) = compute_child_shutdown_proof(&state.shutdown_secret, "listener", nonce) 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", nonce) else { + return Ok(()); + }; + if !valid_frame(&req) || challenge_count != 1 { + return write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#); + } + write!( + writer, + "HTTP/1.1 204 No Content\r\nContent-Length: 0\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(); diff --git a/native/ocx-child/src/relay_auth.rs b/native/ocx-child/src/relay_auth.rs index 7ec94d2d775..b88cd23f25d 100644 --- a/native/ocx-child/src/relay_auth.rs +++ b/native/ocx-child/src/relay_auth.rs @@ -1,7 +1,9 @@ use hmac::{Hmac, Mac}; use sha2::Sha256; -use crate::platform::{constant_time_eq, hex_decode_lower, hex_encode, random_hex, sha256_hex}; +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"; @@ -13,6 +15,27 @@ pub const LINK_RELAY_AUTH_VERSION: &str = "2"; type HmacSha256 = Hmac; +pub fn compute_child_shutdown_proof(secret: &str, role: &str, nonce: &str) -> Option { + if secret.len() != 48 + || !secret + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || (role != "caller" && role != "listener") + || !is_valid_hex64(nonce) + { + 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(nonce.as_bytes()); + Some(hex_encode(&mac.finalize().into_bytes())) +} + pub fn token_fingerprint_hex(raw_token: &str) -> String { sha256_hex(raw_token.as_bytes()) } diff --git a/native/ocx-child/src/state.rs b/native/ocx-child/src/state.rs index e5657bc0c5c..ab15e40155b 100644 --- a/native/ocx-child/src/state.rs +++ b/native/ocx-child/src/state.rs @@ -18,6 +18,7 @@ pub struct ChildPaths { pub state_file: PathBuf, pub key_file: PathBuf, pub journal_file: PathBuf, + pub rollback_file: PathBuf, pub catalog_backup_file: PathBuf, } @@ -29,6 +30,7 @@ impl ChildPaths { 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"), opencodex_home, codex_home, @@ -97,8 +99,19 @@ pub fn read_child_state(path: &Path) -> StateReadOutcome { Err(e) => return StateReadOutcome::Invalid(format!("unreadable state.json: {e}")), }; match serde_json::from_slice::(&bytes) { - Ok(st) if st.schema == CHILD_STATE_SCHEMA => StateReadOutcome::Valid(Box::new(st)), - Ok(st) => StateReadOutcome::Invalid(format!("unsupported schema: {}", st.schema)), + 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}")), } } diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 756d9aa8234..c71415a0410 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -492,6 +492,278 @@ impl Drop for ScratchGuard { } } +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( + &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(&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( + &original, + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home"}]}"#, + &settings, + ) + .unwrap(); + let journal_before = fs::read(&original.journal_file).unwrap(); + assert!( + apply_attach_or_sync( + &other, + "lnk_0123456789abcdef", + "link:t", + 10100, + br#"{"models":[{"slug":"home-next"}]}"#, + &settings, + ) + .is_err() + ); + assert_eq!(fs::read(&original.journal_file).unwrap(), journal_before); + assert!(!other.codex_config_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( + &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(&equivalent, false).unwrap(), + DetachOutcome::ExactRestore + ); +} + +#[test] +fn detach_refuses_a_journal_with_a_foreign_catalog_target() { + let (_guard, original, other, settings) = homes_fixture(); + apply_attach_or_sync( + &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(&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(&paths, None).unwrap(); + apply_attach_or_sync( + &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(&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(&paths, None).unwrap(); + apply_attach_or_sync( + &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(&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()); +} + +#[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( + &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(&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())); diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 45f3ebee069..95d7ad78534 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -12,6 +12,8 @@ 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_bounded_body, read_request_head, validate_and_sanitize_request_headers, }; @@ -21,6 +23,8 @@ use ocx_child::relay_auth::{ compute_connection_proof, compute_relay_proof, relay_proof_matches, token_fingerprint_hex, }; use ocx_child::state::ChildPaths; +#[cfg(unix)] +use ocx_child::state::{CHILD_STATE_SCHEMA, ChildStateV1}; struct MockHomeState { spoof_listener_proof: AtomicBool, @@ -315,6 +319,221 @@ fn raw_http_exchange(port: u16, raw_req: &str) -> String { String::from_utf8_lossy(&out).into_owned() } +#[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()); +} + +#[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), + parity_sequence: 0, + pending_operation: None, + applied_settings: 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()); + 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 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())); @@ -380,6 +599,79 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac 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}"); @@ -391,12 +683,58 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac 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"), diff --git a/structure/remote-link.md b/structure/remote-link.md index b43f103b5b9..b4eff4f9184 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -191,3 +191,5 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src 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. Shared golden vectors in `tests/fixtures/link/ocx-child-conformance.json` lock TypeScript and Rust parity. 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. From 1aa88805bdc40fe618c973b5a63e08bb71d4de8c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 00:46:16 +0900 Subject: [PATCH 13/34] fix(ocx-child): harden recovery identity and shutdown --- native/ocx-child/src/cli.rs | 87 +++- native/ocx-child/src/daemon.rs | 41 +- native/ocx-child/src/journal.rs | 87 ++-- native/ocx-child/src/platform.rs | 86 ++++ native/ocx-child/src/relay.rs | 30 +- native/ocx-child/src/relay_auth.rs | 9 +- native/ocx-child/tests/conformance.rs | 126 ++++++ native/ocx-child/tests/relay_and_lifecycle.rs | 374 +++++++++++++++++- 8 files changed, 767 insertions(+), 73 deletions(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index dc3959cfc78..e7ab47f1603 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -18,12 +18,14 @@ use crate::journal::{ }; use crate::parity::LinkChildParityReport; use crate::platform::{ - FileLock, LockAcquireError, SecretBytes, constant_time_eq, 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, + 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, fetch_home_authenticated, post_child_parity_report, serve_foreground, + 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; @@ -158,10 +160,21 @@ fn read_stdin_credential() -> Result<(String, SecretBytes), String> { 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 { +) -> Result { let (head, body, _) = fetch_home_authenticated( tunnel_port, creds, @@ -171,7 +184,10 @@ fn fetch_home_settings_doc( None, 64 * 1024, ) - .map_err(|e| format!("codex-settings fetch failed: {e:?}"))?; + .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, @@ -188,10 +204,15 @@ fn fetch_home_settings_doc( }); } if head.status != 200 { - return Err(format!("Home codex-settings returned HTTP {}", head.status)); + 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(|| "Home codex-settings failed strict schema validation".to_owned()) + 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 { @@ -214,6 +235,8 @@ 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]>, @@ -231,6 +254,8 @@ 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>, @@ -354,6 +379,8 @@ impl AttachRollbackSnapshot { let (opencodex_home, codex_home) = Self::canonical_homes(paths)?; let record = AttachRollbackRecordRef { schema: "ocx.child.attach-rollback/1", + opencodex_home_identity: directory_identity(std::path::Path::new(&opencodex_home))?, + codex_home_identity: directory_identity(std::path::Path::new(&codex_home))?, opencodex_home, codex_home, prior_state: &self.prior_state, @@ -399,6 +426,8 @@ impl AttachRollbackSnapshot { 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()); } @@ -1132,25 +1161,41 @@ fn cmd_sync(args: &[String]) -> u8 { 4 * 1024 * 1024, ) { Ok((h, b, _)) if h.status == 200 => b, - _ => 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; + 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(_) if stale && state.applied_settings.is_some() => { + Err(e) if stale && e.retryable && state.applied_settings.is_some() => { state.applied_settings.as_ref().unwrap().document.clone() } Err(e) => { diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index 565151f51e2..bb15bc749b7 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -95,12 +95,6 @@ fn send_attested_shutdown(port: u16, shutdown_secret: &str) -> bool { let Ok(nonce) = random_hex(32) else { return false; }; - let Some(expected) = compute_child_shutdown_proof(shutdown_secret, "listener", &nonce) else { - return false; - }; - let Some(caller) = compute_child_shutdown_proof(shutdown_secret, "caller", &nonce) 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; @@ -125,6 +119,21 @@ fn send_attested_shutdown(port: u16, shutdown_secret: &str) -> bool { .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() @@ -133,6 +142,8 @@ fn send_attested_shutdown(port: u16, shutdown_secret: &str) -> bool { .collect(); if head.status != 204 || proof_count != 1 + || nonce_count != 1 + || !crate::relay_auth::is_valid_hex64(server_nonce) || lengths != ["0"] || head .headers @@ -156,16 +167,20 @@ fn send_attested_shutdown(port: u16, shutdown_secret: &str) -> bool { 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 verified_pid = match (state.daemon_pid, &live_health) { - (Some(expected_pid), Some(h)) + let observed_pid = match &live_health { + Some(h) if h.service == "opencodex" && h.ephemeral_child == Some(true) - && expected_pid == h.pid => + && 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)) => { - Some(h.pid) + return Ok(()); } - (Some(pid), None) if !is_port_bound(port) && !process_is_alive(pid) => return Ok(()), - (None, None) if !is_port_bound(port) => return Ok(()), _ => return Err("cannot establish that the child daemon has stopped".to_owned()), }; @@ -175,7 +190,7 @@ pub fn stop_owned_daemon(state: &ChildStateV1) -> Result<(), String> { let deadline = Instant::now() + Duration::from_secs(3); while Instant::now() < deadline { let port_down = !is_port_bound(port); - let pid_down = verified_pid.is_none_or(|p| !process_is_alive(p)); + let pid_down = !process_is_alive(observed_pid); if port_down && pid_down { return Ok(()); } diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index 01460bd607d..8d0aa27ae26 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::codex_settings::HomeCodexSettings; use crate::platform::{ - ensure_private_dir, reject_symlink, sha256_hex, write_private_atomic, write_regular_atomic, + DirectoryIdentity, directory_identity, ensure_private_dir, reject_symlink, sha256_hex, + write_private_atomic, write_regular_atomic, }; use crate::state::ChildPaths; use crate::toml_surgeon::{ @@ -26,6 +27,10 @@ pub struct ChildJournalV1 { 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, @@ -94,20 +99,22 @@ pub fn write_journal(path: &Path, journal: &ChildJournalV1) -> Result<(), String 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 recorded_ocx = fs::canonicalize(&journal.opencodex_home) - .map_err(|e| format!("cannot resolve recorded OpenCodex home: {e}"))?; let current_codex = fs::canonicalize(&paths.codex_home) .map_err(|e| format!("cannot resolve current Codex home: {e}"))?; - let recorded_codex = fs::canonicalize(&journal.codex_home) - .map_err(|e| format!("cannot resolve recorded Codex home: {e}"))?; let catalog = Path::new(&journal.catalog_path); - let catalog_parent = catalog.parent().ok_or("journal catalog has no parent")?; - let recorded_catalog_parent = fs::canonicalize(catalog_parent) - .map_err(|e| format!("cannot resolve journal catalog parent: {e}"))?; - if current_ocx != recorded_ocx - || current_codex != recorded_codex - || current_codex != recorded_catalog_parent - || catalog.file_name() != Some(std::ffi::OsStr::new("opencodex-catalog.json")) + 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()); } @@ -311,7 +318,18 @@ pub fn apply_attach_or_sync_with_fault( detect_unowned_root_routing_conflict(¤t_cfg_text)?; } - let catalog_path_str = catalog_path.to_string_lossy().into_owned(); + 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, @@ -439,8 +457,16 @@ pub fn apply_attach_or_sync_with_fault( schema: CHILD_JOURNAL_SCHEMA.to_owned(), link_id: link_id.to_owned(), api_key_id: api_key_id.to_owned(), - opencodex_home: paths.opencodex_home.to_string_lossy().into_owned(), - codex_home: paths.codex_home.to_string_lossy().into_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, @@ -459,10 +485,6 @@ pub fn apply_attach_or_sync_with_fault( previous_models_cache_sha256: prev_cache_sha, ledger: next_ledger, }; - // Create homes before the journal so later validation can resolve their identities. - ensure_private_dir(&paths.child_dir)?; - fs::create_dir_all(&paths.codex_home) - .map_err(|e| format!("failed to create Codex home: {e}"))?; // Persist recovery journal BEFORE mutating any file in $CODEX_HOME (write-ahead durability). write_journal(&paths.journal_file, &journal)?; if fault == Some(JournalWriteFault::AfterJournalWrite) { @@ -536,6 +558,21 @@ fn offline_detach_inner( 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}")))?; @@ -543,11 +580,9 @@ fn offline_detach_inner( 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 = journal - .pre_attach_catalog_bytes_b64 + let matches_prior = prior_catalog .as_ref() - .and_then(|b64| BASE64.decode(b64).ok()) - .is_some_and(|prior| prior == cat_bytes); + .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(), @@ -663,10 +698,8 @@ fn offline_detach_inner( if !keep_catalog { if journal.pre_attach_catalog_existed { - if let Some(b64) = &journal.pre_attach_catalog_bytes_b64 - && let Ok(prior_bytes) = BASE64.decode(b64) - { - write_regular_atomic(catalog_path, &prior_bytes).map_err(DetachError::Io)?; + if let Some(prior_bytes) = &prior_catalog { + write_regular_atomic(catalog_path, prior_bytes).map_err(DetachError::Io)?; } } else { remove_optional_file(catalog_path).map_err(DetachError::Io)?; diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 1f8e4800067..5b1d3a231cf 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -7,8 +7,94 @@ 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); diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index 9219585aed7..f31b772d3ce 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -12,6 +12,7 @@ use crate::http_wire::{ 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::{ SecretBytes, constant_time_eq, format_iso8601_utc, now_epoch_ms, random_hex, @@ -301,6 +302,19 @@ impl SharedServerState { pub fn serve_foreground(paths: ChildPaths, listen_port: u16) -> Result<(), String> { 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 !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()); + } let actual_port = listener .local_addr() .map_err(|e| format!("local_addr failed: {e}"))? @@ -468,18 +482,22 @@ fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> st 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"}"#); }; - let Some(listener_proof) = compute_child_shutdown_proof(&state.shutdown_secret, "listener", nonce) else { + 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", nonce) else { + let Some(caller_proof) = compute_child_shutdown_proof(&state.shutdown_secret, "caller", &binding) else { return Ok(()); }; - if !valid_frame(&req) || challenge_count != 1 { - return write_json_response(&mut writer, 404, "Not Found", &[], br#"{"error":"not_found"}"#); - } write!( writer, - "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nx-ocx-child-shutdown-proof: {listener_proof}\r\nConnection: keep-alive\r\n\r\n" + "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(()) }; diff --git a/native/ocx-child/src/relay_auth.rs b/native/ocx-child/src/relay_auth.rs index b88cd23f25d..87b09cb9b7e 100644 --- a/native/ocx-child/src/relay_auth.rs +++ b/native/ocx-child/src/relay_auth.rs @@ -15,13 +15,16 @@ pub const LINK_RELAY_AUTH_VERSION: &str = "2"; type HmacSha256 = Hmac; -pub fn compute_child_shutdown_proof(secret: &str, role: &str, nonce: &str) -> Option { +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") - || !is_valid_hex64(nonce) + || binding.len() != 128 + || !binding + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { return None; } @@ -32,7 +35,7 @@ pub fn compute_child_shutdown_proof(secret: &str, role: &str, nonce: &str) -> Op mac.update(b"opencodex-child-shutdown-v1\0"); mac.update(role.as_bytes()); mac.update(b"\0"); - mac.update(nonce.as_bytes()); + mac.update(binding.as_bytes()); Some(hex_encode(&mac.finalize().into_bytes())) } diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index c71415a0410..13b2bf7cb18 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -602,6 +602,99 @@ fn detach_accepts_an_alias_of_the_same_canonical_codex_home() { ); } +#[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( + &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(&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( + &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(&original, false), + Err(DetachError::Conflict(_)) + )); + assert!(original.journal_file.is_file()); + assert!(saved.join("config.toml").is_file()); +} + +#[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( + &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(&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(); @@ -712,6 +805,39 @@ fn detach_recovers_a_durable_incomplete_attach_snapshot() { 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(&paths, None).unwrap(); + apply_attach_or_sync( + &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(&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(); diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 95d7ad78534..f57187a5e1d 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -6,7 +6,7 @@ use std::io::{BufReader, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::Path; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -15,12 +15,13 @@ use ocx_child::daemon::pick_ephemeral_link_port; #[cfg(unix)] use ocx_child::daemon::stop_owned_daemon; use ocx_child::http_wire::{ - read_bounded_body, read_request_head, validate_and_sanitize_request_headers, + read_bounded_body, read_request_head, read_response_head, validate_and_sanitize_request_headers, }; use ocx_child::parity::{LinkChildParityReport, parse_link_child_parity_report}; use ocx_child::platform::{FileLock, process_is_alive}; use ocx_child::relay_auth::{ - compute_connection_proof, compute_relay_proof, relay_proof_matches, token_fingerprint_hex, + compute_child_shutdown_proof, compute_connection_proof, compute_relay_proof, + relay_proof_matches, token_fingerprint_hex, }; use ocx_child::state::ChildPaths; #[cfg(unix)] @@ -30,6 +31,8 @@ struct MockHomeState { spoof_listener_proof: AtomicBool, fast_mode: Mutex>, catalog_slug: Mutex, + catalog_status: AtomicU16, + settings_status: AtomicU16, used_nonces: Mutex>, parity_reports: Mutex>, observed_data_headers: Mutex>>, @@ -54,6 +57,8 @@ impl MockHomeServer { 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()), @@ -219,6 +224,14 @@ fn handle_mock_home_socket( 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!( @@ -228,6 +241,14 @@ fn handle_mock_home_socket( ); } ("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, @@ -360,6 +381,135 @@ fn detached_foreground_serve_refuses_to_start() { 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 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() { @@ -534,6 +684,224 @@ fn native_attach_starts_authorized_daemon_and_clears_rollback_snapshot() { 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())); From 39075461b9110cf4bad25d371f2685bd2db625c9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 01:02:16 +0900 Subject: [PATCH 14/34] fix(ocx-child): preserve local state during detach and sync --- native/ocx-child/src/cli.rs | 63 ++++++- native/ocx-child/src/journal.rs | 174 ++++++++++++++---- native/ocx-child/src/relay.rs | 5 +- native/ocx-child/src/toml_surgeon.rs | 4 +- native/ocx-child/tests/conformance.rs | 160 +++++++++++++++- native/ocx-child/tests/relay_and_lifecycle.rs | 82 +++++++++ 6 files changed, 443 insertions(+), 45 deletions(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index e7ab47f1603..a2dfd13dc06 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -216,6 +216,10 @@ fn fetch_home_settings_doc( } 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>, @@ -330,10 +334,28 @@ impl AttachRollbackSnapshot { } pub fn capture(paths: &ChildPaths, prior_state: Option) -> Result { + 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)?, @@ -344,6 +366,7 @@ impl AttachRollbackSnapshot { 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) } @@ -358,6 +381,18 @@ impl AttachRollbackSnapshot { 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(); @@ -376,13 +411,13 @@ impl AttachRollbackSnapshot { Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) => return Err(format!("cannot inspect rollback snapshot: {e}")), } - let (opencodex_home, codex_home) = Self::canonical_homes(paths)?; + self.validate_homes(paths)?; let record = AttachRollbackRecordRef { schema: "ocx.child.attach-rollback/1", - opencodex_home_identity: directory_identity(std::path::Path::new(&opencodex_home))?, - codex_home_identity: directory_identity(std::path::Path::new(&codex_home))?, - opencodex_home, - codex_home, + 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(), @@ -439,6 +474,10 @@ impl AttachRollbackSnapshot { } } 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, @@ -481,6 +520,7 @@ impl AttachRollbackSnapshot { } pub fn restore(&self, paths: &ChildPaths) -> Result<(), String> { + self.validate_homes(paths)?; let mut errors = Vec::new(); Self::restore_path( &paths.codex_config_path(), @@ -992,7 +1032,10 @@ fn cmd_sync(args: &[String]) -> u8 { if arg == "--json" { json_mode = true; i += 1; - } else if arg == "--local-only" || arg == "--restart-codex" { + } else if arg == "--local-only" + || arg == "--restart-codex" + || arg == "--restart-app-server-only" + { i += 1; } else if arg == "--expect-link" { expect_count += 1; @@ -1304,6 +1347,14 @@ fn cmd_detach(args: &[String]) -> u8 { 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(&paths) { eprintln!("detach recovery failed: {e}"); return 1; diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index 8d0aa27ae26..34b3e92830d 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -50,6 +50,8 @@ pub struct ChildJournalV1 { 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, } @@ -211,12 +213,20 @@ pub fn prepare_codex_models_cache( 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 + 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) { @@ -229,7 +239,12 @@ pub fn prepare_codex_models_cache( .get("slug") .and_then(serde_json::Value::as_str) .unwrap_or(""); - if is_obs && !slug.is_empty() && !catalog_slugs.contains(slug) { + 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()); } } @@ -246,7 +261,7 @@ pub fn prepare_codex_models_cache( .map_err(|e| format!("failed serializing models_cache.json: {e}"))?; rendered.push(b'\n'); let digest = sha256_hex(&rendered); - let to_write = (existing_bytes.as_deref() != Some(rendered.as_slice())).then_some(rendered); + let to_write = (existing_bytes != Some(rendered.as_slice())).then_some(rendered); Ok((to_write, digest)) } @@ -415,44 +430,108 @@ pub fn apply_attach_or_sync_with_fault( } }; - let (pre_cache_existed, 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()) + 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())), } - 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")?; + let reconciled = reconcile_modified_models_cache( + current, + original.as_deref(), + &paths.catalog_backup_file, + )?; + let local: serde_json::Value = serde_json::from_slice(&reconciled) + .map_err(|e| format!("invalid reconciled models_cache.json: {e}"))?; + let local_models = local + .get("models") + .and_then(serde_json::Value::as_array) + .ok_or("reconciled models_cache.json missing models array")?; + for entry in local_models { + if let Some(slug) = entry.get("slug").and_then(serde_json::Value::as_str) + && catalog_models.iter().any(|model| { + model.get("slug").and_then(serde_json::Value::as_str) == Some(slug) + && model != entry + }) + { + return Err(format!( + "client_models_cache_conflict: local model {slug} conflicts with Home catalog" + )); + } + } + 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)? }; - let (prepared_cache, latest_cache_sha) = - prepare_codex_models_cache(&cache_path, &catalog_models)?; - let journal = ChildJournalV1 { schema: CHILD_JOURNAL_SCHEMA.to_owned(), link_id: link_id.to_owned(), @@ -483,6 +562,7 @@ pub fn apply_attach_or_sync_with_fault( 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). @@ -547,7 +627,7 @@ fn offline_detach_inner( keep_catalog: bool, defer_cleanup: bool, ) -> Result { - let Some(journal) = read_journal(&paths.journal_file).map_err(DetachError::Io)? else { + 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)?; @@ -722,6 +802,28 @@ fn offline_detach_inner( } } + // 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; + write_journal(&paths.journal_file, &journal).map_err(DetachError::Io)?; + if !defer_cleanup { remove_optional_file(&paths.catalog_backup_file).map_err(DetachError::Io)?; remove_optional_file(&paths.journal_file).map_err(DetachError::Io)?; diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index f31b772d3ce..cb5aaf2ef33 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -15,7 +15,7 @@ use crate::http_wire::{ use crate::journal::validate_existing_journal_paths; use crate::parity::LinkChildParityReport; use crate::platform::{ - SecretBytes, constant_time_eq, format_iso8601_utc, now_epoch_ms, random_hex, + 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, @@ -300,6 +300,8 @@ impl SharedServerState { } pub fn serve_foreground(paths: ChildPaths, listen_port: u16) -> 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) { @@ -315,6 +317,7 @@ pub fn serve_foreground(paths: ChildPaths, listen_port: u16) -> Result<(), Strin { 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}"))? diff --git a/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs index 075fda85490..06548341b11 100644 --- a/native/ocx-child/src/toml_surgeon.rs +++ b/native/ocx-child/src/toml_surgeon.rs @@ -545,11 +545,13 @@ fn strip_owned_web_search(content_lf: &str, injected_val: Option<&str>) -> Strin 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 marker_above || value_match { + 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; } } } diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 13b2bf7cb18..42e4adad729 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -16,7 +16,7 @@ use ocx_child::http_wire::{ }; use ocx_child::journal::{ DetachError, DetachOutcome, JournalWriteFault, apply_attach_or_sync, - apply_attach_or_sync_with_fault, offline_detach, + 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::{ @@ -24,6 +24,148 @@ use ocx_child::relay_auth::{ }; 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(); + 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( + &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(&paths, false).unwrap(); + let restored = fs::read(&cache_path).unwrap(); + fs::remove_file(&paths.catalog_backup_file).unwrap(); + offline_detach_deferred(&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( + &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( + &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( + &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(&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") + ); +} #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -659,6 +801,22 @@ fn detach_refuses_a_replaced_codex_home_directory() { 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(&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(&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!")] { diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index f57187a5e1d..a25d3aa1092 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -432,6 +432,80 @@ fn direct_foreground_server_revalidates_attachment_after_binding() { 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!( @@ -672,6 +746,14 @@ fn native_attach_starts_authorized_daemon_and_clears_rollback_snapshot() { ); 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, From 768a0583dcf79f6fbd8087afdd5f7c51fe00edce Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:40:16 +0900 Subject: [PATCH 15/34] test(ocx-child): add env-gated test barrier and shared mock Home harness Process-level lifecycle regressions need to pause a real ocx-child at a named point. test_hooks::barrier is a no-op unless both OCX_CHILD_TEST_BARRIER_DIR and OCX_CHILD_TEST_BARRIERS are set; spawned serve children never inherit those variables. The mock Home and binary runners move into tests/common so new test files can share them; the mock gains parity stall/trickle modes. --- native/ocx-child/src/cli.rs | 12 + native/ocx-child/src/daemon.rs | 1 + native/ocx-child/src/lib.rs | 1 + native/ocx-child/src/platform.rs | 2 + native/ocx-child/src/test_hooks.rs | 38 ++ native/ocx-child/tests/common/mod.rs | 486 ++++++++++++++++++ native/ocx-child/tests/relay_and_lifecycle.rs | 330 +----------- 7 files changed, 546 insertions(+), 324 deletions(-) create mode 100644 native/ocx-child/src/test_hooks.rs create mode 100644 native/ocx-child/tests/common/mod.rs diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index a2dfd13dc06..8da1c3e98f4 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -499,6 +499,17 @@ impl AttachRollbackSnapshot { bytes: Option<&[u8]>, private: bool, errors: &mut Vec, + ) { + crate::test_hooks::barrier("restore-before-write"); + Self::restore_path_inner(path, bytes, private, errors); + crate::test_hooks::barrier("restore-after-write"); + } + + fn restore_path_inner( + path: &std::path::Path, + bytes: Option<&[u8]>, + private: bool, + errors: &mut Vec, ) { match bytes { Some(b) => { @@ -679,6 +690,7 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } }; + crate::test_hooks::barrier("attach-protected"); if !matches!(rollback_pending(&paths), Ok(false)) { eprintln!("attach refused: incomplete attach requires detach recovery"); return 1; diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index bb15bc749b7..8f745f9fd72 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -254,6 +254,7 @@ pub fn start_or_rollover_daemon( "failed to record spawned child: {e}; cleanup: {cleanup:?}" )); } + crate::test_hooks::barrier("daemon-before-commit"); return Ok((spawned_pid, started_ms)); } thread::sleep(Duration::from_millis(50)); diff --git a/native/ocx-child/src/lib.rs b/native/ocx-child/src/lib.rs index 127650526dd..6ac8eef4a0a 100644 --- a/native/ocx-child/src/lib.rs +++ b/native/ocx-child/src/lib.rs @@ -11,6 +11,7 @@ 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. diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 5b1d3a231cf..b13be685fdc 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -1025,6 +1025,8 @@ pub fn spawn_detached_serve( .arg(listen_port.to_string()) .env("OPENCODEX_HOME", opencodex_home) .env("CODEX_HOME", codex_home) + .env_remove(crate::test_hooks::BARRIER_DIR_ENV) + .env_remove(crate::test_hooks::BARRIERS_ENV) .stdin(if lifecycle_secret.is_some() { Stdio::piped() } else { diff --git a/native/ocx-child/src/test_hooks.rs b/native/ocx-child/src/test_hooks.rs new file mode 100644 index 00000000000..03d40d1d551 --- /dev/null +++ b/native/ocx-child/src/test_hooks.rs @@ -0,0 +1,38 @@ +//! Env-gated synchronization points used by process-level lifecycle tests. +//! +//! 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. + +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +pub const BARRIER_DIR_ENV: &str = "OCX_CHILD_TEST_BARRIER_DIR"; +pub const BARRIERS_ENV: &str = "OCX_CHILD_TEST_BARRIERS"; + +pub fn barrier(name: &str) { + 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)); + } +} diff --git a/native/ocx-child/tests/common/mod.rs b/native/ocx-child/tests/common/mod.rs new file mode 100644 index 00000000000..46435d02056 --- /dev/null +++ b/native/ocx-child/tests/common/mod.rs @@ -0,0 +1,486 @@ +#![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 forever. Applies only to reports with sequence >= 2, so the + /// lifecycle command's own report (sequence 1) still completes. + 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 && parsed.sequence >= 2 { + 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/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index a25d3aa1092..75aa7c6ef77 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -1,12 +1,13 @@ #![deny(unsafe_op_in_unsafe_fn)] -use std::collections::HashSet; +mod common; +use common::*; + use std::fs; use std::io::{BufReader, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::path::Path; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -14,332 +15,13 @@ 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_bounded_body, read_request_head, read_response_head, validate_and_sanitize_request_headers, -}; -use ocx_child::parity::{LinkChildParityReport, parse_link_child_parity_report}; +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, compute_connection_proof, compute_relay_proof, - relay_proof_matches, token_fingerprint_hex, -}; +use ocx_child::relay_auth::compute_child_shutdown_proof; use ocx_child::state::ChildPaths; #[cfg(unix)] use ocx_child::state::{CHILD_STATE_SCHEMA, ChildStateV1}; -struct MockHomeState { - spoof_listener_proof: AtomicBool, - fast_mode: Mutex>, - catalog_slug: Mutex, - catalog_status: AtomicU16, - settings_status: AtomicU16, - used_nonces: Mutex>, - parity_reports: Mutex>, - observed_data_headers: Mutex>>, - stop: AtomicBool, -} - -struct MockHomeServer { - port: u16, - state: Arc, - handle: Option>, -} - -impl MockHomeServer { - 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), - }); - 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), - } - } - - 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"); - 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(); -} - -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(), - ) -} - -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() -} - #[test] fn detached_foreground_serve_refuses_to_start() { let scratch = std::env::temp_dir().join(format!( From fb6d5d57a1d2304238b71884e48f0ef2fe5321b4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:46:34 +0900 Subject: [PATCH 16/34] fix(ocx-child): serialize and own the shared CODEX_HOME The lifecycle lock and journal live under OPENCODEX_HOME/child while config.toml, the catalog and models_cache.json live in CODEX_HOME. Two ocx-child installs with different OPENCODEX_HOMEs and one CODEX_HOME took independent locks and could attach, sync and detach the same files with incompatible journals. Every writing lifecycle command now also locks a fixed .ocx-child.lock inside the canonical CODEX_HOME (never deleted) and checks a durable ownership record (.ocx-child-owner.json: owner id, canonical OPENCODEX_HOME, directory identities, phase; no secrets). Lock order is lifecycle lock, CODEX_HOME lock, daemon start gate. A foreign, corrupt or unreadable record refuses without mutation and names the recovery step; ownership is never taken over by PID or timeout. Detach releases ownership last, after a durable cleanup-complete phase, so any crash is resumable. Journal application and offline detach take the guard as a parameter. Regression: tests/lifecycle_guards.rs runs two real processes against one CODEX_HOME with a barrier inside the protected section. --- native/ocx-child/src/cli.rs | 261 ++++++++++-- native/ocx-child/src/codex_home.rs | 393 ++++++++++++++++++ native/ocx-child/src/journal.rs | 22 +- native/ocx-child/src/lib.rs | 1 + native/ocx-child/src/platform.rs | 22 +- native/ocx-child/src/state.rs | 14 + native/ocx-child/tests/conformance.rs | 131 +++--- native/ocx-child/tests/lifecycle_guards.rs | 169 ++++++++ native/ocx-child/tests/relay_and_lifecycle.rs | 2 +- structure/remote-link.md | 4 +- 10 files changed, 909 insertions(+), 110 deletions(-) create mode 100644 native/ocx-child/src/codex_home.rs create mode 100644 native/ocx-child/tests/lifecycle_guards.rs diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 8da1c3e98f4..53123429f5b 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -4,6 +4,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; +use crate::codex_home::{ClaimPolicy, CodexHomeError, CodexHomeGuard, OwnershipPhase}; use crate::codex_settings::{ HomeCodexSettings, HomeCodexSettingsDocument, HomeIdentity, codex_settings_digest, parse_home_codex_settings_document, @@ -333,7 +334,11 @@ impl AttachRollbackSnapshot { } } - pub fn capture(paths: &ChildPaths, prior_state: Option) -> Result { + 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); @@ -439,7 +444,8 @@ impl AttachRollbackSnapshot { result } - fn recover(paths: &ChildPaths) -> 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, @@ -490,7 +496,7 @@ impl AttachRollbackSnapshot { state_bytes: record.state_bytes.take(), }; let _ = record.prior_was_serving; - snapshot.restore(paths)?; + snapshot.restore(guard)?; Ok(true) } @@ -530,7 +536,8 @@ impl AttachRollbackSnapshot { } } - pub fn restore(&self, paths: &ChildPaths) -> Result<(), String> { + pub fn restore(&self, guard: &CodexHomeGuard) -> Result<(), String> { + let paths = guard.paths(); self.validate_homes(paths)?; let mut errors = Vec::new(); Self::restore_path( @@ -617,9 +624,14 @@ impl AttachRollbackSnapshot { } } - fn restore_and_report(&self, paths: &ChildPaths) { - if let Err(rb_err) = self.restore(paths) { - eprintln!("{rb_err}"); + /// 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 + } } } } @@ -681,8 +693,8 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } }; - let paths = current_paths(); - let _lock = match FileLock::acquire_blocking(&paths.lock_file) { + 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"); @@ -690,11 +702,68 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> } }; - crate::test_hooks::barrier("attach-protected"); - if !matches!(rollback_pending(&paths), Ok(false)) { + 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, @@ -705,14 +774,14 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> .and_then(|prev| prev.daemon_pid); if let Err(e) = ensure_port_available_for_attach(listen_port, existing_pid) { eprintln!("{e}"); - return 1; + 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 2; + return Err(refuse(2)); } }; let fp = crate::relay_auth::token_fingerprint_hex(raw_key_str); @@ -736,11 +805,11 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> Ok((h, _, _)) if h.status == 200 => {} Ok((h, _, _)) => { eprintln!("Home /readyz returned HTTP {}", h.status); - return 1; + return Err(refuse(1)); } Err(e) => { eprintln!("Home /readyz unreachable over tunnel: {e:?}"); - return 1; + return Err(refuse(1)); } } @@ -756,11 +825,11 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> Ok((h, b, _)) if h.status == 200 => b, Ok((h, _, _)) => { eprintln!("Home /v1/catalog returned HTTP {}", h.status); - return 1; + return Err(refuse(1)); } Err(e) => { eprintln!("Home /v1/catalog failed: {e:?}"); - return 1; + return Err(refuse(1)); } }; @@ -768,20 +837,25 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> Ok(d) => d, Err(e) => { eprintln!("{e}"); - return 1; + return Err(refuse(1)); } }; - let rollback = match AttachRollbackSnapshot::capture(&paths, prior_state.clone()) { + let rollback = match AttachRollbackSnapshot::capture(guard, prior_state.clone()) { Ok(r) => r, Err(e) => { eprintln!("{e}"); - return 1; + // 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( - &paths, + guard, &link_id, &api_key_id, listen_port, @@ -790,16 +864,22 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> ) { Ok(j) => j, Err(e) => { - rollback.restore_and_report(&paths); + let restored = rollback.restore_and_report(guard); eprintln!("{e}"); - return 1; + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); } }; if let Err(e) = write_link_key(&paths.key_file, raw_key_str) { - rollback.restore_and_report(&paths); + let restored = rollback.restore_and_report(guard); eprintln!("{e}"); - return 1; + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); } let now_ms = now_epoch_ms(); @@ -808,21 +888,27 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> let shutdown_secret = match random_hex(24) { Ok(s) => s, Err(e) => { - rollback.restore_and_report(&paths); + let restored = rollback.restore_and_report(guard); eprintln!("{e}"); - return 1; + 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) { - rollback.restore_and_report(&paths); + let restored = rollback.restore_and_report(guard); eprintln!( "attach failed stopping previous relay on port {}: {e}", prev.listen_port ); - return 1; + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); } let mut state = ChildStateV1 { schema: CHILD_STATE_SCHEMA.to_owned(), @@ -849,9 +935,12 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> }), }; if let Err(e) = write_child_state(&paths.state_file, &state) { - rollback.restore_and_report(&paths); + let restored = rollback.restore_and_report(guard); eprintln!("{e}"); - return 1; + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); } if start_daemon { @@ -869,20 +958,34 @@ fn cmd_attach_or_connect(args: &[String], native_attach_default_start: bool) -> let _ = post_child_parity_report(tunnel_port, &creds, &report); } Err(e) => { - rollback.restore_and_report(&paths); + let restored = rollback.restore_and_report(guard); eprintln!("{e}"); - return 1; + return Err(AttachFailure { + code: 1, + left_no_changes: restored, + }); } } } + if let Err(e) = guard.set_phase(OwnershipPhase::Attached) { + 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) { eprintln!("attach failed to clear rollback snapshot: {e}"); - return 1; + return Err(AttachFailure { + code: 1, + left_no_changes: false, + }); } println!("Connected to http://127.0.0.1:{tunnel_port} as key {api_key_id}."); println!("Local Codex CLI: ready (ephemeral ocx-child Design B attached)."); - 0 + Ok(()) } fn cmd_serve(args: &[String]) -> u8 { @@ -973,6 +1076,14 @@ fn cmd_restart() -> u8 { 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 => { @@ -1180,6 +1291,23 @@ fn cmd_sync(args: &[String]) -> u8 { { 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"); @@ -1260,7 +1388,7 @@ fn cmd_sync(args: &[String]) -> u8 { }; let journal = match apply_attach_or_sync( - &paths, + &codex_guard, &state.link_id, &state.api_key_id, state.listen_port, @@ -1351,14 +1479,30 @@ fn cmd_sync(args: &[String]) -> u8 { 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 paths = current_paths(); - let _lock = match FileLock::acquire_blocking(&paths.lock_file) { + 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, @@ -1367,7 +1511,7 @@ fn cmd_detach(args: &[String]) -> u8 { return 1; } }; - if let Err(e) = AttachRollbackSnapshot::recover(&paths) { + if let Err(e) = AttachRollbackSnapshot::recover(&codex_guard) { eprintln!("detach recovery failed: {e}"); return 1; } @@ -1412,7 +1556,7 @@ fn cmd_detach(args: &[String]) -> u8 { return 1; } } - let outcome = match offline_detach_deferred(&paths, keep_catalog) { + let outcome = match offline_detach_deferred(&codex_guard, keep_catalog) { Ok(o) => o, Err(DetachError::Conflict(msg)) => { eprintln!("detach conflict: {msg}"); @@ -1434,6 +1578,17 @@ fn cmd_detach(args: &[String]) -> u8 { 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 { @@ -1441,6 +1596,32 @@ fn cmd_detach(args: &[String]) -> u8 { "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 } diff --git a/native/ocx-child/src/codex_home.rs b/native/ocx-child/src/codex_home.rs new file mode 100644 index 00000000000..85e8fbd068b --- /dev/null +++ b/native/ocx-child/src/codex_home.rs @@ -0,0 +1,393 @@ +//! 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(), + } + } +} + +#[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(()) + } + + 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/journal.rs b/native/ocx-child/src/journal.rs index 34b3e92830d..edb76be5e3a 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -6,6 +6,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use serde::{Deserialize, Serialize}; +use crate::codex_home::CodexHomeGuard; use crate::codex_settings::HomeCodexSettings; use crate::platform::{ DirectoryIdentity, directory_identity, ensure_private_dir, reject_symlink, sha256_hex, @@ -277,7 +278,7 @@ pub fn refresh_codex_models_cache( } pub fn apply_attach_or_sync( - paths: &ChildPaths, + guard: &CodexHomeGuard, link_id: &str, api_key_id: &str, listen_port: u16, @@ -285,7 +286,7 @@ pub fn apply_attach_or_sync( settings: &HomeCodexSettings, ) -> Result { apply_attach_or_sync_with_fault( - paths, + guard, link_id, api_key_id, listen_port, @@ -296,7 +297,7 @@ pub fn apply_attach_or_sync( } pub fn apply_attach_or_sync_with_fault( - paths: &ChildPaths, + guard: &CodexHomeGuard, link_id: &str, api_key_id: &str, listen_port: u16, @@ -304,6 +305,10 @@ pub fn apply_attach_or_sync_with_fault( 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(); @@ -587,18 +592,18 @@ pub fn apply_attach_or_sync_with_fault( /// Performs 100% offline two-tier restoration of `config.toml`, `opencodex-catalog.json`, and `models_cache.json`. pub fn offline_detach( - paths: &ChildPaths, + guard: &CodexHomeGuard, keep_catalog: bool, ) -> Result { - offline_detach_inner(paths, keep_catalog, false) + offline_detach_inner(guard, keep_catalog, false) } /// Restore client files while retaining recovery records until lifecycle cleanup succeeds. pub fn offline_detach_deferred( - paths: &ChildPaths, + guard: &CodexHomeGuard, keep_catalog: bool, ) -> Result { - offline_detach_inner(paths, keep_catalog, true) + offline_detach_inner(guard, keep_catalog, true) } pub fn remove_optional_file(path: &Path) -> Result<(), String> { @@ -623,10 +628,11 @@ pub fn remove_optional_file(path: &Path) -> Result<(), String> { } fn offline_detach_inner( - paths: &ChildPaths, + 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); }; diff --git a/native/ocx-child/src/lib.rs b/native/ocx-child/src/lib.rs index 6ac8eef4a0a..fc3c10ef223 100644 --- a/native/ocx-child/src/lib.rs +++ b/native/ocx-child/src/lib.rs @@ -1,6 +1,7 @@ #![deny(unsafe_op_in_unsafe_fn)] pub mod cli; +pub mod codex_home; pub mod codex_settings; pub mod daemon; pub mod http_wire; diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index b13be685fdc..273789a99ad 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -921,16 +921,30 @@ pub enum LockAcquireError { impl FileLock { pub fn try_acquire_nonblocking(path: &Path) -> Result { - Self::acquire_inner(path, false) + Self::acquire_inner(path, false, true) } pub fn acquire_blocking(path: &Path) -> Result { - Self::acquire_inner(path, true) + Self::acquire_inner(path, true, true) } - fn acquire_inner(path: &Path, blocking: bool) -> Result { + /// 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)?; - ensure_private_dir(parent).map_err(|_| 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)?; #[cfg(windows)] let existed = path.exists(); diff --git a/native/ocx-child/src/state.rs b/native/ocx-child/src/state.rs index ab15e40155b..60b1b4a384a 100644 --- a/native/ocx-child/src/state.rs +++ b/native/ocx-child/src/state.rs @@ -20,6 +20,8 @@ pub struct ChildPaths { 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 { @@ -32,6 +34,7 @@ impl ChildPaths { 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, @@ -49,6 +52,17 @@ impl ChildPaths { 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)] diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 42e4adad729..2fda9ea4d0f 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -6,6 +6,7 @@ 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, @@ -69,7 +70,7 @@ fn detached_reconciled_cache_can_be_retried_after_backup_cleanup() { subagent_defaults: None, }; apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -85,10 +86,11 @@ fn detached_reconciled_cache_can_be_retried_after_backup_cleanup() { .unwrap() .push(serde_json::json!({"slug":"local-added"})); fs::write(&cache_path, serde_json::to_vec(&cache).unwrap()).unwrap(); - offline_detach_deferred(&paths, false).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(&paths, false).expect("retry must not depend on removed backup"); + 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")); } @@ -113,7 +115,7 @@ fn sync_preserves_new_external_cache_models_through_detach() { subagent_defaults: None, }; apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -130,7 +132,7 @@ fn sync_preserves_new_external_cache_models_through_detach() { .push(serde_json::json!({"slug":"local-added"})); fs::write(&cache_path, serde_json::to_vec(&cache).unwrap()).unwrap(); apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -144,7 +146,7 @@ fn sync_preserves_new_external_cache_models_through_detach() { .contains("local-added") ); apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -157,7 +159,7 @@ fn sync_preserves_new_external_cache_models_through_detach() { .unwrap() .contains("local-added") ); - offline_detach(&paths, false).unwrap(); + offline_detach(&detach_guard(&paths), false).unwrap(); let detached = fs::read_to_string(cache_path).unwrap(); assert!(detached.contains("local-added")); assert!( @@ -427,7 +429,7 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { let sample_catalog = b"{\"models\":[{\"slug\":\"home-m1\"}]}\n"; let attach_res = apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:test", row.listen_port, @@ -442,7 +444,7 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { ); if !row.expected_attach_ok { - let detach_res = offline_detach(&paths, false).unwrap(); + 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(); @@ -454,7 +456,7 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { if let Some(sync_set) = &row.sync_settings { apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:test", row.listen_port, @@ -549,7 +551,7 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { let snap_cat = fs::read(paths.codex_catalog_path()).ok(); let snap_journal = fs::read(&paths.journal_file).ok(); - match offline_detach(&paths, false) { + match offline_detach(&detach_guard(&paths), false) { Ok(DetachOutcome::ExactRestore) => { assert_eq!( row.expected_detach_outcome, "exact_restore", @@ -626,6 +628,16 @@ fn conformance_journal_roundtrips_and_1798_detach_match_typescript() { } } +/// 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 { @@ -664,7 +676,7 @@ fn detach_refuses_a_different_codex_home_before_mutating_the_original() { ) .unwrap(); apply_attach_or_sync( - &original, + &owned(&original), "lnk_0123456789abcdef", "link:t", 10100, @@ -677,7 +689,7 @@ fn detach_refuses_a_different_codex_home_before_mutating_the_original() { let journal_before = fs::read(&original.journal_file).unwrap(); assert!(matches!( - offline_detach(&other, false), + offline_detach(&detach_guard(&other), false), Err(DetachError::Conflict(_)) )); assert_eq!( @@ -696,7 +708,7 @@ fn detach_refuses_a_different_codex_home_before_mutating_the_original() { fn sync_refuses_a_different_codex_home_without_rewriting_the_journal() { let (_guard, original, other, settings) = homes_fixture(); apply_attach_or_sync( - &original, + &owned(&original), "lnk_0123456789abcdef", "link:t", 10100, @@ -705,9 +717,15 @@ fn sync_refuses_a_different_codex_home_without_rewriting_the_journal() { ) .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( - &other, + &unowned, "lnk_0123456789abcdef", "link:t", 10100, @@ -716,8 +734,10 @@ fn sync_refuses_a_different_codex_home_without_rewriting_the_journal() { ) .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)] @@ -727,7 +747,7 @@ fn detach_accepts_an_alias_of_the_same_canonical_codex_home() { let (guard, original, _other, settings) = homes_fixture(); apply_attach_or_sync( - &original, + &owned(&original), "lnk_0123456789abcdef", "link:t", 10100, @@ -739,7 +759,7 @@ fn detach_accepts_an_alias_of_the_same_canonical_codex_home() { symlink(&original.codex_home, &alias).unwrap(); let equivalent = ChildPaths::new(original.opencodex_home.clone(), alias); assert_eq!( - offline_detach(&equivalent, false).unwrap(), + offline_detach(&detach_guard(&equivalent), false).unwrap(), DetachOutcome::ExactRestore ); } @@ -754,7 +774,7 @@ fn detach_refuses_a_retargeted_codex_home_alias() { symlink(&original.codex_home, &alias).unwrap(); let aliased = ChildPaths::new(original.opencodex_home.clone(), alias.clone()); apply_attach_or_sync( - &aliased, + &owned(&aliased), "lnk_0123456789abcdef", "link:t", 10100, @@ -768,7 +788,7 @@ fn detach_refuses_a_retargeted_codex_home_alias() { symlink(&other.codex_home, &alias).unwrap(); assert!(matches!( - offline_detach(&aliased, false), + offline_detach(&detach_guard(&aliased), false), Err(DetachError::Conflict(_)) )); assert!(fs::read(original.codex_config_path()).unwrap() == original_config); @@ -781,7 +801,7 @@ fn detach_refuses_a_retargeted_codex_home_alias() { fn detach_refuses_a_replaced_codex_home_directory() { let (guard, original, _other, settings) = homes_fixture(); apply_attach_or_sync( - &original, + &owned(&original), "lnk_0123456789abcdef", "link:t", 10100, @@ -794,7 +814,7 @@ fn detach_refuses_a_replaced_codex_home_directory() { fs::create_dir(&original.codex_home).unwrap(); assert!(matches!( - offline_detach(&original, false), + offline_detach(&detach_guard(&original), false), Err(DetachError::Conflict(_)) )); assert!(original.journal_file.is_file()); @@ -805,11 +825,11 @@ fn detach_refuses_a_replaced_codex_home_directory() { 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(&original, None).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(&original).is_err()); + assert!(rollback.restore(&detach_guard(&original)).is_err()); assert_eq!( fs::read(original.codex_config_path()).unwrap(), b"replacement config" @@ -827,7 +847,7 @@ fn detach_refuses_missing_or_invalid_catalog_snapshot_before_mutation() { ) .unwrap(); apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -844,7 +864,7 @@ fn detach_refuses_missing_or_invalid_catalog_snapshot_before_mutation() { let journal_before = fs::read(&paths.journal_file).unwrap(); assert!(matches!( - offline_detach(&paths, false), + offline_detach(&detach_guard(&paths), false), Err(DetachError::Conflict(_)) )); assert!(fs::read(paths.codex_config_path()).unwrap() == config_before); @@ -857,7 +877,7 @@ fn detach_refuses_missing_or_invalid_catalog_snapshot_before_mutation() { fn detach_refuses_a_journal_with_a_foreign_catalog_target() { let (_guard, original, other, settings) = homes_fixture(); apply_attach_or_sync( - &original, + &owned(&original), "lnk_0123456789abcdef", "link:t", 10100, @@ -877,7 +897,7 @@ fn detach_refuses_a_journal_with_a_foreign_catalog_target() { .unwrap(); assert!(matches!( - offline_detach(&original, false), + offline_detach(&detach_guard(&original), false), Err(DetachError::Conflict(_)) )); assert_eq!(fs::read(foreign).unwrap(), b"foreign"); @@ -890,9 +910,9 @@ fn failed_first_attach_rollback_keeps_the_recovery_journal() { 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(&paths, None).unwrap(); + let snapshot = AttachRollbackSnapshot::capture(&owned(&paths), None).unwrap(); apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -904,7 +924,7 @@ fn failed_first_attach_rollback_keeps_the_recovery_journal() { fs::remove_file(paths.codex_config_path()).unwrap(); fs::create_dir(paths.codex_config_path()).unwrap(); - assert!(snapshot.restore(&paths).is_err()); + assert!(snapshot.restore(&detach_guard(&paths)).is_err()); assert!( paths.journal_file.is_file(), "recovery journal must survive an incomplete rollback" @@ -922,9 +942,9 @@ fn failed_first_attach_rollback_keeps_the_recovery_journal() { #[test] fn detach_recovers_a_durable_incomplete_attach_snapshot() { let (_guard, paths, other, settings) = homes_fixture(); - let snapshot = AttachRollbackSnapshot::capture(&paths, None).unwrap(); + let snapshot = AttachRollbackSnapshot::capture(&owned(&paths), None).unwrap(); apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -934,7 +954,7 @@ fn detach_recovers_a_durable_incomplete_attach_snapshot() { .unwrap(); fs::remove_file(paths.codex_config_path()).unwrap(); fs::create_dir(paths.codex_config_path()).unwrap(); - assert!(snapshot.restore(&paths).is_err()); + 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") @@ -967,9 +987,9 @@ fn detach_recovers_a_durable_incomplete_attach_snapshot() { #[test] fn rollback_recovery_refuses_a_replaced_codex_home_directory() { let (guard, paths, _other, settings) = homes_fixture(); - let snapshot = AttachRollbackSnapshot::capture(&paths, None).unwrap(); + let snapshot = AttachRollbackSnapshot::capture(&owned(&paths), None).unwrap(); apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -979,7 +999,7 @@ fn rollback_recovery_refuses_a_replaced_codex_home_directory() { .unwrap(); fs::remove_file(paths.codex_config_path()).unwrap(); fs::create_dir(paths.codex_config_path()).unwrap(); - assert!(snapshot.restore(&paths).is_err()); + 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(); @@ -1028,7 +1048,7 @@ fn detach_keeps_recovery_journal_when_required_file_removal_fails() { let (_guard, paths, _other, settings) = homes_fixture(); apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1041,7 +1061,7 @@ fn detach_keeps_recovery_journal_when_required_file_removal_fails() { fs::set_permissions(&paths.codex_home, fs::Permissions::from_mode(0o500)).unwrap(); assert!(matches!( - offline_detach(&paths, false), + offline_detach(&detach_guard(&paths), false), Err(DetachError::Io(_)) )); assert!(paths.journal_file.is_file()); @@ -1072,7 +1092,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { }; let cat_v1 = b"{\"models\":[{\"slug\":\"m-v1\"}]}\n"; apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1102,7 +1122,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { }; assert!( apply_attach_or_sync_with_fault( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1114,7 +1134,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { ); assert!( apply_attach_or_sync_with_fault( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1131,7 +1151,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { injected ); - let outcome = offline_detach(&paths, false).unwrap(); + 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(), @@ -1143,7 +1163,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { // 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( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1157,7 +1177,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { ] { assert!( apply_attach_or_sync_with_fault( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1171,7 +1191,7 @@ fn conformance_multiline_placement_and_write_ahead_journal_crash_recovery() { assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), cat_v1); assert!(paths.codex_models_cache_path().exists()); - let outcome = offline_detach(&paths, false).unwrap(); + 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(), @@ -1249,7 +1269,7 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { subagent_defaults: None, }; apply_attach_or_sync( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1260,7 +1280,7 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { 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( - &paths, + &owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1271,7 +1291,7 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { assert!(sync_err.contains("client_catalog_ownership_changed")); assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), user_cat); assert!(matches!( - offline_detach(&paths, false), + offline_detach(&detach_guard(&paths), false), Err(DetachError::Conflict(_)) )); assert_eq!(fs::read(paths.codex_catalog_path()).unwrap(), user_cat); @@ -1283,8 +1303,7 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { 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( - &paths, + apply_attach_or_sync(&owned(&paths), "lnk_0123456789abcdef", "link:t", 10100, @@ -1295,7 +1314,7 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { // Invalid cache JSON fails closed without deleting journal fs::write(paths.codex_models_cache_path(), b"{invalid").unwrap(); assert!(matches!( - offline_detach(&paths, false), + offline_detach(&detach_guard(&paths), false), Err(DetachError::Conflict(_)) )); assert!(paths.journal_file.exists()); @@ -1305,14 +1324,14 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { let saved_backup = fs::read(&paths.catalog_backup_file).unwrap(); fs::remove_file(&paths.catalog_backup_file).unwrap(); assert!(matches!( - offline_detach(&paths, false), + 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(&paths, false).unwrap(), + offline_detach(&detach_guard(&paths), false).unwrap(), DetachOutcome::ExactRestore ); let final_cache: serde_json::Value = @@ -1325,17 +1344,17 @@ fn strict_settings_validation_chunk_eof_and_catalog_sync_ownership_guards() { assert_eq!(models[1]["context_window"], 9); // 5. AttachRollbackSnapshot::capture rejects non-NotFound read failure & restore reports incomplete rollback - let snap_ok = AttachRollbackSnapshot::capture(&paths, None).unwrap(); + 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(&paths, None) + 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(&paths).unwrap_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..bcb091a5429 --- /dev/null +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -0,0 +1,169 @@ +#![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::daemon::pick_ephemeral_link_port; + +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 +} + +#[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_eq!( + 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)); + 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_eq!( + 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" + ); +} diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 75aa7c6ef77..4fb9391fa85 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -709,7 +709,7 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac Some(cred_json.as_bytes()), ); assert_ne!(rc_conflict, 0); - assert!(err_conflict.contains("full OpenCodex runtime state")); + 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 diff --git a/structure/remote-link.md b/structure/remote-link.md index b4eff4f9184..7609a561cde 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,6 +190,8 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src 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. Shared golden vectors in `tests/fixtures/link/ocx-child-conformance.json` lock TypeScript and Rust parity. 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`. +`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. 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, 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`). 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. From 1612d7a03cd73190142faf2753339cc5c2139d69 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:46:40 +0900 Subject: [PATCH 17/34] style(ocx-child): format lifecycle assertion message --- native/ocx-child/tests/relay_and_lifecycle.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 4fb9391fa85..fda22b16237 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -709,7 +709,10 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac Some(cred_json.as_bytes()), ); assert_ne!(rc_conflict, 0); - assert!(err_conflict.contains("full OpenCodex runtime state"), "{err_conflict}"); + 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 From 27712f59215e04da43b46794e4dccae199b81aca Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:49:16 +0900 Subject: [PATCH 18/34] fix(ocx-child): recheck home identity around every restoration step Immediate rollback, crash recovery and offline detach verified the home directory identity once and then wrote and removed files by path name. If a home was replaced after that check, restoration kept writing into the new directory and could delete its recovery records. Every restoration write, rename and removal now goes through CodexHomeGuard::restore_step, which re-verifies both captured home identities immediately before and after the call while the CODEX_HOME lock is held. A mismatch stops restoration, keeps every recovery record (rollback snapshot, journal, key, ownership record) and reports a recovery error. The rollback snapshot and journal are removed only by a step whose checks passed. Regression: tests/lifecycle_guards.rs pauses detach between verification and the write, replaces CODEX_HOME with a directory of sentinel files, and checks the sentinels, the original directory and the records. --- native/ocx-child/src/cli.rs | 114 +++++------ native/ocx-child/src/codex_home.rs | 36 ++++ native/ocx-child/src/journal.rs | 61 ++++-- native/ocx-child/tests/lifecycle_guards.rs | 222 ++++++++++++++++++++- structure/remote-link.md | 4 +- 5 files changed, 339 insertions(+), 98 deletions(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 53123429f5b..1807cd41b21 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -4,7 +4,9 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::codex_home::{ClaimPolicy, CodexHomeError, CodexHomeGuard, OwnershipPhase}; +use crate::codex_home::{ + ClaimPolicy, CodexHomeError, CodexHomeGuard, OwnershipPhase, RestoreStepError, +}; use crate::codex_settings::{ HomeCodexSettings, HomeCodexSettingsDocument, HomeIdentity, codex_settings_digest, parse_home_codex_settings_document, @@ -500,39 +502,27 @@ impl AttachRollbackSnapshot { 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, - ) { - crate::test_hooks::barrier("restore-before-write"); - Self::restore_path_inner(path, bytes, private, errors); - crate::test_hooks::barrier("restore-after-write"); - } - - fn restore_path_inner( - path: &std::path::Path, - bytes: Option<&[u8]>, - private: bool, - errors: &mut Vec, - ) { - match bytes { - Some(b) => { - let res = if private { - write_private_atomic(path, b) - } else { - write_regular_atomic(path, b) - }; - if let Err(e) = res { - errors.push(format!("restore {}: {e}", path.display())); - } - } - None => { - if let Err(e) = remove_optional_file(path) { - errors.push(e); - } + ) -> 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}")), } } @@ -540,51 +530,36 @@ impl AttachRollbackSnapshot { let paths = guard.paths(); self.validate_homes(paths)?; let mut errors = Vec::new(); - Self::restore_path( - &paths.codex_config_path(), - self.config.as_deref(), - false, - &mut errors, - ); - Self::restore_path( - &paths.codex_catalog_path(), - self.catalog.as_deref(), - false, - &mut errors, - ); - Self::restore_path( - &paths.codex_models_cache_path(), - self.models_cache.as_deref(), - false, - &mut errors, - ); + 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() { - Self::restore_path( - &paths.catalog_backup_file, - self.catalog_backup.as_deref(), - true, - &mut errors, - ); - Self::restore_path( - &paths.key_file, - self.key.as_ref().map(SecretBytes::as_slice), - true, - &mut errors, - ); - Self::restore_path( - &paths.state_file, - self.state_bytes.as_deref(), - true, - &mut errors, - ); + 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, - ); + )?; } if let Some(mut prev) = self.prior_state.clone() && self.prior_was_serving @@ -617,7 +592,10 @@ impl AttachRollbackSnapshot { } } if errors.is_empty() { - remove_optional_file(&paths.rollback_file)?; + // The rollback record goes only after restoration verified both homes. + guard + .restore_step(|| remove_optional_file(&paths.rollback_file)) + .map_err(RestoreStepError::into_message)?; Ok(()) } else { Err(format!("attach rollback incomplete: {}", errors.join("; "))) @@ -1573,8 +1551,8 @@ fn cmd_detach(args: &[String]) -> u8 { &paths.catalog_backup_file, &paths.journal_file, ] { - if let Err(e) = remove_optional_file(path) { - eprintln!("detach cleanup failed: {e}"); + if let Err(e) = codex_guard.restore_step(|| remove_optional_file(path)) { + eprintln!("detach cleanup failed: {}", e.into_message()); return 1; } } diff --git a/native/ocx-child/src/codex_home.rs b/native/ocx-child/src/codex_home.rs index 85e8fbd068b..83c3628ab8a 100644 --- a/native/ocx-child/src/codex_home.rs +++ b/native/ocx-child/src/codex_home.rs @@ -73,6 +73,24 @@ impl CodexHomeError { } } +/// 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. @@ -207,6 +225,24 @@ impl CodexHomeGuard { 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| { diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index edb76be5e3a..0004c9a988a 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -6,7 +6,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use serde::{Deserialize, Serialize}; -use crate::codex_home::CodexHomeGuard; +use crate::codex_home::{CodexHomeGuard, RestoreStepError}; use crate::codex_settings::HomeCodexSettings; use crate::platform::{ DirectoryIdentity, directory_identity, ensure_private_dir, reject_symlink, sha256_hex, @@ -758,9 +758,13 @@ fn offline_detach_inner( DetachError::Conflict(format!("bad snapshot base64: {e}")) }) })?; - write_regular_atomic(&config_path, &orig).map_err(DetachError::Io)?; + guard + .restore_step(|| write_regular_atomic(&config_path, &orig)) + .map_err(step_err)?; } else { - remove_optional_file(&config_path).map_err(DetachError::Io)?; + guard + .restore_step(|| remove_optional_file(&config_path)) + .map_err(step_err)?; } DetachOutcome::ExactRestore } else { @@ -770,11 +774,14 @@ fn offline_detach_inner( surgical_detach_config(text, &journal.ledger).map_err(DetachError::Conflict)?; match stripped { Some(updated) => { - write_regular_atomic(&config_path, updated.as_bytes()) - .map_err(DetachError::Io)?; + guard + .restore_step(|| write_regular_atomic(&config_path, updated.as_bytes())) + .map_err(step_err)?; } None => { - remove_optional_file(&config_path).map_err(DetachError::Io)?; + guard + .restore_step(|| remove_optional_file(&config_path)) + .map_err(step_err)?; } } DetachOutcome::SurgicalRestore @@ -785,26 +792,28 @@ fn offline_detach_inner( if !keep_catalog { if journal.pre_attach_catalog_existed { if let Some(prior_bytes) = &prior_catalog { - write_regular_atomic(catalog_path, prior_bytes).map_err(DetachError::Io)?; + guard + .restore_step(|| write_regular_atomic(catalog_path, prior_bytes)) + .map_err(step_err)?; } } else { - remove_optional_file(catalog_path).map_err(DetachError::Io)?; + guard + .restore_step(|| remove_optional_file(catalog_path)) + .map_err(step_err)?; } } match cache_plan { CacheDetachPlan::Noop => {} - CacheDetachPlan::Remove => match fs::remove_file(&cache_path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return Err(DetachError::Io(format!( - "failed removing models_cache.json: {e}" - ))); - } - }, + CacheDetachPlan::Remove => { + guard + .restore_step(|| remove_optional_file(&cache_path)) + .map_err(step_err)?; + } CacheDetachPlan::Write(reconciled) => { - write_regular_atomic(&cache_path, &reconciled).map_err(DetachError::Io)?; + guard + .restore_step(|| write_regular_atomic(&cache_path, &reconciled)) + .map_err(step_err)?; } } @@ -828,15 +837,25 @@ fn offline_detach_inner( } } journal.previous_models_cache_sha256 = None; - write_journal(&paths.journal_file, &journal).map_err(DetachError::Io)?; + guard + .restore_step(|| write_journal(&paths.journal_file, &journal)) + .map_err(step_err)?; if !defer_cleanup { - remove_optional_file(&paths.catalog_backup_file).map_err(DetachError::Io)?; - remove_optional_file(&paths.journal_file).map_err(DetachError::Io)?; + 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()) +} + /// Preserves newly observed native models (`opencodex_account_observed_native`) and prior cache /// entries while stripping Child-injected Home catalog slugs when `models_cache.json` changed /// externally while attached. diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index bcb091a5429..887e2a548af 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -12,7 +12,12 @@ 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"; @@ -69,6 +74,24 @@ fn snapshot_dir(dir: &Path) -> BTreeMap> { 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"); @@ -117,10 +140,10 @@ fn two_child_homes_sharing_one_codex_home_are_serialized_and_exclusive() { !b_entered, "B entered the protected section while A held the shared CODEX_HOME" ); - assert_eq!( - snapshot_dir(&codex), - before, - "B changed shared files while A held them" + assert_same_files( + &snapshot_dir(&codex), + &before, + "B changed shared files while A held them", ); let out_a = a.wait_with_output().unwrap(); @@ -138,10 +161,10 @@ fn two_child_homes_sharing_one_codex_home_are_serialized_and_exclusive() { text(&out_b) ); assert!(!barrier_b.reached("attach-protected")); - assert_eq!( - snapshot_dir(&codex), - after_a, - "refused attach B changed shared files" + 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()); @@ -167,3 +190,186 @@ fn two_child_homes_sharing_one_codex_home_are_serialized_and_exclusive() { 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"); +} diff --git a/structure/remote-link.md b/structure/remote-link.md index 7609a561cde..7d3ba843a87 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -192,6 +192,8 @@ When detached, `ocx-child config show --json` reports `{"port": , "r `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. 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, 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`). 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`. +`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, 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: a process running as the same user can still replace a home directory in that instant. Doing so gives that process nothing it lacks already, because it can write those same files directly with the same permissions; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against the user's own processes. 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). 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. From 62aab7ae8daca1929ab5a3648769b086a5cdf89c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:54:21 +0900 Subject: [PATCH 19/34] fix(ocx-child): commit a daemon generation before it serves spawn_detached_serve started the relay, the parent waited for /healthz and then recorded the PID; attach removed its rollback record later. If the parent died after handing over the secret, the child kept serving, including after recovery restored files or after another generation was committed. Each start now records a random daemonGeneration in state before the spawn and passes it with the secret on stdin, which stays open. Inside the start gate the child requires state.daemonGeneration to match, and until it reads "commit " it answers only /healthz and posts no parity reports. EOF or any other line before the commit makes it exit without serving. The parent commits only after the state naming the generation and PID is durable and, for attach, after the rollback record is removed. Restart, sync rollover and rollback's prior-relay restart use the same rule. An operator-run serve --foreground is unchanged and is refused while an attach rollback is pending. Regression: tests/lifecycle_guards.rs kills the real attach parent at a barrier before the commit, and starts a daemon with stale and superseded generations. --- native/ocx-child/src/cli.rs | 148 +++++++--- native/ocx-child/src/daemon.rs | 136 +++++++--- native/ocx-child/src/platform.rs | 33 ++- native/ocx-child/src/relay.rs | 71 ++++- native/ocx-child/src/state.rs | 11 + native/ocx-child/tests/lifecycle_guards.rs | 252 ++++++++++++++++++ native/ocx-child/tests/relay_and_lifecycle.rs | 1 + structure/remote-link.md | 4 +- 8 files changed, 573 insertions(+), 83 deletions(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 1807cd41b21..8f2e91def8f 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -1,5 +1,5 @@ use std::fs; -use std::io::{self, Read}; +use std::io::{self, BufRead, Read}; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -34,8 +34,8 @@ 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_link_token_format, read_child_state, read_verified_link_key, write_child_state, - write_link_key, + 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}; @@ -579,7 +579,9 @@ impl AttachRollbackSnapshot { errors.push("prior relay restart skipped: restored state.json and link-key are inconsistent".to_owned()); } else { let had_file_errors = !errors.is_empty(); - match start_or_rollover_daemon(paths, &mut prev, None) { + match start_or_rollover_daemon(paths, &mut prev, None) + .and_then(|pending| pending.commit()) + { Ok(_) => { if let Err(e) = write_child_state(&paths.state_file, &prev) { errors.push(format!("restore daemon state: {e}")); @@ -903,6 +905,7 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( shutdown_secret, parity_sequence: 0, pending_operation: None, + daemon_generation: None, applied_settings: Some(AppliedSettingsRecord { link_id: link_id.clone(), fetched_at: now_iso.clone(), @@ -921,20 +924,9 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( }); } - if start_daemon { + let pending = if start_daemon { match start_or_rollover_daemon(&paths, &mut state, Some(now_ms)) { - 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); - } + Ok(pending) => Some(pending), Err(e) => { let restored = rollback.restore_and_report(guard); eprintln!("{e}"); @@ -944,9 +936,13 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( }); } } - } + } 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 { @@ -955,19 +951,77 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( }); } 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} as key {api_key_id}."); 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() { @@ -976,6 +1030,10 @@ fn cmd_serve(args: &[String]) -> u8 { 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; @@ -985,6 +1043,22 @@ fn cmd_serve(args: &[String]) -> u8 { } 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 => { @@ -1003,24 +1077,22 @@ fn cmd_serve(args: &[String]) -> u8 { return 1; } match rollback_pending(&paths) { - Ok(true) => { - let mut supplied = Vec::new(); - let read = io::stdin().take(129).read_to_end(&mut supplied); - let authorized = read.is_ok() - && supplied.len() <= 128 - && constant_time_eq(&supplied, state.shutdown_secret.as_bytes()); - volatile_zero_bytes(&mut supplied); - if !authorized { - eprintln!("serve refused: incomplete attach requires lifecycle authorization"); - return 1; - } + Ok(true) if start.is_none() => { + eprintln!("serve refused: incomplete attach requires lifecycle authorization"); + return 1; } - Ok(false) => {} + 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; @@ -1030,7 +1102,13 @@ fn cmd_serve(args: &[String]) -> u8 { eprintln!("serve refused: listen port does not match child state"); return 1; } - return match serve_foreground(paths, port) { + 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}"); @@ -1078,7 +1156,9 @@ fn cmd_restart() -> u8 { 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) { + 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}"); @@ -1413,7 +1493,9 @@ fn cmd_sync(args: &[String]) -> u8 { _ => true, }; let runtime_started_ms = if needs_rollover { - match start_or_rollover_daemon(&paths, &mut state, Some(applied_at_ms)) { + 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}"); diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index 8f745f9fd72..1ee192c02b6 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -1,5 +1,6 @@ use std::io::{BufReader, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::process::{Child, ChildStdin}; use std::thread; use std::time::{Duration, Instant}; @@ -21,6 +22,8 @@ pub struct LocalHealthz { pub pid: u32, pub port: u16, pub started_at: Option, + #[serde(default)] + pub generation_committed: Option, } pub fn pick_ephemeral_link_port() -> Result { @@ -202,11 +205,83 @@ pub fn stop_owned_daemon(state: &ChildStateV1) -> Result<(), String> { )) } +/// 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(); + } + } +} + +/// 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<(u32, u64), String> { +) -> Result { stop_owned_daemon(state)?; if let Some(floor_ms) = min_started_at_ms { let now = now_epoch_ms(); @@ -217,25 +292,40 @@ pub fn start_or_rollover_daemon( 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 pending_attach = match std::fs::symlink_metadata(&paths.rollback_file) { - Ok(_) => true, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(e) => return Err(format!("cannot inspect rollback snapshot: {e}")), - }; - let mut spawned = spawn_detached_serve( + 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 = format!( + "ocx-child-start/1 {generation} {} +", + state.shutdown_secret + ); + let spawned = spawn_detached_serve( &exe, &paths.opencodex_home, &paths.codex_home, state.listen_port, - pending_attach.then_some(state.shutdown_secret.as_str()), + &start_line, )?; 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(status) = spawned - .try_wait() - .map_err(|e| format!("failed to observe spawned child: {e}"))? + 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}")); } @@ -248,29 +338,13 @@ pub fn start_or_rollover_daemon( 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); - if let Err(e) = write_child_state(&paths.state_file, state) { - let cleanup = spawned.kill().and_then(|()| spawned.wait().map(|_| ())); - return Err(format!( - "failed to record spawned child: {e}; cleanup: {cleanup:?}" - )); - } - crate::test_hooks::barrier("daemon-before-commit"); - return Ok((spawned_pid, 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)); } - if spawned - .try_wait() - .map_err(|e| format!("failed to observe spawned child: {e}"))? - .is_none() - { - spawned - .kill() - .map_err(|e| format!("failed to terminate spawned child: {e}"))?; - spawned - .wait() - .map_err(|e| format!("failed to reap spawned child: {e}"))?; - } Err(format!( "ocx-child serve failed to answer /healthz on 127.0.0.1:{}", state.listen_port diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 273789a99ad..0dee04e3c41 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -1025,27 +1025,26 @@ impl Drop for FileLock { } } +/// 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, - lifecycle_secret: Option<&str>, + start_line: &str, ) -> 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) .env_remove(crate::test_hooks::BARRIER_DIR_ENV) .env_remove(crate::test_hooks::BARRIERS_ENV) - .stdin(if lifecycle_secret.is_some() { - Stdio::piped() - } else { - Stdio::null() - }) + .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()); @@ -1072,17 +1071,17 @@ pub fn spawn_detached_serve( let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn detached ocx-child serve: {e}"))?; - if let Some(secret) = lifecycle_secret { - let write_result = match child.stdin.take() { - Some(mut stdin) => stdin.write_all(secret.as_bytes()), - 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:?}" - )); - } + let write_result = match child.stdin.as_mut() { + Some(stdin) => stdin + .write_all(start_line.as_bytes()) + .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) } diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index cb5aaf2ef33..5d27db9feb4 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -1,4 +1,4 @@ -use std::io::{BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; @@ -264,6 +264,9 @@ struct SharedServerState { 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, } impl SharedServerState { @@ -299,7 +302,48 @@ impl SharedServerState { } } +/// 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))) @@ -312,6 +356,15 @@ pub fn serve_foreground(paths: ChildPaths, listen_port: u16) -> Result<(), Strin } _ => 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() { @@ -332,6 +385,7 @@ pub fn serve_foreground(paths: ChildPaths, listen_port: u16) -> Result<(), Strin stop_flag: AtomicBool::new(false), stop_cv: (Mutex::new(false), Condvar::new()), last_generation: Mutex::new(None), + committed, }); let parity_shared = Arc::clone(&shared); @@ -389,6 +443,10 @@ fn run_parity_loop(shared: Arc) { 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; @@ -462,6 +520,16 @@ fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> st req.header("origin"), req.header("upgrade"), ); + let committed = shared.committed.load(Ordering::SeqCst); + 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 => { @@ -536,6 +604,7 @@ fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> st "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) diff --git a/native/ocx-child/src/state.rs b/native/ocx-child/src/state.rs index 60b1b4a384a..94dc4817cb1 100644 --- a/native/ocx-child/src/state.rs +++ b/native/ocx-child/src/state.rs @@ -94,6 +94,17 @@ pub struct ChildStateV1 { 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)] diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index 887e2a548af..b3a26714646 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -373,3 +373,255 @@ fn offline_detach_rechecks_codex_home_identity_before_each_restore_write() { 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}"); +} + +/// 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()); +} diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index fda22b16237..b97562312b4 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -343,6 +343,7 @@ fn untrusted_health_pid_never_authorizes_signalling_an_unrelated_process() { parity_sequence: 0, pending_operation: None, applied_settings: None, + daemon_generation: None, }; let result = stop_owned_daemon(&state); diff --git a/structure/remote-link.md b/structure/remote-link.md index 7d3ba843a87..7856497d6d1 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -194,6 +194,8 @@ When detached, `ocx-child config show --json` reports `{"port": , "r `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, 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: a process running as the same user can still replace a home directory in that instant. Doing so gives that process nothing it lacks already, because it can write those same files directly with the same permissions; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against the user's own processes. 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). 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`. +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: a process running as the same user can still replace a home directory in that instant. Doing so gives that process nothing it lacks already, because it can write those same files directly with the same permissions; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against the user's own processes. 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; 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). 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. From 02f8376c8c2f2af2909f749d0b75e8b19288ef60 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 20:57:45 +0900 Subject: [PATCH 20/34] fix(ocx-child): let shutdown end in-flight parity work and a busy pool The relay joined its parity thread on shutdown, but an in-flight parity request had only per-read socket timeouts (5 s/15 s), so a stalled or trickling Home kept it alive well past stop_owned_daemon's 3-second wait and detach reported that the daemon did not stop. Separately, the accept loop blocked on the worker semaphore when all 32 workers were busy, so the shutdown connection was never accepted. One-shot Home calls now carry a whole-request deadline (60 s for lifecycle fetches, 10 s for parity) enforced per read. The parity thread registers its socket in a CancelSlot; shutdown cancels the slot under the same mutex and shuts the socket down, and serve exits only after the thread ends. When the pool is full, a connection gets a short-lived control thread (capped at 256) that answers only /healthz and the authenticated shutdown with 1-second timeouts. Regression: tests/lifecycle_guards.rs runs detach against a Home that stalls or trickles the parity response and against a relay whose workers are all blocked, and checks the time budget and that the process is gone. --- native/ocx-child/src/relay.rs | 309 +++++++++++++++++++-- native/ocx-child/tests/common/mod.rs | 5 +- native/ocx-child/tests/lifecycle_guards.rs | 113 ++++++++ structure/remote-link.md | 4 +- 4 files changed, 405 insertions(+), 26 deletions(-) diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index 5d27db9feb4..d8c115905f6 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -1,6 +1,6 @@ use std::io::{BufRead, BufReader, Read, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::sync::atomic::{AtomicBool, Ordering}; +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}; @@ -27,8 +27,16 @@ 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); +/// Read/write timeout for a control connection handled while every worker is busy. +const CONTROL_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)] @@ -47,8 +55,120 @@ pub struct RelayCredentials { 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 reader: BufReader, pub writer: TcpStream, pub nonce: String, pub connection_generation: Option, @@ -72,17 +192,37 @@ 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 stream = TcpStream::connect_timeout(&addr, AUTH_TIMEOUT) + let stream = TcpStream::connect_timeout(&addr, limits.remaining(AUTH_TIMEOUT)?) .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(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(stream); + 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) @@ -177,13 +317,65 @@ pub fn fetch_home_authenticated( body: Option<(&str, &[u8])>, max_bytes: u64, ) -> Result<(HttpResponseHead, Vec, Option), UpstreamError> { - let mut upstream = open_authenticated_upstream(tunnel_port, creds, false)?; - let _ = upstream - .writer - .set_read_timeout(Some(Duration::from_secs(15))); + 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(Duration::from_secs(15))); + .set_write_timeout(Some(limits.remaining(FETCH_READ_TIMEOUT)?)); let token_str = std::str::from_utf8(creds.raw_token.as_slice()) .map_err(|_| UpstreamError::RelayAuthFailed("invalid utf8 token".to_owned()))?; let mut head = format!( @@ -240,9 +432,22 @@ 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 (head, _, _) = fetch_home_authenticated( + let limits = RequestLimits { + deadline: Some(Instant::now() + PARITY_DEADLINE), + cancel, + }; + let (head, _, _) = fetch_home_with_limits( tunnel_port, creds, "POST", @@ -250,6 +455,7 @@ pub fn post_child_parity_report( RelayAuthKind::Bearer, Some(("application/json", &json_bytes)), 1024, + limits, ) .map_err(|e| format!("{e:?}"))?; Ok(head.status) @@ -267,6 +473,8 @@ struct SharedServerState { /// 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 { @@ -290,6 +498,7 @@ impl SharedServerState { 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(); @@ -386,11 +595,13 @@ fn serve_foreground_inner( 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) { @@ -403,17 +614,40 @@ fn serve_foreground_inner( break; } let sem_clone = Arc::clone(&sem); - { - let (lock, cv) = &*sem_clone; + let admitted = { + let (lock, _) = &*sem_clone; let mut active = lock.lock().unwrap_or_else(|e| e.into_inner()); - while *active >= MAX_CONCURRENT_WORKERS { - active = cv.wait(active).unwrap_or_else(|e| e.into_inner()); + if *active < MAX_CONCURRENT_WORKERS { + *active += 1; + true + } else { + false } - *active += 1; + }; + if !admitted { + // Every worker is busy. Health checks and the authenticated shutdown still get a + // short-lived thread with tight timeouts, so a full pool (or a slow client in + // this tier) cannot keep the shutdown connection from being handled. Past the + // cap the 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); + 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); @@ -467,7 +701,7 @@ fn run_parity_loop(shared: Arc) { applied_codex_settings_digest: digest, sequence: local_seq, }; - match post_child_parity_report(st.tunnel_port, &creds, &report) { + match post_parity_with(st.tunnel_port, &creds, &report, Some(&shared.parity_cancel)) { Ok(204) => { failures = 0; wait_ms = 60_000; @@ -482,10 +716,27 @@ fn run_parity_loop(shared: Arc) { } } -fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> std::io::Result<()> { +#[derive(Clone, Copy, PartialEq, Eq)] +enum ConnectionMode { + Full, + /// Accepted while every worker is busy, with short timeouts: `/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 _ = stream.set_read_timeout(Some(Duration::from_secs(30))); - let _ = stream.set_write_timeout(Some(HEADER_AND_SSE_TIMEOUT)); + if mode == ConnectionMode::ControlOnly { + let _ = stream.set_read_timeout(Some(CONTROL_TIMEOUT)); + let _ = stream.set_write_timeout(Some(CONTROL_TIMEOUT)); + } else { + let _ = stream.set_read_timeout(Some(Duration::from_secs(30))); + let _ = stream.set_write_timeout(Some(HEADER_AND_SSE_TIMEOUT)); + } let mut writer = stream.try_clone()?; let mut reader = BufReader::new(stream); @@ -521,6 +772,20 @@ fn handle_client_connection(stream: TcpStream, shared: &SharedServerState) -> st 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, diff --git a/native/ocx-child/tests/common/mod.rs b/native/ocx-child/tests/common/mod.rs index 46435d02056..268fc63ab4a 100644 --- a/native/ocx-child/tests/common/mod.rs +++ b/native/ocx-child/tests/common/mod.rs @@ -32,8 +32,7 @@ pub struct MockHomeState { pub observed_data_headers: Mutex>>, pub stop: AtomicBool, /// 0 answers parity reports, 1 holds the socket without answering, 2 trickles an - /// unfinished response forever. Applies only to reports with sequence >= 2, so the - /// lifecycle command's own report (sequence 1) still completes. + /// unfinished response until the peer goes away. pub parity_mode: AtomicU8, pub parity_stalled: AtomicUsize, } @@ -273,7 +272,7 @@ fn handle_mock_home_socket( 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 && parsed.sequence >= 2 { + if mode != 0 { st.parity_stalled.fetch_add(1, Ordering::SeqCst); hold_parity_socket(&mut writer, mode, st); return; diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index b3a26714646..d1d1c9ac197 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -625,3 +625,116 @@ fn lifecycle_start_requires_the_recorded_generation_and_a_commit() { 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); +} diff --git a/structure/remote-link.md b/structure/remote-link.md index 7856497d6d1..6ac5d04a266 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -196,6 +196,8 @@ When detached, `ocx-child config show --json` reports `{"port": , "r 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: a process running as the same user can still replace a home directory in that instant. Doing so gives that process nothing it lacks already, because it can write those same files directly with the same permissions; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against the user's own processes. 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; 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). 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`. +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; 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. `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 timeouts, so a saturated pool cannot keep the shutdown connection from being accepted and handled. 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). 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. From 86f199a018b375f088dbda1b502c1f7308cf5659 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:00:18 +0900 Subject: [PATCH 21/34] test(ocx-child): cover removal of an unchanged injected web_search on detach --- native/ocx-child/tests/conformance.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 2fda9ea4d0f..be1f210d1e1 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -43,6 +43,13 @@ fn detach_preserves_user_edited_web_search_with_managed_marker() { 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(); From e459d8641d114686f97ec3a8bef66d98b9f95ace Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:01:09 +0900 Subject: [PATCH 22/34] docs(link): state the single-owner CODEX_HOME rule for ocx-child --- docs-site/src/content/docs/reference/cli/lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 5779dc4e2f1..3b3325d601f 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1363,7 +1363,7 @@ 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. +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 From 00c11d64cfb2a8ac90376765e7348ac838672a04 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:18:40 +0900 Subject: [PATCH 23/34] fix(ocx-child): let Home catalog slugs win in models_cache sync after outside edits A sync after any outside edit to models_cache.json rebuilt the cache by swapping injected Home entries back to their pre-attach versions, then rejected each restored entry because it differed from the Home catalog entry with the same slug. Every later sync failed with client_models_cache_conflict until detach and re-attach. A Codex rewrite that re-serialized a Home entry hit the same path. Injected catalog slugs are now Child-owned by slug: sync writes the Home version and keeps entries with other slugs, and the detach snapshot keeps the pre-attach version or drops the slug. --- native/ocx-child/src/journal.rs | 46 +++------- native/ocx-child/tests/conformance.rs | 117 ++++++++++++++++++++++++++ structure/remote-link.md | 2 +- 3 files changed, 131 insertions(+), 34 deletions(-) diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index 0004c9a988a..452e61c059d 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -501,29 +501,14 @@ pub fn apply_attach_or_sync_with_fault( 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 local: serde_json::Value = serde_json::from_slice(&reconciled) - .map_err(|e| format!("invalid reconciled models_cache.json: {e}"))?; - let local_models = local - .get("models") - .and_then(serde_json::Value::as_array) - .ok_or("reconciled models_cache.json missing models array")?; - for entry in local_models { - if let Some(slug) = entry.get("slug").and_then(serde_json::Value::as_str) - && catalog_models.iter().any(|model| { - model.get("slug").and_then(serde_json::Value::as_str) == Some(slug) - && model != entry - }) - { - return Err(format!( - "client_models_cache_conflict: local model {slug} conflicts with Home catalog" - )); - } - } let (rendered, hash) = render_codex_models_cache(Some(&reconciled), &catalog_models, true)?; pre_cache_existed = true; @@ -856,9 +841,12 @@ fn step_err(e: RestoreStepError) -> DetachError { DetachError::Io(e.into_message()) } -/// Preserves newly observed native models (`opencodex_account_observed_native`) and prior cache -/// entries while stripping Child-injected Home catalog slugs when `models_cache.json` changed -/// externally while attached. +/// 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]>, @@ -887,13 +875,9 @@ fn reconcile_modified_models_cache( .ok_or_else(|| { "client_models_cache_reconcile_failed: catalog backup missing models array".to_owned() })?; - let child_by_slug: HashMap = backup_models + let child_slugs: HashSet<&str> = backup_models .iter() - .filter_map(|m| { - m.get("slug") - .and_then(serde_json::Value::as_str) - .map(|s| (s.to_owned(), m.clone())) - }) + .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) => { @@ -925,12 +909,8 @@ fn reconcile_modified_models_cache( .get("slug") .and_then(serde_json::Value::as_str) .unwrap_or(""); - if let Some(injected) = child_by_slug.get(slug) { - if &entry == injected { - prior_by_slug.get(slug).cloned() - } else { - Some(entry) - } + if child_slugs.contains(slug) { + prior_by_slug.get(slug).cloned() } else { Some(entry) } diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index be1f210d1e1..44e5a53311b 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -176,6 +176,123 @@ fn sync_preserves_new_external_cache_models_through_detach() { ); } +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_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 { diff --git a/structure/remote-link.md b/structure/remote-link.md index 6ac5d04a266..1a51506d6cc 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src 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. Shared golden vectors in `tests/fixtures/link/ocx-child-conformance.json` lock TypeScript and Rust parity. +`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. 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, 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`). From 44220133385ebcbf16261721a56d8dbc4b0de0ee Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:21:45 +0900 Subject: [PATCH 24/34] fix(ocx-child): commit a rollback-restarted relay after removing the record Attach rollback restarted the prior relay and committed it before it removed the rollback record, so the relay could serve while recovery might still restore files underneath it. The restart now commits only after the record is removed; an uncommitted relay is dropped and exits if the removal fails. The structure doc names this ordering. --- native/ocx-child/src/cli.rs | 25 ++++---- native/ocx-child/tests/lifecycle_guards.rs | 75 ++++++++++++++++++++++ structure/remote-link.md | 2 +- 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 8f2e91def8f..4f812ba6520 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -561,6 +561,7 @@ impl AttachRollbackSnapshot { &mut errors, )?; } + let mut restarted = None; if let Some(mut prev) = self.prior_state.clone() && self.prior_was_serving && errors.is_empty() @@ -578,26 +579,25 @@ impl AttachRollbackSnapshot { if !state_key_consistent { errors.push("prior relay restart skipped: restored state.json and link-key are inconsistent".to_owned()); } else { - let had_file_errors = !errors.is_empty(); - match start_or_rollover_daemon(paths, &mut prev, None) - .and_then(|pending| pending.commit()) - { - Ok(_) => { - if let Err(e) = write_child_state(&paths.state_file, &prev) { - errors.push(format!("restore daemon state: {e}")); - } else if had_file_errors { - errors.push("prior relay restart attempted and succeeded".to_owned()); - } - } + // 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. + // 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("; "))) @@ -925,6 +925,7 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( } 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) => { diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index d1d1c9ac197..bc86ad20c52 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -459,6 +459,81 @@ fn daemon_exits_without_serving_when_its_parent_dies_before_commit() { 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 { diff --git a/structure/remote-link.md b/structure/remote-link.md index 1a51506d6cc..91eb0afb2b0 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -196,7 +196,7 @@ When detached, `ocx-child config show --json` reports `{"port": , "r 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: a process running as the same user can still replace a home directory in that instant. Doing so gives that process nothing it lacks already, because it can write those same files directly with the same permissions; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against the user's own processes. 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; 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). +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. `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 timeouts, so a saturated pool cannot keep the shutdown connection from being accepted and handled. 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). 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`. From 8808f984bb05e376889cde9a0c079b9e1a6c9ca6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:23:44 +0900 Subject: [PATCH 25/34] fix(ocx-child): bound each control-mode request head with a deadline A control connection, accepted while every worker is busy, had only a 1-second per-read timeout, so a sender writing one byte per second held its slot indefinitely and 256 such senders kept the authenticated shutdown from being handled. Control mode now reads through the existing DeadlineStream with a 2-second deadline that covers the whole exchange, including the shutdown proof frame. The code comment and structure text now state the bound and the connection cap precisely. --- native/ocx-child/src/relay.rs | 28 +++++--- native/ocx-child/tests/lifecycle_guards.rs | 79 ++++++++++++++++++++++ structure/remote-link.md | 2 +- 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index d8c115905f6..727c5f41154 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -32,8 +32,11 @@ 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); -/// Read/write timeout for a control connection handled while every worker is busy. +/// 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); const HEADER_AND_SSE_TIMEOUT: Duration = Duration::from_secs(300); const MAX_CONCURRENT_WORKERS: usize = 32; const MAX_CONTROL_CONNECTIONS: usize = 256; @@ -626,9 +629,10 @@ fn serve_foreground_inner( }; if !admitted { // Every worker is busy. Health checks and the authenticated shutdown still get a - // short-lived thread with tight timeouts, so a full pool (or a slow client in - // this tier) cannot keep the shutdown connection from being handled. Past the - // cap the connection is closed unanswered. + // 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; @@ -719,8 +723,8 @@ fn run_parity_loop(shared: Arc) { #[derive(Clone, Copy, PartialEq, Eq)] enum ConnectionMode { Full, - /// Accepted while every worker is busy, with short timeouts: `/healthz` and the - /// authenticated shutdown only. + /// Accepted while every worker is busy, with short timeouts and a request-head deadline: + /// `/healthz` and the authenticated shutdown only. ControlOnly, } @@ -730,15 +734,18 @@ fn handle_client_connection( mode: ConnectionMode, ) -> std::io::Result<()> { let _ = stream.set_nodelay(true); - if mode == ConnectionMode::ControlOnly { + 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(stream); + let mut reader = BufReader::new(DeadlineStream::new(stream, head_deadline, CONTROL_TIMEOUT)); let req = match read_request_head(&mut reader) { Ok(r) => r, @@ -1009,7 +1016,7 @@ fn handle_local_readyz( } fn relay_request( - client_reader: &mut BufReader, + client_reader: &mut BufReader, client_writer: &mut TcpStream, req: &HttpRequestHead, shared: &SharedServerState, @@ -1042,6 +1049,7 @@ fn relay_request( 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) { diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index bc86ad20c52..aab9f40c607 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -813,3 +813,82 @@ fn detach_stops_a_relay_whose_workers_are_all_busy() { ); 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); +} diff --git a/structure/remote-link.md b/structure/remote-link.md index 91eb0afb2b0..b2a492657d0 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -198,6 +198,6 @@ Restoration (immediate attach rollback, crash recovery of an incomplete attach d 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. `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 timeouts, so a saturated pool cannot keep the shutdown connection from being accepted and handled. 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). 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`. +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. `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. From 6c917b3f20f961c3c1c84a56bb18a1d6a3287fbe Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:24:31 +0900 Subject: [PATCH 26/34] fix(ocx-child): cap the cancellable parity connect at one second The parity socket can be registered for cancellation only after it connects, and that connect could take up to the 5-second auth timeout, longer than the 3-second wait stop_owned_daemon gives a relay to exit. A request that carries a CancelSlot now connects with a 1-second bound; one-shot lifecycle fetches keep the 5-second bound. --- native/ocx-child/src/relay.rs | 10 +++++++++- structure/remote-link.md | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index 727c5f41154..9c7f1a867ca 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -37,6 +37,9 @@ 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; @@ -214,7 +217,12 @@ fn open_authenticated_upstream_with( limits: RequestLimits<'_>, ) -> Result { let addr = SocketAddr::from(([127, 0, 0, 1], tunnel_port)); - let stream = TcpStream::connect_timeout(&addr, limits.remaining(AUTH_TIMEOUT)?) + 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)?; diff --git a/structure/remote-link.md b/structure/remote-link.md index b2a492657d0..2e7670e71d4 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -198,6 +198,6 @@ Restoration (immediate attach rollback, crash recovery of an incomplete attach d 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. `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`. +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. From f9d1b5462d4bec96e8a8a1b3676aeba78c8d9c8a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:25:53 +0900 Subject: [PATCH 27/34] fix(ocx-child): keep both catalogs in the backup across the cache write sync replaced the catalog backup with the new catalog before it wrote models_cache.json. An I/O error or crash between the two left a cache of previous-catalog entries next to a backup naming only the new catalog, so the next reconcile kept those previous Home entries as local ones and detach restored them into the native cache. Before the cache write the backup now names the slugs of both catalogs; after it, the new catalog alone. The regression interrupts a sync with JournalWriteFault::AfterCatalogWrite and checks detach, directly and after a retried sync. --- native/ocx-child/src/journal.rs | 53 ++++++++++++++++++++++++++- native/ocx-child/tests/conformance.rs | 51 ++++++++++++++++++++++++++ structure/remote-link.md | 2 +- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/native/ocx-child/src/journal.rs b/native/ocx-child/src/journal.rs index 452e61c059d..28b19f00d7d 100644 --- a/native/ocx-child/src/journal.rs +++ b/native/ocx-child/src/journal.rs @@ -521,6 +521,12 @@ pub fn apply_attach_or_sync_with_fault( } 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(), @@ -561,13 +567,19 @@ pub fn apply_attach_or_sync_with_fault( return Err("injected fault: AfterJournalWrite".to_owned()); } write_regular_atomic(&catalog_path, catalog_bytes)?; - write_private_atomic(&paths.catalog_backup_file, 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()); } @@ -575,6 +587,45 @@ pub fn apply_attach_or_sync_with_fault( 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, diff --git a/native/ocx-child/tests/conformance.rs b/native/ocx-child/tests/conformance.rs index 44e5a53311b..1b6b64a8cdb 100644 --- a/native/ocx-child/tests/conformance.rs +++ b/native/ocx-child/tests/conformance.rs @@ -252,6 +252,57 @@ fn sync_after_external_cache_edit_keeps_home_entries_over_restored_pre_attach_ve ); } +#[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] { diff --git a/structure/remote-link.md b/structure/remote-link.md index 2e7670e71d4..616ea38ef84 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -190,7 +190,7 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src 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. Shared golden vectors in `tests/fixtures/link/ocx-child-conformance.json` lock TypeScript and Rust parity. +`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, 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`). From 62d9e2fb151847f5c49e7057e1d5dea1ad34816f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:26:09 +0900 Subject: [PATCH 28/34] fix(ocx-child): remove the padding inside the relay restart hint A source line wrap had put about 20 spaces between "run" and "ocx-child restart" in the message attach prints when its relay does not start. --- native/ocx-child/src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 4f812ba6520..2d440978e93 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -977,7 +977,7 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( } Err(e) => { eprintln!( - "attach completed but the client relay did not start: {e}; run `ocx-child restart`" + "attach completed but the client relay did not start: {e}; run `ocx-child restart`" ); return Err(AttachFailure { code: 1, From 0f4b36d667ac3d9abaaaff04559d08a50dc65c9e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:27:09 +0900 Subject: [PATCH 29/34] fix(ocx-child): compile the test barrier out of release builds test_hooks::barrier read two environment variables on every lifecycle path and the env names were linked into the release binary. The barrier, its env constants, and the env stripping for a spawned relay now exist only with debug_assertions, the profile cargo test builds the binary with; a release build compiles them to no-ops. --- native/ocx-child/src/platform.rs | 3 +-- native/ocx-child/src/test_hooks.rs | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 0dee04e3c41..e44ebbf9eb3 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -1042,11 +1042,10 @@ pub fn spawn_detached_serve( .arg(listen_port.to_string()) .env("OPENCODEX_HOME", opencodex_home) .env("CODEX_HOME", codex_home) - .env_remove(crate::test_hooks::BARRIER_DIR_ENV) - .env_remove(crate::test_hooks::BARRIERS_ENV) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()); + crate::test_hooks::strip_barrier_env(&mut cmd); #[cfg(unix)] { diff --git a/native/ocx-child/src/test_hooks.rs b/native/ocx-child/src/test_hooks.rs index 03d40d1d551..29e10dbfd30 100644 --- a/native/ocx-child/src/test_hooks.rs +++ b/native/ocx-child/src/test_hooks.rs @@ -1,18 +1,24 @@ //! 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. -use std::path::PathBuf; -use std::time::{Duration, Instant}; - +#[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; }; @@ -36,3 +42,17 @@ pub fn barrier(name: &str) { 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) {} From 0d0f17da0465bc31b7ae68daa3ad8ab78b1996b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:29:22 +0900 Subject: [PATCH 30/34] fix(ocx-child): open lock files without following links FileLock checked the path with reject_symlink and then opened it with create(true), so a symlink swapped in between made the open follow it and create the link target. The open now uses O_NOFOLLOW on Unix, with the value libc defines for each target, and FILE_FLAG_OPEN_REPARSE_POINT on Windows, and refuses anything that is not a plain file. On Windows an existing lock file is verified to carry the owner-only DACL instead of being accepted unchecked. A debug-only barrier between the check and the open lets the regression swap in the symlink. --- native/ocx-child/src/platform.rs | 72 ++++++++++++++++++++-- native/ocx-child/tests/lifecycle_guards.rs | 42 +++++++++++++ structure/remote-link.md | 2 +- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index e44ebbf9eb3..6a5db02e729 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -298,6 +298,43 @@ pub fn random_hex(byte_len: usize) -> Result { 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() => { @@ -564,6 +601,12 @@ mod windows_private_acl { 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::(); @@ -946,6 +989,7 @@ impl FileLock { 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(); @@ -953,13 +997,33 @@ impl FileLock { #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); + 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)] - if !existed { - windows_private_acl::harden_and_verify(path, false) - .map_err(|_| LockAcquireError::Io)?; + { + 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)] { diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index aab9f40c607..fb29a0930d4 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -892,3 +892,45 @@ fn control_connections_that_trickle_their_request_head_are_cut_off() { ); 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/structure/remote-link.md b/structure/remote-link.md index 616ea38ef84..c4cc77d96aa 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -192,7 +192,7 @@ When detached, `ocx-child config show --json` reports `{"port": , "r `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, 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`). +`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: a process running as the same user can still replace a home directory in that instant. Doing so gives that process nothing it lacks already, because it can write those same files directly with the same permissions; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against the user's own processes. 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). From 3e6dfecf7a49c6d54147ef3d7bef4978546df234 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:32:08 +0900 Subject: [PATCH 31/34] fix(ocx-child): wipe the shutdown secret and token-bearing request heads The shutdown secret was a plain String in ChildStateV1, so every state read, clone, and drop left a copy in memory and Debug printed it. It is now a SecretString that is wiped on drop, serialized as the same plain string, and redacted from Debug; load_live_creds no longer hands out a copy nobody used, and the rollback records no longer wipe it by hand. The daemon start line and the relay and lifecycle request heads that carry the link token are built once at their final size into SecretBytes, and state.json bytes are wiped after a read or write. --- native/ocx-child/src/cli.rs | 10 +-- native/ocx-child/src/daemon.rs | 25 ++++-- native/ocx-child/src/platform.rs | 49 ++++++++++- native/ocx-child/src/relay.rs | 88 ++++++++++--------- native/ocx-child/src/state.rs | 14 +-- native/ocx-child/tests/relay_and_lifecycle.rs | 38 +++++++- 6 files changed, 157 insertions(+), 67 deletions(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 2d440978e93..2370bbccadd 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -276,10 +276,6 @@ struct AttachRollbackRecord { impl Drop for AttachRollbackRecord { fn drop(&mut self) { - if let Some(state) = &mut self.prior_state { - let mut secret = std::mem::take(&mut state.shutdown_secret).into_bytes(); - volatile_zero_bytes(&mut secret); - } for bytes in [ &mut self.journal, &mut self.config, @@ -307,10 +303,6 @@ fn rollback_pending(paths: &ChildPaths) -> Result { impl Drop for AttachRollbackSnapshot { fn drop(&mut self) { - if let Some(state) = &mut self.prior_state { - let mut secret = std::mem::take(&mut state.shutdown_secret).into_bytes(); - volatile_zero_bytes(&mut secret); - } for bytes in [ &mut self.journal, &mut self.config, @@ -902,7 +894,7 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( catalog_synced_at: now_iso.clone(), daemon_pid: existing_pid, daemon_started_at_ms: None, - shutdown_secret, + shutdown_secret: shutdown_secret.into(), parity_sequence: 0, pending_operation: None, daemon_generation: None, diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index 1ee192c02b6..2f29f2b2853 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use crate::http_wire::{read_bounded_body, read_response_head, response_framing}; use crate::platform::{ - constant_time_eq, now_epoch_ms, process_is_alive, random_hex, spawn_detached_serve, + 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}; @@ -274,6 +274,15 @@ impl Drop for PendingDaemon { } } +/// 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. @@ -297,17 +306,19 @@ pub fn start_or_rollover_daemon( state.daemon_started_at_ms = None; state.daemon_generation = Some(generation.clone()); write_child_state(&paths.state_file, state)?; - let start_line = format!( - "ocx-child-start/1 {generation} {} -", - state.shutdown_secret - ); + 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, + start_line.as_slice(), )?; let spawned_pid = spawned.id(); let mut pending = PendingDaemon { diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 6a5db02e729..7e7e1d1f888 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -114,6 +114,49 @@ impl Drop for SecretBytes { } } +/// 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`. @@ -1096,7 +1139,7 @@ pub fn spawn_detached_serve( opencodex_home: &Path, codex_home: &Path, listen_port: u16, - start_line: &str, + start_line: &[u8], ) -> Result { let mut cmd = Command::new(exe); cmd.arg("serve") @@ -1135,9 +1178,7 @@ pub fn spawn_detached_serve( .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.as_bytes()) - .and_then(|()| stdin.flush()), + 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 { diff --git a/native/ocx-child/src/relay.rs b/native/ocx-child/src/relay.rs index 9c7f1a867ca..90c57113c2e 100644 --- a/native/ocx-child/src/relay.rs +++ b/native/ocx-child/src/relay.rs @@ -319,6 +319,27 @@ fn open_authenticated_upstream_with( }) } +/// 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, @@ -387,30 +408,24 @@ fn fetch_home_inner( let _ = upstream .writer .set_write_timeout(Some(limits.remaining(FETCH_READ_TIMEOUT)?)); - let token_str = std::str::from_utf8(creds.raw_token.as_slice()) + std::str::from_utf8(creds.raw_token.as_slice()) .map_err(|_| UpstreamError::RelayAuthFailed("invalid utf8 token".to_owned()))?; - let mut head = format!( + 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 ); - match auth_kind { - RelayAuthKind::ApiKeyHeader => { - head.push_str(&format!("x-opencodex-api-key: {token_str}\r\n")); - } - RelayAuthKind::Bearer => { - head.push_str(&format!("Authorization: Bearer {token_str}\r\n")); - } - } + let mut after = String::new(); if let Some((ct, payload)) = body { - head.push_str(&format!( + after.push_str(&format!( "Content-Type: {ct}\r\nContent-Length: {}\r\n", payload.len() )); } - head.push_str("\r\n"); + 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_bytes()) + .write_all(head.as_slice()) .map_err(|e| UpstreamError::HomeUnreachable(format!("request write failed: {e}")))?; if let Some((_, payload)) = body { upstream @@ -489,22 +504,19 @@ struct SharedServerState { } impl SharedServerState { - fn load_live_creds(&self) -> Result<(RelayCredentials, String), &'static str> { + 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, - }, - state.shutdown_secret, - )) + 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) { @@ -697,7 +709,7 @@ fn run_parity_loop(shared: Arc) { wait_ms = 5_000; continue; }; - let Ok((creds, _)) = shared.load_live_creds() else { + let Ok(creds) = shared.load_live_creds() else { wait_ms = 5_000; continue; }; @@ -949,7 +961,7 @@ fn handle_local_readyz( &body, ); }; - let Ok((creds, _)) = shared.load_live_creds() else { + let Ok(creds) = shared.load_live_creds() else { let body = serde_json::to_vec(&serde_json::json!({ "service": "opencodex", "version": OCX_PROTOCOL_COMPAT_VERSION, @@ -1092,7 +1104,7 @@ fn relay_request( br#"{"error":"link_credential_unavailable"}"#, ); }; - let Ok((creds, _)) = shared.load_live_creds() else { + let Ok(creds) = shared.load_live_creds() else { return write_json_response( client_writer, 503, @@ -1144,7 +1156,7 @@ fn relay_request( .writer .set_write_timeout(Some(HEADER_AND_SSE_TIMEOUT)); - let Ok(token_str) = std::str::from_utf8(creds.raw_token.as_slice()) else { + if std::str::from_utf8(creds.raw_token.as_slice()).is_err() { return write_json_response( client_writer, 503, @@ -1152,32 +1164,26 @@ fn relay_request( &[], br#"{"error":"link_credential_unavailable"}"#, ); - }; + } - let mut out_head = format!( + 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 ); - match auth_kind { - RelayAuthKind::ApiKeyHeader => { - out_head.push_str(&format!("x-opencodex-api-key: {token_str}\r\n")); - } - RelayAuthKind::Bearer => { - out_head.push_str(&format!("Authorization: Bearer {token_str}\r\n")); - } - } + let mut after = String::new(); for (k, v) in &validated.sanitized { if k.eq_ignore_ascii_case("accept-encoding") { continue; } - out_head.push_str(&format!("{k}: {v}\r\n")); + after.push_str(&format!("{k}: {v}\r\n")); } if req.method != "GET" && req.method != "HEAD" { - out_head.push_str(&format!("Content-Length: {}\r\n", req_body.len())); + after.push_str(&format!("Content-Length: {}\r\n", req_body.len())); } - out_head.push_str("\r\n"); + 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_bytes()).is_err() + 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() { diff --git a/native/ocx-child/src/state.rs b/native/ocx-child/src/state.rs index 94dc4817cb1..90d7e49989d 100644 --- a/native/ocx-child/src/state.rs +++ b/native/ocx-child/src/state.rs @@ -4,7 +4,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::codex_settings::HomeCodexSettingsDocument; -use crate::platform::{SecretBytes, reject_symlink, write_private_atomic}; +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"; @@ -90,7 +92,7 @@ pub struct ChildStateV1 { pub catalog_synced_at: String, pub daemon_pid: Option, pub daemon_started_at_ms: Option, - pub shutdown_secret: String, + pub shutdown_secret: SecretString, pub parity_sequence: u64, pub pending_operation: Option, pub applied_settings: Option, @@ -119,11 +121,11 @@ pub fn read_child_state(path: &Path) -> StateReadOutcome { return StateReadOutcome::Invalid(e); } let bytes = match fs::read(path) { - Ok(b) => b, + 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) { + match serde_json::from_slice::(bytes.as_slice()) { Ok(st) if st.schema != CHILD_STATE_SCHEMA => { StateReadOutcome::Invalid(format!("unsupported schema: {}", st.schema)) } @@ -145,7 +147,9 @@ 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'); - write_private_atomic(path, &bytes) + let result = write_private_atomic(path, &bytes); + volatile_zero_bytes(&mut bytes); + result } pub fn is_valid_link_token_format(raw_token: &str) -> bool { diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index b97562312b4..8e85f13b13e 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -339,7 +339,7 @@ fn untrusted_health_pid_never_authorizes_signalling_an_unrelated_process() { catalog_synced_at: String::new(), daemon_pid: Some(unrelated.id()), daemon_started_at_ms: None, - shutdown_secret: "a".repeat(48), + shutdown_secret: "a".repeat(48).into(), parity_sequence: 0, pending_operation: None, applied_settings: None, @@ -1082,3 +1082,39 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac 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); +} From 6ceb5ed1212db117739488d89095fc93f232ced1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:33:13 +0900 Subject: [PATCH 32/34] docs(link): state Child sync serialization and the restore window precisely The lifecycle reference said a second ocx sync fails fast on any Child. That holds for a full ocx Child; on ocx-child only the --expect-link form the Home's fan-out runs exits 5 with pending_operation, and a plain ocx-child sync waits for the first apply. The structure note now names who can act in the restore window, and the ocx-child status ledger no longer carries stale test counts or a stale wp5 summary. --- .../261107_ocx_child_ephemeral_link/001_audit_and_status.md | 4 ++-- docs-site/src/content/docs/reference/cli/lifecycle.md | 6 ++++-- structure/remote-link.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) 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 index 13356e42e4e..8e60f63a076 100644 --- 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 @@ -21,7 +21,7 @@ Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents |---|---|---| | `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` (878 KiB / 899,216 B stripped release binary; `cargo test --locked` 6 pass, 0 fail; `clippy -D warnings` & `fmt --check` 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 | Fixed all 3 PR #8237 Codex review findings (write-ahead recovery journal, multiline TOML structural scanner, and overwritten user `[features]` surgical restoration) with shared golden & Rust crash-recovery tests | +| `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/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 3b3325d601f..dcc9b2da8f6 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1390,8 +1390,10 @@ 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, 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: a second -`ocx sync` while one is applying fails fast and changes nothing. +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/structure/remote-link.md b/structure/remote-link.md index c4cc77d96aa..d9043c1aa0c 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -194,7 +194,7 @@ When detached, `ocx-child config show --json` reports `{"port": , "r `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: a process running as the same user can still replace a home directory in that instant. Doing so gives that process nothing it lacks already, because it can write those same files directly with the same permissions; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against the user's own processes. 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). +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). From ff6f9e5044aa7c5326d4abf0627f885ec4b973f6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:34:16 +0900 Subject: [PATCH 33/34] test(link): find the ocx-child binary under CARGO_TARGET_DIR The e2e test built ocx-child with cargo but always looked for the binary under native/ocx-child/target, so a run with CARGO_TARGET_DIR set failed with ENOENT. It now resolves the target directory the way cargo does. --- tests/clients/link-ocx-child-e2e.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/clients/link-ocx-child-e2e.test.ts b/tests/clients/link-ocx-child-e2e.test.ts index 6ab05b7d68f..b05f480f90c 100644 --- a/tests/clients/link-ocx-child-e2e.test.ts +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -2,7 +2,7 @@ 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 } from "node:path"; +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"; @@ -22,8 +22,11 @@ import type { OcxConfig } from "../../src/types"; function findOrBuildOcxChildBinary(): string { const exeSuffix = process.platform === "win32" ? ".exe" : ""; - const releaseBin = repoPath("native", "ocx-child", "target", "release", `ocx-child${exeSuffix}`); - const debugBin = repoPath("native", "ocx-child", "target", "debug", `ocx-child${exeSuffix}`); + // 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)) { From 79e4a18240e943d07b4f601a729fb9c5995a05fa Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:37:06 +0900 Subject: [PATCH 34/34] fix(ocx-child): stop printing the link key id after attach The attach success line named the link key identifier. Keep identifiers out of console output, matching the repository privacy rule; the regression asserts attach output never contains the key id. --- native/ocx-child/src/cli.rs | 2 +- native/ocx-child/tests/lifecycle_guards.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 2370bbccadd..9a0a3e3f5af 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -978,7 +978,7 @@ fn attach_locked(guard: &mut CodexHomeGuard, request: AttachRequest) -> Result<( } } } - println!("Connected to http://127.0.0.1:{tunnel_port} as key {api_key_id}."); + println!("Connected to http://127.0.0.1:{tunnel_port}."); println!("Local Codex CLI: ready (ephemeral ocx-child Design B attached)."); Ok(()) } diff --git a/native/ocx-child/tests/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index fb29a0930d4..c23c78bde8a 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -148,6 +148,12 @@ fn two_child_homes_sharing_one_codex_home_are_serialized_and_exclusive() { 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!(