From a5c605fafcd297c322d12e11fe9c9bedc0eeab6c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 01:10:26 +0900 Subject: [PATCH 01/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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 fb2f12a79331708ef7ae3fb1d3a24f1011cfa35c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 07:33:14 +0900 Subject: [PATCH 11/50] docs(devlog): add wp0 roadmap and PRDs for ocx-child plant UX --- .../261107_ocx_child_plant_ux/000_roadmap.md | 136 +++++++++++++ .../001_audit_and_status.md | 26 +++ .../010_wp1_rust_purge_and_conflicts.md | 181 ++++++++++++++++++ .../020_wp2_home_plant_store_and_api.md | 103 ++++++++++ .../030_wp3_cli_and_gui_remote_ux.md | 109 +++++++++++ .../040_wp4_docs_screenshots_and_pr.md | 39 ++++ 6 files changed, 594 insertions(+) create mode 100644 devlog/_plan/261107_ocx_child_plant_ux/000_roadmap.md create mode 100644 devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md create mode 100644 devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md create mode 100644 devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md create mode 100644 devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md create mode 100644 devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md diff --git a/devlog/_plan/261107_ocx_child_plant_ux/000_roadmap.md b/devlog/_plan/261107_ocx_child_plant_ux/000_roadmap.md new file mode 100644 index 00000000000..b541177ad66 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/000_roadmap.md @@ -0,0 +1,136 @@ +# Zero-Preinstall SSH Planting, Two-Tier `--purge` Detach, and `#remote` UX Complete Lightweight Ephemeral Child Onboarding + +> Reader contract: For OpenCodex maintainers and reviewers evaluating stacked PR delivery on `feat/ocx-child-plant-ux` (based on `feat/ocx-child-ephemeral-link`, #8237). Document type: Decision & Roadmap Specification (`000`-range, zero implementation diffs per `LEXICO-SPLIT-01`). + +PR #8237 introduced `native/ocx-child` (~0.9 MB standalone Rust binary) and Home discovery/enrollment support, yet operators connecting a bare SSH devbox, container, or VM still hit `502 remote_ocx_missing` unless they manually copy `ocx-child` onto the Child first, have no one-click or `--purge` option to remove the planted binary on disconnect, receive generic conflict errors when port `10100` or `~/.codex/config.toml` is already owned externally, and see no mode or settings-parity badges in `#remote`. How can OpenCodex deliver zero-preinstall SSH Child onboarding, clean two-tier disconnect (`fast detach` vs `--purge`), actionable conflict diagnostics, and full CLI + 11-locale `#remote` UX without touching release workflows or file-size ratchets? We extend Home host inspection to classify `runtimeKind` (`full` | `ephemeral` | `missing`) and `targetTriple`, verify local `ocx-child` artifacts by SHA-256 (`src/link/child-binary-source.ts`), stream atomic SSH `stdin` plants into the Child's user cache bin directory when authorized (`plant: true`), add safe managed-path `--purge` self-removal and structured conflict markers to `native/ocx-child`, persist `childKind` on `LinkRecord`, and redesign `#remote` + `ocx link add|revoke` across all 11 locales. + +## 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-plant-ux` on branch `feat/ocx-child-plant-ux` stacked on open PR #8237 (`feat/ocx-child-ephemeral-link` at `c4d4d5d6311905bea62f065d7d04b9de7cdb5a86`) | +| Goal | Deliver zero-preinstall SSH binary planting, `--purge` managed-path removal, actionable `child_port_busy` / `child_external_provider_conflict` diagnostics, `LinkRecord.childKind` persistence/projection, CLI `--plant` / `--purge` flags, `#remote` GUI redesign across all 11 locales, `pr-assets` UI screenshots, and an unmerged stacked PR | +| Non-goals | Merging the PR (`authority.merge = false`), editing `.github/workflows/` or `scripts/release.ts`, touching `src/lab/` or `core-lab-boundary` protected files (`src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`), raising file-size ratchets, committing PNGs onto `feat/ocx-child-plant-ux`, dispatching `lane=all` CI, mutating shared `.codexclaw` subagent configs, or calling `web_search` | +| Verifier | Grounded commands in [Section 7](#7-grounded-verifiers-plan-verifier-real-01): `cargo test` + `cargo build --release --locked`, `bun run typecheck`, `bun run test:changed`, `cd gui && bun run lint:i18n && bun run lint && bun test tests && bun run build`, `bun run build:gui`, `bun run structure:check`, `cd docs-site && bun run build`, `bun run privacy:scan`, and `gh pr checks` | +| Stop condition | All work-phases `wp0..wp4` and criteria `c-1..c-5` met with fresh receipts, UI screenshots pushed only to `origin/pr-assets`, stacked PR opened with `DEV-STACK-03` table, 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_plant_ux/` (`000_roadmap.md` + `010..040` PRDs) and `.codexclaw/goalplans/loop-archetype-satisfy-spec-class-c4-hotl-multi/goalplan.json` | +| Expected terminal outcomes | `DONE`: all `c-1..c-5` verified + green unmerged stacked PR recorded; `NOOP`: N/A; `BLOCKED`: reproducible CI/policy/toolchain refusal reported with `file:line` and command output without weakening checks; `UNSAFE`: unmitigated binary integrity, symlink, or path-deletion hazard; `NEEDS_HUMAN`: out-of-scope authority required; `BUDGET_EXHAUSTED`: host limit reached | +| Escalation condition | Any requirement to edit outside `writeScope` (`native/ocx-child`, `src/link`, `src/server/management`, `src/cli`, `gui`, `structure`, `docs-site`, `scripts`, `tests`, `devlog`), weaken a check, commit images to the PR branch, or merge. Subagents 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, headless browser for local `#remote` screenshot capture, `origin/pr-assets` upload, `gh pr create` / `gh pr view` / `gh pr checks`, GOAT PR link/watch/unwatch | + +## 2. Threat Model & Security Controls (`SEC-THREAT-01`) + +1. **Assets**: + - Remote Child host filesystem integrity (`$HOME/.cache/opencodex/bin/ocx-child`, `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, `$OPENCODEX_HOME/child/*`, `~/.codex/*`). + - Integrity and authenticity of the `ocx-child` binary streamed from Home to Child over SSH `stdin`. + - Operator consent before mutating a bare remote host (`plant: true` / `--plant` / explicit confirmation). + - Existing SSH host-key pinning (`StrictHostKeyChecking=yes`, private `known_hosts`, `BatchMode=yes`, `ClearAllForwardings=yes`) and Windows `OcxJob` process containment. +2. **Entrypoints & Trust Boundaries**: + - Home binary artifact resolution (`src/link/child-binary-source.ts`) reading `OPENCODEX_CHILD_BIN_DIR`, sidecar directories, local `target/release`, or `$OPENCODEX_HOME/cache/ocx-child//`. + - Home `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply`, and `DELETE /api/link/:id` (`src/server/management/link-enrollment.ts`, `link-routes.ts`). + - SSH `stdin` binary write & execution on POSIX (`sh -c`) and Windows (`powershell.exe -EncodedCommand` under `OcxJob`). + - `ocx-child detach --purge` / `disconnect --purge` self-removal (`native/ocx-child/src/cli.rs`, `platform.rs`). +3. **Attacker Capabilities & Hazard Scenarios**: + - Corrupted, truncated, symlinked, or unverified `ocx-child` binary in cache/override directory streamed onto a Child. + - Pre-existing symlink at `$HOME/.cache/opencodex/bin` or `$HOME/.cache/opencodex/bin/ocx-child` (or Windows ReparsePoint) tricking plant or `--purge` into overwriting or deleting an unintended file. + - `ocx-child detach --purge` invoked on a system/package-manager/user path (`/usr/local/bin/ocx-child`, `~/.local/bin/ocx-child`, Cargo target dir) deleting a binary Home did not plant. + - Non-interactive CLI automation running `ocx link add --expect-fingerprint ` against a bare host without intending to install software on it. +4. **Controls**: + - **SHA-256 artifact verification & regular-file checks**: `child-binary-source.ts` rejects symlinks (`lstatSync`), group/world-writable binaries on POSIX, empty/oversized (> 32 MiB) files, and requires exact `SHA256SUMS` verification for cached/distributed artifacts (computing SHA-256 over the exact read `Buffer` streamed to `stdin` to eliminate TOCTOU between hash and stream). + - **Explicit `plant` authorization**: `POST /api/link/apply` never plants unless `body.plant === true`; non-TTY `ocx link add` refuses bare hosts unless `--plant` is passed alongside `--expect-fingerprint`; GUI shows an explicit Ready-to-Plant card with architecture and byte size. + - **Symlink-refusing atomic plant & Windows `OcxJob` containment**: POSIX plant runs under `umask 077`, refuses symlinked parent/target (`[ ! -L ... ]`), writes to `.ocx-child.tmp.$$.`, verifies exact byte count and `chmod 0700`, atomically `mv -f`s into `$HOME/.cache/opencodex/bin/ocx-child`, and verifies `--version` exit `0`. Windows plant runs inside packed PowerShell + `OcxJob` (`<= 8000` UTF-16LE base64 chars), rejects `ReparsePoint` attributes on parent/target, verifies exact byte length and SHA-256, moves atomically into `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, and verifies `[OcxJob]::Ok`. + - **Post-plant rollback**: If `apply` fails after planting a fresh binary in that enrollment attempt (`plantedFreshBinary === true`), Home runs bounded cleanup (`ocx-child detach --purge` / managed-path removal) before returning the error so failed enrollments leave zero orphan binaries or daemons. + - **Managed-path-only `--purge`**: `native/ocx-child` inspects `current_exe` without following a final symlink (`symlink_metadata`), verifies the parent directory matches ONLY the managed plant location (`*/.cache/opencodex/bin/ocx-child` on POSIX; `*\opencodex\bin\ocx-child.exe` or `*/.cache/opencodex/bin/ocx-child.exe` on Windows), refuses symlinks/reparse points and foreign paths (treating non-managed paths as a safe no-op binary keep with a notice), removes `$OPENCODEX_HOME/child` only via `fs::remove_dir` (which fails safely if non-empty), and on Windows renames `ocx-child.exe` aside in the same directory before spawning a short detached `cmd.exe /d /c` deleter. + +## 3. Architect Consultation & Design Dispositions + +- **Proposals consulted**: + - Initial read-only architect handle `01a1b2ea-432d-7001-8efc-89edb28881db` (retired after 145s timeout with zero workspace mutations per `DISPATCH-RETIRE-01`) and live read-only architect handle `01a1b2ed-664f-7180-99b1-fd1091864296` (both inherited `google-antigravity/gemini-4-argon`). +- **Main dispositions on `ARCH-01..ARCH-06`**: + - **`ARCH-01` (Amended)**: Adopt `allowMissing?: boolean` on `POST /api/link/probe` (when `includeVersion: true`) and `POST /api/link/confirm-host`, returning `runtimeKind: "full" | "ephemeral" | "missing"`, `targetTriple: "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64" | "windows-x64" | null`, `ocxVersion: string | null`, `plantable: boolean`, `binarySizeBytes?: number`, and `plantUnavailableReason?: string`. **Amendment**: When `allowMissing !== true`, `confirm-host` preserves the exact legacy `remoteOcxArgv(["--version"], remotePlatform)` call and `{ alias, fingerprint, ocxVersion }` response shape so `tests/server/link-enrollment.test.ts:90` and Find Home (`role === "child"`) remain 100% unchanged; when `allowMissing === true`, a single framed inspection command (`src/link/remote-plant.ts`) reports OS/arch, selected candidate kind (`full` vs `ephemeral` vs `missing`), and `--version` output in one SSH round-trip. + - **`ARCH-02` (Accepted)**: Implement `src/link/child-binary-source.ts` resolving `(1)` `OPENCODEX_CHILD_BIN_DIR`, `(2)` sidecar directories (`native-bin//` and beside `process.execPath`), `(3)` local `native/ocx-child/target/release/ocx-child(.exe)` when `targetTriple === hostTriple`, and `(4)` `$OPENCODEX_HOME/cache/ocx-child//` verified against `SHA256SUMS`. Read bytes once into memory and return `{ bytes, sizeBytes, sha256, targetTriple, sourceKind }`. + - **`ARCH-03` (Amended)**: Implement POSIX and Windows SSH `stdin` atomic plant and post-plant failure rollback in `POST /api/link/apply` (`plant?: boolean`). **Amendment**: Reject reordering `REMOTE_OCX_CANDIDATES` / `OCX_CANDIDATES` ahead of full `ocx` when `child/state.json` is absent — `structure/remote-link.md:187` and `tests/clients/link-ephemeral-child-discovery.test.ts` lock the invariant that full `ocx` wins when detached while `child/state.json` pins `ocx-child` once attached. On a bare host no full `ocx` exists, so `$HOME/.cache/opencodex/bin/ocx-child` / `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` is already discovered first and then pinned by `child/state.json`. + - **`ARCH-04` (Accepted)**: Implement `--purge` on `ocx-child detach` and `ocx-child disconnect` (`cli.rs` + `platform.rs`) with strict managed-path and non-symlink verification, empty `$OPENCODEX_HOME/child` directory removal, and Windows rename-aside + detached `cmd.exe /d /c` self-delete. Add deterministic conflict markers (`ocx-child-conflict:child_port_busy`, `ocx-child-conflict:child_external_provider_conflict`) and map both Home preflight and `connect` failures to `409 child_port_busy` and `409 child_external_provider_conflict` with zero Child file mutations. + - **`ARCH-05` (Amended)**: Persist optional `childKind?: "full" | "ephemeral"` on `LinkRecord` (`src/link/store.ts`), project `childKind` on `LinkStatusDto["links"][number]` (`src/link/status-projection.ts`, omitting when undefined on legacy records so legacy exact-object fixtures stay untouched while GUI defaults legacy hub-initiated rows to `"full"`), expose `homeCodexSettingsDigest` on `GET /api/link/status` for `gui-session` callers (`src/server/management/link-routes.ts:300`), and extend `DELETE /api/link/:id` body to `{ force?: boolean, purge?: boolean }` (rejecting `force: true` combined with `purge: true` as `400 invalid_body`, and running `remoteOcxArgv(["disconnect", "--purge"], platform)` when `purge === true` on `record.childKind === "ephemeral"`). + - **`ARCH-06` (Accepted)**: Extend `ocx link add [--plant]` and `ocx link revoke [--purge]`, extract modular GUI components (`gui/src/pages/RemoteLinkSheet.tsx` and `gui/src/pages/RemoteLinkDialogs.tsx`) so `RemoteLink.tsx` shrinks cleanly, style via `gui/src/styles-remote-link.css` (leaving baseline-capped `gui/src/styles.css` untouched), and update all 11 locale catalogs (`en`, `ko`, `ja`, `zh`, `zh-TW`, `de`, `fr`, `pt`, `ru`, `tr`, `vi`) in small 2–3 file batches. + +### 3.1 Same-Architect Reflection Record + +- **Submitted concrete plan revision**: `devlog/_plan/261107_ocx_child_plant_ux/{000_roadmap.md,010_wp1_rust_purge_and_conflicts.md,020_wp2_home_plant_store_and_api.md,030_wp3_cli_and_gui_remote_ux.md,040_wp4_docs_screenshots_and_pr.md}`. +- **Reflection handle `01a1b2ed-664f-7180-99b1-fd1091864296` verdict**: `ALIGNED` (zero material gaps). +- **Decision-ID mapping verified**: `ARCH-01` -> `000` §3, §5 + `010` §2.3 + `020` §2.1–§2.2; `ARCH-02` -> `000` §2–§3 + `010` §2.3; `ARCH-03` -> `000` §2–§3 + `010` §2.3 + `020` §2.2; `ARCH-04` -> `000` §3, §5 + `010` §2.1–§2.2 + `020` §2.2; `ARCH-05` -> `000` §3, §5 + `010` §2.3 + `020` §2.3; `ARCH-06` -> `000` §3 + `030` §1–§3 + `040` §1–§2. + +## 4. Dependency-Ordered Work-Phase Map (`PHASE-SPLIT-01`) + +| Work Phase | Decade Doc | Architectural Layer | Depends On | Key Deliverables & Verifiable Close | +|---|---|---|---|---| +| `wp0` | `000_roadmap.md`, `010..040` | Specification & Audit | — | Diff-level PRDs, architect reflection, independent A-gate review, verifier grounding; closes `D -> IDLE` with zero production edits | +| `wp1` | `010_wp1_rust_purge_and_conflicts.md` | Rust `ocx-child` & `src/link/` Foundations | `wp0` | `native/ocx-child` `--purge` + conflict markers; `LinkRecord.childKind`; `child-binary-source.ts`; `remote-plant.ts` + `remote-windows.ts` inspect/plant argv builders; Cargo + Bun unit tests green | +| `wp2` | `020_wp2_home_plant_store_and_api.md` | Home Management API & Status Projection | `wp1` | `probe` / `confirm-host` (`allowMissing`), `apply` (`plant` + rollback + `childKind` + `409` conflict codes), `status` (`childKind` + `homeCodexSettingsDigest`), `DELETE /api/link/:id` (`purge`); server & E2E plant/purge tests green | +| `wp3` | `030_wp3_cli_and_gui_remote_ux.md` | CLI & `#remote` Dashboard UX + 11 Locales | `wp2` | `ocx link add --plant`, `ocx link revoke --purge`, `RemoteLink.tsx` + sibling components, Ready-to-Plant card, mode/parity badges, conflict notices, Disconnect `--purge` modal, 11 locales; CLI + GUI `lint:i18n`, `lint`, `test`, `build` green | +| `wp4` | `040_wp4_docs_screenshots_and_pr.md` | SoT Docs, `pr-assets` Screenshots & Stacked PR | `wp3` | `structure/remote-link.md` + `docs-site/` sync, full gates, real `#remote` PNGs pushed ONLY to `origin/pr-assets`, stacked PR opened against `feat/ocx-child-ephemeral-link` (#8237), CI/review watch to green, `cxc loop pr-record` | + +## 5. Cross-Boundary Field & Type Chains (`PLAN-FIELD-CHAIN-01`) + +1. **`LinkChildKind` (`"full" | "ephemeral"`)**: + - **Creation**: `src/server/management/link-enrollment.ts` (`apply` sets `childKind: ephemeralChild ? "ephemeral" : "full"` on hub-initiated `LinkRecord`). Client-initiated `/api/link/issue` sets `childKind: "full"` (legacy records omit). + - **Serialization**: `src/link/store.ts` (`writeLinkStore` writes `links.json` with `"childKind"` added to `RECORD_KEYS`). + - **Deserialization**: `src/link/store.ts` (`parseRecord` validates `raw.childKind === undefined || raw.childKind === "full" || raw.childKind === "ephemeral"`). + - **Consumers**: `src/link/status-projection.ts` (`projectLinkStatus` copies optional `childKind` onto `LinkStatusDto["links"][number]`); `src/server/management/link-routes.ts` (`remove` checks `purge === true && record.childKind === "ephemeral"`); `src/cli/link.ts` (`validateStatus` accepts optional `"childKind"`, `runRevoke` uses status/store `childKind` to print `~/.cache/opencodex/bin/ocx-child detach` recovery on `--force`); `gui/src/remote-link-api.ts` (`parseRemoteLinkStatus` parses `childKind`); `gui/src/pages/RemoteLink.tsx` & `RemoteLinkDialogs.tsx` (Connected row badge, Disconnect `--purge` checkbox visibility, Force-remove command chip). +2. **`RemoteRuntimeKind` (`"full" | "ephemeral" | "missing"`) & `ChildTargetTriple` (`"darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64" | "windows-x64"`)**: + - **Creation**: `src/link/remote-plant.ts` (`inspectRemoteChildRuntime` parses framed SSH stdout + `resolveChildBinarySource`). + - **Serialization**: `src/server/management/link-enrollment.ts` (`probe` and `confirmHost` JSON responses when `allowMissing === true`, plus `ConfirmedLinkHost` in-memory state). + - **Deserialization**: `src/cli/link-enrollment.ts` (`offered` & confirm validator) and `gui/src/remote-link-api.ts` (`parseConfirmation`). + - **Consumers**: `src/server/management/link-enrollment.ts` (`apply` requires `plant === true` when `confirmed.runtimeKind === "missing"`, streams `targetTriple` binary); `src/cli/link-enrollment.ts` (TTY plant prompt vs non-TTY `--plant` gate); `gui/src/pages/RemoteLinkSheet.tsx` (Ready-to-Plant card vs Cached Ephemeral card vs Full OpenCodex card). +3. **Conflict Error Codes (`"child_port_busy"` | `"child_external_provider_conflict"` | `"remote_plant_failed"`)**: + - **Creation**: `native/ocx-child/src/daemon.rs` (`ensure_port_available_for_attach`), `native/ocx-child/src/toml_surgeon.rs` (`detect_unowned_root_routing_conflict`), and `src/server/management/link-enrollment.ts` (`apply` preflight/plant/connect error mapping). + - **Serialization**: `src/server/management/link-route-state.ts` (`fail(code, message, 409 | 502, hint)`). + - **Deserialization**: `src/cli/link-enrollment.ts` (`PHASES` / specific error messages) and `gui/src/remote-link-api.ts` (`LINK_ERROR_CODES` + `LinkApiError`). + - **Consumers**: `gui/src/pages/RemoteLink.tsx` (`ERROR_TKEY` -> localized `Notice` across all 11 `gui/src/i18n/*.ts` files) and `src/cli/link-enrollment.ts` (human-readable stderr recovery). +4. **Home Parity Digest on GUI Status (`homeCodexSettingsDigest` + `appliedCodexSettingsDigest`)**: + - **Creation**: `src/server/management/link-routes.ts` (`GET /api/link/status` computes `codexSettingsDigest(projectHomeCodexSettings(ctx.config, "0.0.0").settings)` for `ctx.principal === "gui-session"`, alongside existing `LinkChildParity.appliedCodexSettingsDigest`). + - **Serialization**: `Response.json(body)` on `GET /api/link/status` (omitted on `admin-token` responses so strict CLI `validateStatus` top-level keys stay unchanged). + - **Deserialization**: `gui/src/remote-link-api.ts` (`parseRemoteLinkStatus` extracts `homeCodexSettingsDigest: string | null` and per-row `appliedCodexSettingsDigest: string | null`). + - **Consumers**: `gui/src/pages/RemoteLink.tsx` (renders "Home settings synced" / "Sync needed" badge when both digests are non-null on a connected Child row). + +## 6. Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`) + +| Guard / Control | Tier | Executing Surface | Known Bypass Path | Residual Risk | Wording Policy | Final Layer | +|---|---|---|---|---|---|---| +| Managed-path check on `ocx-child detach --purge` | E2 (Runtime check) | `native/ocx-child/src/platform.rs` (`purge_managed_executable`) | Local Child user can manually `rm` any file they own over SSH | None for foreign paths: `ocx-child` refuses to unlink symlinks or paths outside `.cache/opencodex/bin` / `AppData\Local\opencodex\bin` | Described as a safety guard on `--purge`, never OS-level immutability | `native/ocx-child` `symlink_metadata` + parent path suffix match | +| `SHA256SUMS` check in `child-binary-source.ts` | E2 (Runtime check) | `src/link/child-binary-source.ts` | Same local OS user setting `OPENCODEX_CHILD_BIN_DIR` or rebuilding `native/ocx-child/target/release` locally | Local Home process user already owns SSH keys to Child; protects against corrupted/tampered downloads or cache files | Called artifact integrity verification against `SHA256SUMS` / local build | Exact byte-count + SHA-256 check on the Child plant script | +| Non-TTY `--plant` requirement in `ocx link add` | E2 (CLI check) | `src/cli/link-enrollment.ts` + `POST /api/link/apply` (`plant === true`) | Caller with admin token or paired GUI session explicitly passing `"plant": true` | Intentional: `plant: true` is the explicit opt-in contract | Called explicit plant opt-in guard | `POST /api/link/apply` server check (`body.plant === true`) | + +## 7. Grounded Verifiers (`PLAN-VERIFIER-REAL-01`) + +Every verifier below was executed or verified against the repository scripts on `2026-11-07`: + +1. `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` — baseline ran (`exit 0`, 8/8 tests passed); directly compiles and tests `native/ocx-child/src/**/*.rs` and `native/ocx-child/tests/*.rs`. +2. `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` — builds the real `native/ocx-child/target/release/ocx-child` binary consumed by `child-binary-source.ts` and E2E SSH plant/purge tests. +3. `bun run typecheck` (`package.json` -> `bun check --no-script --noEmit`) — baseline ran (`exit 0`, 2,319 files checked); directly typechecks `src/**/*.ts`, `tests/**/*.ts`, and `gui/src/**/*.tsx` including all 11 `gui/src/i18n/*.ts` `TKey` exhaustiveness checks. +4. `cd gui && bun run lint:i18n` (`oxlint src/pages src/components src/App.tsx src/main.tsx src/ui.tsx src/provider-workspace-data.ts`) — baseline ran (`exit 0`); enforces zero hardcoded UI strings in `gui/src/pages/RemoteLink*.tsx` and locale catalog parity. +5. `cd gui && bun run lint && bun test tests && bun run build` (plus root `bun run build:gui`) — directly lints, runs `gui/tests/remote-link.test.tsx` + `remote-link-route.test.tsx`, and bundles `gui/dist`. +6. `bun run test:changed` + explicit focused suites (`bun test tests/clients/link-*.test.ts tests/server/link-*.test.ts tests/cli/cli-link*.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/ci-workflows/repo-hygiene.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts`) — exercises module-graph + subprocess/layout/ratchet/hygiene gates. +7. `bun run skill:surface:check` — baseline ran (`exit 0`); verifies `src/cli/capabilities*.ts` and `skills/ocx/` stay synchronized. +8. `bun run structure:check` — baseline ran (`exit 0`); verifies `structure/remote-link.md`, `structure/manifest.json`, and `structure/INDEX.md`. +9. `cd docs-site && bun run build` — Astro + Starlight build verifying `docs-site/` remote-link user docs. +10. `bun run privacy:scan` — baseline ran (`exit 0`); scans tracked `src/`, `gui/`, `tests/`, `docs-site/`, and `devlog/` for accidental credentials or PII. + +## 8. Non-Goals, Alternatives Rejected, & Consequences + +- **Rejected SFTP/SCP upload for `ocx-child` planting**: OpenSSH `scp -O` vs SFTP protocol availability varies across minimal Alpine/BusyBox containers and restricted Windows OpenSSH setups, whereas `ssh -T ... sh -c` / `-EncodedCommand` with piped `stdin` uses the exact transport already proven by `probe` and `connect --key-stdin` and works 100% air-gapped. +- **Rejected modifying `.github/workflows/` or `scripts/release.ts`**: Out of lane write scope and requires maintainer release-pipeline security sign-off; `child-binary-source.ts` cleanly supports `OPENCODEX_CHILD_BIN_DIR`, packaged sidecars, local `target/release`, and `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//` today. +- **Rejected committing UI screenshots onto `feat/ocx-child-plant-ux`**: Prohibited by `AGENTS.md` and `tests/ci-workflows/repo-hygiene.test.ts`; screenshots are committed strictly to `origin/pr-assets` and referenced by immutable commit SHA in the PR description. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md b/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md new file mode 100644 index 00000000000..2b155dc668d --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md @@ -0,0 +1,26 @@ +# Audit & Work-Phase Status Ledger (`261107_ocx_child_plant_ux`) + +> Reader contract: Durable audit, disclosure, and work-phase status record for `feat/ocx-child-plant-ux` (stacked on PR #8237 `feat/ocx-child-ephemeral-link`). + +`wp0` (docs-first roadmap cycle per `LOOP-DOCS-FIRST-01`) audited all five plan documents (`000_roadmap.md` and `010..040`) with zero Critical/High blockers (`VERDICT: PASS`) and folded all four Medium/Low refinements into `010..030` prior to implementation. + +## 1. `wp0` Audit Record & Same-Family Disclosure (`REVIEW-DECORRELATE-01`) + +- **Model inheritance disclosure**: Per explicit user instruction ("너 상속 으로 해서"), both architect (`01a1b2ed-664f-7180-99b1-fd1091864296`) and independent A-phase reviewer (`01a1b2f8-ffdf-77b0-a854-0141550b121d`) inherited the coordinator's model family (`google-antigravity/gemini-4-argon`); cross-family decorrelation was intentionally not used. +- **Reviewer `01a1b2f8-ffdf-77b0-a854-0141550b121d` verdict**: `VERDICT: PASS` (`blocking_issues: []`). +- **Dispositions of Medium/Low findings**: + 1. `M1` (Accepted & folded into `020`): Update `tests/server/link-enrollment-ephemeral-child.test.ts:192` (and `structure/remote-link.md:191` in `wp4`) from `"child_restart_unsupported"` to `"child_port_busy"` when `before.code === 0` on an ephemeral Child. + 2. `M2` (Accepted & folded into `010`): Prefix all three conflict branches in `native/ocx-child/src/toml_surgeon.rs:399,421,433` (including user-owned `model_catalog_json`) with `ocx-child-conflict:child_external_provider_conflict:`. + 3. `L3` (Accepted & folded into `020`/`030`): Keep `childKind` omitted on `POST /api/link/issue` client-initiated records so existing exact-store assertions stay green, and accept both legacy `{ alias, fingerprint, ocxVersion }` and extended `allowMissing` confirmation responses in `src/cli/link-enrollment.ts`. + 4. `L4` (Accepted & folded into `030`): Leave `src/cli/capabilities-base.ts` untouched so `skills/ocx/references/*` (outside `writeScope`) remains byte-identical and `bun run skill:surface:check` stays green. + +## 2. Work-Phase Status Table + +| Work Phase | Status | Summary & Verification Receipt | +|---|---|---| +| `wp0` | DONE | Docs-first roadmap (`000_roadmap.md`, `001_audit_and_status.md`, `010..040`) completed, audited (`VERDICT: PASS`), and verified with `privacy:scan` + `structure:check` (`exit 0`); zero production files modified | +| `wp1` | PENDING | Rust `native/ocx-child` `--purge` & conflict prefixes + `src/link/` foundations | +| `wp2` | PENDING | Home Management API `confirm-host`, `apply` plant/rollback, `status`, and `DELETE --purge` | +| `wp3` | PENDING | CLI (`--plant`, `--purge`) & `#remote` GUI + 11 locales | +| `wp4` | PENDING | SoT docs, `origin/pr-assets` screenshots, full gates, stacked PR, CI watch & `pr-record` | + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md b/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md new file mode 100644 index 00000000000..c531a3e97bd --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md @@ -0,0 +1,181 @@ +# Work-Phase 1 (`wp1`): `native/ocx-child` `--purge` & Conflict Markers + `src/link/` Foundations + +> Depends on: `000_roadmap.md` (`ARCH-01..ARCH-05`). Consumed types: `LinkRecord`, `SshRunner`, `packedPowerShellArgv`, `ChildPaths`. Exported types: `LinkChildKind`, `ChildTargetTriple`, `RemoteRuntimeKind`, `ChildBinarySource`, `RemoteRuntimeInspection`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `native/ocx-child/src/platform.rs` | Add `PurgeExecutableOutcome`, `is_managed_plant_executable(exe_path, user_home, local_appdata)`, and `purge_managed_executable(exe_path, user_home, local_appdata)` (refusing symlinks/reparse points and foreign paths; POSIX `fs::remove_file`; Windows rename-aside + detached `cmd.exe /d /c` cleanup) | +| MODIFY | `native/ocx-child/src/daemon.rs` | Prefix `ensure_port_available_for_attach` conflict errors with `ocx-child-conflict:child_port_busy:` so Home maps foreign port ownership deterministically to `409 child_port_busy` | +| MODIFY | `native/ocx-child/src/toml_surgeon.rs` | Prefix `detect_unowned_root_routing_conflict` external provider / user `openai_base_url` errors with `ocx-child-conflict:child_external_provider_conflict:` | +| MODIFY | `native/ocx-child/src/cli.rs` | Extend `cmd_detach` (`detach` / `disconnect`) to accept `--purge` (reject unknown flags with exit `2`), drop `FileLock` and remove `lock_file` + empty `$OPENCODEX_HOME/child` dir, and run `purge_managed_executable` when `--purge` is passed | +| MODIFY | `native/ocx-child/tests/relay_and_lifecycle.rs` | Add Rust integration tests for `detach --purge` (managed path removed + empty `child` dir removed; foreign path preserved; symlink refused) and structured `child_port_busy` / `child_external_provider_conflict` stderr prefixes | +| MODIFY | `src/link/store.ts` | Add `export type LinkChildKind = "full" | "ephemeral"` and optional `childKind?: LinkChildKind` to `LinkRecord` + `"childKind"` in `RECORD_KEYS` | +| NEW | `src/link/child-binary-source.ts` | Resolve `ocx-child` binary for `ChildTargetTriple` across `OPENCODEX_CHILD_BIN_DIR`, sidecar dirs, `native/ocx-child/target/release` (when `targetTriple === hostChildTargetTriple()`), and `$OPENCODEX_HOME/cache/ocx-child//` with `SHA256SUMS` verification | +| MODIFY | `src/link/remote-windows.ts` | Add `windowsInspectRuntimeArgv()` and `windowsPlantChildArgv(expectedBytes, expectedSha256)` + `windowsPurgePlantedChildArgv()` staying strictly below `POWERSHELL_ENCODED_MAX = 8000` inside `OcxJob` | +| NEW | `src/link/remote-plant.ts` | Implement `ChildTargetTriple`, `RemoteRuntimeKind`, `parseTargetTriple`, `remoteInspectRuntimeArgv`, `inspectRemoteChildRuntime`, `remotePlantChildArgv`, `remotePurgePlantedChildArgv`, and `classifyChildAttachConflict` | +| NEW | `tests/clients/link-child-plant-foundations.test.ts` | Unit tests for `child-binary-source.ts` (`SHA256SUMS` pass/mismatch, symlink refusal, `OPENCODEX_CHILD_BIN_DIR`, host release fallback), `LinkRecord.childKind` roundtrip, and POSIX/Windows inspect & plant argv budgets (`<= 8000`) | +| MODIFY | `scripts/test-layout/layout-clients.json` & `tests/fixtures/test-layout-expected.json` | Register `tests/clients/link-child-plant-foundations.test.ts` | + +## 2. Diff-Level Specifications + +### 2.1 `native/ocx-child/src/platform.rs` & `cli.rs` (`--purge`) + +```diff +--- a/native/ocx-child/src/platform.rs ++++ b/native/ocx-child/src/platform.rs +@@ -876,5 +876,82 @@ 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") + } ++ ++#[derive(Debug, Clone, PartialEq, Eq)] ++pub enum PurgeExecutableOutcome { ++ Removed, ++ SkippedUnmanagedPath, ++} ++ ++pub fn is_managed_plant_executable( ++ exe_path: &Path, ++ user_home: &Path, ++ local_appdata: Option<&Path>, ++) -> Result { ++ reject_symlink(exe_path)?; ++ let Some(parent) = exe_path.parent() else { return Ok(false); }; ++ reject_symlink(parent)?; ++ let expected_name = if cfg!(windows) { "ocx-child.exe" } else { "ocx-child" }; ++ if exe_path.file_name().and_then(|n| n.to_str()) != Some(expected_name) { ++ return Ok(false); ++ } ++ let canon_parent = fs::canonicalize(parent) ++ .map_err(|e| format!("failed canonicalizing {}: {e}", parent.display()))?; ++ let cache_bin = user_home.join(".cache").join("opencodex").join("bin"); ++ if fs::canonicalize(&cache_bin).ok().as_ref() == Some(&canon_parent) { ++ return Ok(true); ++ } ++ if let Some(appdata) = local_appdata { ++ let win_bin = appdata.join("opencodex").join("bin"); ++ if fs::canonicalize(&win_bin).ok().as_ref() == Some(&canon_parent) { ++ return Ok(true); ++ } ++ } ++ Ok(false) ++} ++ ++pub fn purge_managed_executable( ++ exe_path: &Path, ++ user_home: &Path, ++ local_appdata: Option<&Path>, ++) -> Result { ++ if !is_managed_plant_executable(exe_path, user_home, local_appdata)? { ++ return Ok(PurgeExecutableOutcome::SkippedUnmanagedPath); ++ } ++ #[cfg(unix)] ++ { ++ fs::remove_file(exe_path) ++ .map_err(|e| format!("failed removing planted ocx-child {}: {e}", exe_path.display()))?; ++ Ok(PurgeExecutableOutcome::Removed) ++ } ++ #[cfg(windows)] ++ { ++ use std::os::windows::process::CommandExt; ++ const CREATE_NO_WINDOW: u32 = 0x0800_0000; ++ const DETACHED_PROCESS: u32 = 0x0000_0008; ++ let parent = exe_path.parent().ok_or_else(|| "missing parent".to_owned())?; ++ let trashed = parent.join(format!(".ocx-child.purge.{}.old", std::process::id())); ++ let _ = fs::remove_file(&trashed); ++ fs::rename(exe_path, &trashed) ++ .map_err(|e| format!("failed renaming planted ocx-child for purge: {e}"))?; ++ let cmd_line = format!( ++ "ping -n 2 127.0.0.1 >nul & del /f /q "{}"", ++ trashed.display() ++ ); ++ let _ = Command::new("cmd.exe") ++ .args(["/d", "/c", &cmd_line]) ++ .stdin(Stdio::null()) ++ .stdout(Stdio::null()) ++ .stderr(Stdio::null()) ++ .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) ++ .spawn(); ++ Ok(PurgeExecutableOutcome::Removed) ++ } ++} +``` + +In `native/ocx-child/src/cli.rs` (`cmd_detach`): +- Parse `--keep-catalog`, `--json`, `--purge`; reject unknown flags with exit `2`. +- Hold `FileLock` during daemon stop, `offline_detach`, and removal of `key_file` + `state_file`; then explicitly `drop(lock)`, remove `paths.lock_file` and attempt `fs::remove_dir(&paths.child_dir)` (ignoring non-empty `Err`, plus `fs::remove_dir(&paths.opencodex_home)` only when empty) so a detached ephemeral Child leaves zero stale files in `$OPENCODEX_HOME/child/`. +- When `purge` is true, resolve `current_exe` (using `OCX_CHILD_SELF_EXE_FOR_TEST` override only under explicit test env or `std::env::current_exe()` — better: factor `detach_with_exe(args, current_exe_override: Option<&Path>)` for Rust library integration tests without any env-var backdoor in production!) and call `purge_managed_executable`, reporting `"purged": true | false` in JSON output. + +### 2.2 Conflict Markers in `daemon.rs` and `toml_surgeon.rs` + +```diff +--- a/native/ocx-child/src/daemon.rs ++++ b/native/ocx-child/src/daemon.rs +@@ -80,14 +80,14 @@ pub fn ensure_port_available_for_attach(port: u16, owned_pid: Option) -> Re + return Err(format!( +- "attach refused: 127.0.0.1:{port} is already served by foreign listener (pid {})", ++ "ocx-child-conflict:child_port_busy: 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" ++ "ocx-child-conflict:child_port_busy: attach refused: 127.0.0.1:{port} is already in use" + )); + } +``` + +```diff +--- a/native/ocx-child/src/toml_surgeon.rs ++++ b/native/ocx-child/src/toml_surgeon.rs +@@ -399,7 +399,7 @@ 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" ++ "ocx-child-conflict:child_external_provider_conflict: attach refused: an external Codex model_provider ({ext}) owns config.toml" + )); + } +``` +(Apply the exact `ocx-child-conflict:child_external_provider_conflict:` prefix to all three conflict branches in `detect_unowned_root_routing_conflict` — `external_codex_model_provider` at `toml_surgeon.rs:399`, unmarked `openai_base_url` / `experimental_realtime_ws_base_url` at `toml_surgeon.rs:421`, and unmarked user-owned `model_catalog_json` at `toml_surgeon.rs:433` — preserving `"attach refused:"` so all existing tests stay green.) + +### 2.3 `src/link/store.ts`, `src/link/child-binary-source.ts`, and `src/link/remote-plant.ts` + +1. `src/link/store.ts`: + ```diff + +export type LinkChildKind = "full" | "ephemeral"; + + + export interface LinkRecord { + ... + createdAt: string; + remotePlatform?: "posix" | "windows"; + + childKind?: LinkChildKind; + } + -const RECORD_KEYS = new Set(["id", "alias", "direction", "hostKeyFingerprint", "tunnelPort", "apiKeyId", "createdAt", "remotePlatform"]); + +const RECORD_KEYS = new Set(["id", "alias", "direction", "hostKeyFingerprint", "tunnelPort", "apiKeyId", "createdAt", "remotePlatform", "childKind"]); + ``` +2. `src/link/child-binary-source.ts`: + - `export type ChildTargetTriple = "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64" | "windows-x64"` + - `export type PlantUnavailableReason = "unsupported_target" | "artifact_not_found" | "checksum_mismatch"` + - `resolveChildBinarySource(targetTriple: ChildTargetTriple | null, options?: { env?: NodeJS.ProcessEnv; hostPlatform?: NodeJS.Platform; hostArch?: string; opencodexHome?: string; version?: string })`: + - Returns `{ ok: true, artifact: { targetTriple, bytes: Uint8Array, sizeBytes: number, sha256: string, source: "env" | "sidecar" | "local-release" | "cache" } }` or `{ ok: false, reason: PlantUnavailableReason }`. + - Artifact naming: `ocx-child-${targetTriple}${isWin ? ".exe" : ""}` (and in `OPENCODEX_CHILD_BIN_DIR` also accepts `

/${targetTriple}/ocx-child[.exe]` when `SHA256SUMS` lists either). + - `SHA256SUMS` requirement: for `env`, `sidecar`, and `cache` sources, requires a sibling `SHA256SUMS` file in the candidate directory containing `<64hex> ` (or `*`), verifies `lstatSync` is a regular non-symlink file (`<= 32 MiB`, `> 0` bytes, mode `& 0o022 === 0` on POSIX), reads the file once into `Uint8Array`, computes SHA-256 over those exact bytes, and returns `checksum_mismatch` if it diverges! For `local-release` (`native/ocx-child/target/release/ocx-child[.exe]` when `targetTriple === hostChildTargetTriple()`), verifies regular non-symlink file & permissions and hashes the read bytes directly. +3. `src/link/remote-plant.ts` & `src/link/remote-windows.ts`: + - `remoteInspectRuntimeArgv(platform)`: + - POSIX `sh -c`: emits `ocx-inspect:uname::`, evaluates candidate order identical to `REMOTE_OCX_SCRIPT`, distinguishes `full` vs `ephemeral` (selected path basename starting with `ocx-child` or `"$selected" --help` containing `(ocx-child`), and emits `ocx-inspect:kind:` + `ocx-inspect:version:` with exit `0`. + - Windows `packedPowerShellArgv`: emits `ocx-inspect:arch:<$env:PROCESSOR_ARCHITECTURE>`, checks `OCX_CANDIDATES`, and emits `ocx-inspect:kind:` + `ocx-inspect:version:` with exit `0`. + - `remotePlantChildArgv(platform, expectedBytes, expectedSha256)`: + - POSIX `sh -c`: `umask 077`, checks `[ ! -L "$HOME/.cache/opencodex" ] && [ ! -L "$HOME/.cache/opencodex/bin" ] && [ ! -L "$HOME/.cache/opencodex/bin/ocx-child" ]`, `mkdir -p "$HOME/.cache/opencodex/bin"`, `chmod 0700 "$HOME/.cache/opencodex/bin"`, writes stdin to `$tmp`, verifies `[ "$actual_bytes" = "$expected_bytes" ]`, verifies SHA-256 via `sha256sum` or `shasum -a 256` when available, `chmod 0700 "$tmp"`, `mv -f "$tmp" "$dest"`, and runs `"$dest" --version`. + - Windows `packedPowerShellArgv` (`< 8000` chars): inside `NATIVE_PROCESS` (`OcxJob`), resolves `$dir = if($env:LOCALAPPDATA){"$env:LOCALAPPDATA\opencodex\bin"}else{"$env:USERPROFILE\.cache\opencodex\bin"}`, refuses `ReparsePoint` on `$dir` or `$dest`, reads `[Console]::OpenStandardInput()` bounded to `expectedBytes + 1`, checks exact length and `[Security.Cryptography.SHA256]` lowercase hex digest against `expectedSha256`, moves atomically to `$dest`, and runs `[OcxJob]::Run($dest, '"'+$dest+'" --version')`. + +## 3. Conditional Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- `--purge` on managed path vs foreign path vs symlink: Rust integration test invokes `detach_with_exe` on `.cache/opencodex/bin/ocx-child` (deletes binary + empty `child` dir), `/tmp/custom/ocx-child` (preserves binary), and symlinked `.cache/opencodex/bin/ocx-child` (refuses unlink). +- `SHA256SUMS` mismatch & symlink in `child-binary-source.ts`: unit test writes tampered `SHA256SUMS` -> observes `{ ok: false, reason: "checksum_mismatch" }`; writes symlink -> refused. +- `child_port_busy` & `child_external_provider_conflict`: Rust test binds a dummy TCP listener on `listen_port` or writes `model_provider = "custom"` -> observes exact `ocx-child-conflict:...` stderr prefix and untouched `config.toml`. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md b/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md new file mode 100644 index 00000000000..59516c624b4 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md @@ -0,0 +1,103 @@ +# Work-Phase 2 (`wp2`): Home Management API — Inspection, SSH Plant & Rollback, `childKind` Projection, and `DELETE --purge` + +> Depends on: `000_roadmap.md`, `010_wp1_rust_purge_and_conflicts.md`. Consumed types: `LinkChildKind`, `ChildTargetTriple`, `RemoteRuntimeKind`, `ChildBinarySource`, `inspectRemoteChildRuntime`, `remotePlantChildArgv`, `remotePurgePlantedChildArgv`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `src/server/management/link-route-state.ts` | Extend `PendingLinkHost` and `ConfirmedLinkHost` with optional `runtimeKind`, `targetTriple`, `plantable`, `binarySizeBytes`, `plantUnavailableReason` | +| MODIFY | `src/server/management/link-enrollment.ts` | Extend `probe` and `confirmHost` with `allowMissing?: boolean`; extend `apply` with `plant?: boolean`, streaming verified `ocx-child` bytes over SSH `stdin`, rolling back newly planted binary on failure, persisting `childKind`, and mapping `child_port_busy` / `child_external_provider_conflict` to `409` | +| MODIFY | `src/link/status-projection.ts` | Project optional `childKind?: "full" | "ephemeral"` on `LinkStatusDto["links"][number]` | +| MODIFY | `src/server/management/link-routes.ts` | Extend `GET /api/link/status` (`homeCodexSettingsDigest` on `gui-session`) and `DELETE /api/link/:id` (`{ force?: boolean, purge?: boolean }`, passing `["disconnect", "--purge"]` when `purge === true && record.childKind === "ephemeral"`) | +| MODIFY | `tests/server/link-enrollment-ephemeral-child.test.ts` | Add server route tests for `confirm-host` (`allowMissing: true` across `missing`/`ephemeral`/`full`), `apply` (`plant: true` success, missing `plant` refusal, post-plant failure rollback, `child_port_busy`, `child_external_provider_conflict`), `status` (`childKind` + `homeCodexSettingsDigest`), and `DELETE` (`purge: true` vs default vs `force+purge` `400`) | +| MODIFY | `tests/clients/link-ocx-child-e2e.test.ts` | Add real-binary E2E case streaming compiled `ocx-child` into a bare Child `$HOME/.cache/opencodex/bin/ocx-child`, enrolling, relaying, and running `disconnect --purge` to verify zero leftover binary or `child/` directory | + +## 2. Diff-Level Specifications + +### 2.1 `src/server/management/link-route-state.ts` + +```diff +--- a/src/server/management/link-route-state.ts ++++ b/src/server/management/link-route-state.ts +@@ -18,10 +18,16 @@ export interface PendingLinkHost { + probedAt: number; + sshLatencyMs?: number; + remotePlatform?: "posix" | "windows"; ++ runtimeKind?: "full" | "ephemeral" | "missing"; ++ targetTriple?: import("../../link/child-binary-source").ChildTargetTriple | null; + } + + export interface ConfirmedLinkHost extends PendingLinkHost { +- ocxVersion: string; ++ ocxVersion: string | null; ++ plantable?: boolean; ++ binarySizeBytes?: number; ++ plantUnavailableReason?: import("../../link/child-binary-source").PlantUnavailableReason; + } +``` + +### 2.2 `src/server/management/link-enrollment.ts` + +1. **`probe`**: + - Accept `{ alias }`, `{ alias, includeVersion }`, or `{ alias, includeVersion, allowMissing }` (`includeVersion` and `allowMissing` must be `boolean` when present). + - When `includeVersion === true && allowMissing !== true`: unchanged legacy behavior (`remoteOcxArgv(["--version"], remotePlatform)` -> `{ alias, fingerprint, keyType, ocxVersion }`). + - When `includeVersion === true && allowMissing === true`: run `inspectRemoteChildRuntime(runner, alias, path, remotePlatform)`. + - If `runtimeKind === "full"` or `"ephemeral"`: verify `MIN_REMOTE_OCX` (`>= 2.66.0`, else `409 remote_ocx_outdated`), store `runtimeKind` and `targetTriple` on `PendingLinkHost`, and return `{ alias, fingerprint, keyType, ocxVersion, runtimeKind, targetTriple, plantable: false }`. + - If `runtimeKind === "missing"`: resolve `resolveChildBinarySource(targetTriple)`, store on `PendingLinkHost`, and return `{ alias, fingerprint, keyType, ocxVersion: null, runtimeKind: "missing", targetTriple, plantable, ...(binarySizeBytes !== undefined ? { binarySizeBytes } : {}), ...(plantUnavailableReason ? { plantUnavailableReason } : {}) }`. + +2. **`confirmHost`**: + - Accept `{ alias, fingerprint }` or `{ alias, fingerprint, allowMissing }`. + - When `allowMissing !== true`: preserve exact legacy `remoteOcxArgv(["--version"], remotePlatform)` call and exact `{ alias, fingerprint, ocxVersion }` JSON response! + - When `allowMissing === true`: run `inspectRemoteChildRuntime(runner, alias, path, remotePlatform)`: + - If `runtimeKind !== "missing"`: check `MIN_REMOTE_OCX`, pin host in `known_hosts`, store `ConfirmedLinkHost` with `runtimeKind`, `targetTriple`, `ocxVersion`, `plantable: false`, and return `{ alias, fingerprint, ocxVersion, runtimeKind, targetTriple, plantable: false }`. + - If `runtimeKind === "missing"`: resolve `resolveChildBinarySource(targetTriple)`, pin host in `known_hosts`, store `ConfirmedLinkHost` with `ocxVersion: null, runtimeKind: "missing", targetTriple, plantable, binarySizeBytes, plantUnavailableReason`, and return that object. + +3. **`apply`**: + - Accept `alias`, optional `fingerprint`, and optional `plant?: boolean`. + - If `confirmed.runtimeKind === "missing"` and `body.plant !== true`: return `fail("remote_ocx_missing", "ocx was not found on the remote host; confirm planting ocx-child before applying.", 409)`. + - Track `let plantedFreshBinary = false`. + - When `body.plant === true && confirmed.runtimeKind === "missing"`: + - `phase = "remote_plant"`; re-resolve `resolveChildBinarySource(confirmed.targetTriple ?? null)` (reading verified `Uint8Array` bytes + `sha256` right before streaming). If `!source.ok`, return `fail("remote_plant_unavailable", "No verified ocx-child binary is available for this host architecture.", 409, source.reason)`. + - Run `runner.run(buildExecArgv({ alias, argv: remotePlantChildArgv(remotePlatform, source.artifact.sizeBytes, source.artifact.sha256), knownHostsFile: knownHosts }), { stdin: source.artifact.bytes, timeoutMs: Math.max(budget, 60_000) })`. + - If nonzero exit, return `fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshFailureHint(plantRes.stderr))`. + - Set `plantedFreshBinary = true`. + - Extend `failed(response)` helper so whenever `plantedFreshBinary === true`, after `compensateNewLink` revokes any issued key and stops the tunnel, Home runs best-effort `run(remotePurgePlantedChildArgv(remotePlatform))` (which invokes `ocx-child detach --purge` if state exists and removes the newly planted managed binary) so failed enrollments leave the bare host untouched! + - Conflict mapping in `apply`: + - During `child_preflight` for `ephemeralChild`: when `before.code === 0` (or a TCP listener probe answers on `childPort`), return `fail("child_port_busy", "Port " + childPort + " is already in use on the Child; no Child files were modified.", 409)` (before any key is issued!). + - During `remote_connect`: if `connected.code !== 0`, inspect `classifyChildAttachConflict(connected.stderr)`: + - `"child_port_busy"` -> compensate (and purge if freshly planted; `remoteMayBeConnected = false` because `ocx-child` exited before writing state/files!) and return `fail("child_port_busy", "The Child's target port is already in use; no Child files were modified.", 409, sshFailureHint(connected.stderr))`. + - `"child_external_provider_conflict"` -> compensate (`remoteMayBeConnected = false` because `detect_unowned_root_routing_conflict` runs before journal/config writes!) and return `fail("child_external_provider_conflict", "The Child's ~/.codex/config.toml is already managed by another model provider or custom base URL; no Child files were modified.", 409, sshFailureHint(connected.stderr))`. + - Persist `childKind: ephemeralChild ? "ephemeral" : "full"` on hub-initiated `apply` `record` in `links.json` (leaving `POST /api/link/issue` client-initiated records without `childKind` so existing exact-store fixtures in `tests/server/link-management-routes.test.ts` stay untouched). + - Update `tests/server/link-enrollment-ephemeral-child.test.ts:192` assertion from `"child_restart_unsupported"` to `"child_port_busy"` on ephemeral foreign-listener preflight. + +### 2.3 `src/link/status-projection.ts` & `src/server/management/link-routes.ts` + +```diff +--- a/src/link/status-projection.ts ++++ b/src/link/status-projection.ts +@@ -20,6 +20,7 @@ export interface LinkStatusDto { + since: string; + reason: string | null; + tunnelPort: number; ++ childKind?: "full" | "ephemeral"; + /** Home only: what the link carried from this Child, and the compatibility verdict. */ + peer?: LinkPeerStatus; +@@ -141,6 +142,7 @@ export function projectLinkStatus( + ...stateDetails(record, byId.get(record.id), compensation), + tunnelPort: record.tunnelPort, ++ ...(record.childKind ? { childKind: record.childKind } : {}), + ...parity(record.id), + })); +``` + +In `src/server/management/link-routes.ts`: +- In `remove`: validate `body` keys `<= ["force", "purge"]`, both optional booleans, and reject `force === true && purge === true` with `400 invalid_body`. When `!force && record.direction === "hub-initiated"`, pass `purge === true && record.childKind === "ephemeral" ? ["disconnect", "--purge"] : ["disconnect"]` to `remoteOcxArgv`. +- In `GET /api/link/status`: when `ctx.principal === "gui-session"`, include `homeCodexSettingsDigest: codexSettingsDigest(projectHomeCodexSettings(ctx.config, packageVersion("0.0.0")).settings)` alongside `joinAvailable` and `joinDenied`. + +## 3. Conditional Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- Bare host `apply` without `plant: true` -> `409 remote_ocx_missing`, zero SSH writes. +- Bare host `apply` with `plant: true` + simulated `child_relay_failed` -> key revoked, tunnel stopped, and `remotePurgePlantedChildArgv` invoked to remove planted binary. +- `ocx-child` `connect` returning `ocx-child-conflict:child_external_provider_conflict` -> `409 child_external_provider_conflict` without `residual: true` and with link compensated cleanly. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md b/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md new file mode 100644 index 00000000000..e908596854d --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md @@ -0,0 +1,109 @@ +# Work-Phase 3 (`wp3`): CLI (`--plant`, `--purge`) & Dashboard `#remote` UX Across All 11 Locales + +> Depends on: `000_roadmap.md`, `010_wp1_rust_purge_and_conflicts.md`, `020_wp2_home_plant_store_and_api.md`. Consumed types: `LinkStatusDto`, `LinkChildKind`, `ChildTargetTriple`, `RemoteRuntimeKind`, `PlantUnavailableReason`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `src/cli/link-enrollment.ts` | Add `--plant` to `ocx link add`, probe/confirm with `allowMissing: true`, interactive TTY bare-host plant prompt (`Confirm — plant lightweight ocx-child () and connect? (type yes): `), non-TTY `--plant` requirement on bare hosts, and actionable messages for `child_port_busy` / `child_external_provider_conflict` / `remote_plant_*` | +| MODIFY | `src/cli/link.ts` | Update `LINK_USAGE` (keeping `src/cli/capabilities-base.ts` untouched so `skills/ocx/references/*` outside `writeScope` stays current), accept optional `childKind` in `validateStatus`, add `--purge` to `ocx link revoke` (mutually exclusive with `--force`), and emit `~/.cache/opencodex/bin/ocx-child detach` recovery guidance on `--force` when `childKind === "ephemeral"` | +| MODIFY | `gui/src/remote-link-api.ts` | Add `child_port_busy`, `child_external_provider_conflict`, `remote_plant_failed`, `remote_plant_unavailable` to `LINK_ERROR_CODES`; extend `LinkConfirmHostView`, `LinkRowWire` (`childKind`, `appliedCodexSettingsDigest`), and `RemoteLinkStatusWire` (`homeCodexSettingsDigest`) | +| NEW | `gui/src/pages/RemoteLinkSheet.tsx` | Extracted Add Child / Find Home `` sheet with Ready-to-Plant card (architecture, formatted ~0.9 MB size, zero-Node/Bun cache explanation, primary CTA `link.plant.apply`), Cached Ephemeral card (`link.ephemeralReady`), Full OpenCodex card, and unavailable-plant notice | +| NEW | `gui/src/pages/RemoteLinkDialogs.tsx` | Extracted Disconnect / Force-Remove `` with Ephemeral fast-detach explanation + `--purge` checkbox (`remoteLink.disconnect.purgeLabel`), and `~/.cache/opencodex/bin/ocx-child detach` `` for forced ephemeral removal | +| MODIFY | `gui/src/pages/RemoteLink.tsx` | Wire `allowMissing: true` on Home `confirmHost`, `plant: true` when `confirmation.runtimeKind === "missing"`, Connected Child row mode badge (`remoteLink.badge.ephemeral` vs `remoteLink.badge.full`) & parity badge (`remoteLink.parity.synced` vs `remoteLink.parity.outdated`), `purge` state in `removeLink`, and new conflict `ERROR_TKEY` mappings | +| MODIFY | `gui/src/styles-remote-link.css` | Add token-based styles for Ready-to-Plant card (`.remote-link-plant-card`), row badges (`.remote-link-badges`), and purge checkbox row (`.remote-link-purge-option`) without touching `gui/src/styles.css` | +| MODIFY | `gui/src/i18n/{en,ko,ja,zh,zh-TW,de,fr,pt,ru,tr,vi}.ts` | Add all new `link.plant.*`, `remoteLink.badge.*`, `remoteLink.parity.*`, `remoteLink.disconnect.*`, and `remoteLink.error.*` keys across all 11 locales (patched in 2–3 file batches) | +| MODIFY | `tests/cli/cli-link-enrollment.test.ts` & `tests/cli/cli-link-force.test.ts` (or `cli-link.test.ts`) | Add CLI tests for `ocx link add --plant` (TTY prompt, non-TTY refusal without `--plant`, non-TTY success with `--plant`), conflict errors, and `ocx link revoke --purge` + ephemeral `--force` recovery guidance | +| MODIFY | `gui/tests/remote-link.test.tsx` | Update Home `confirm-host` `allowMissing: true` body assertions (`lines 1027, 1046, 1048, 1111`) and add tests for Ready-to-Plant card, Cached Ephemeral card, Connected mode/parity badges, `child_port_busy` / `child_external_provider_conflict` notices, and Ephemeral Disconnect `--purge` checkbox + force-remove command | + +## 2. Diff-Level Specifications + +### 2.1 CLI (`src/cli/link-enrollment.ts` & `src/cli/link.ts`) + +- `src/cli/link-enrollment.ts`: + ```diff + const wantsJson = takeJsonFlag(args); + +const plantFlag = takeFlag(args, "--plant"); + const expected = takeOption(args, "--expect-fingerprint"); + ... + +if (command === "probe" && plantFlag) throw new CliUsageError("--plant is only for link add", usage); + ``` + - For `command === "probe"`: send `{ alias, includeVersion: true }` (unchanged). + - For `command === "add"`: send `{ alias, includeVersion: true, allowMissing: true }`. + - Parse `probe` response accepting legacy `{ alias, fingerprint, keyType, ocxVersion }` (`runtimeKind` treated as `"full"`) or extended `{ alias, fingerprint, keyType, ocxVersion: string | null, runtimeKind, targetTriple, plantable, binarySizeBytes?, plantUnavailableReason? }`. + - When `probe.runtimeKind === "missing"`: + - If `probe.plantable !== true`: throw `new Error("Cannot plant ocx-child on " + alias + " (" + (probe.targetTriple ?? "unknown target") + "): " + (probe.plantUnavailableReason ?? "artifact unavailable") + ".")`. + - Format size helper `formatSizeMb(bytes)` -> e.g. `"~0.9 MB"` (`(bytes / (1024 * 1024)).toFixed(1) + " MB"`). + - If `!isTTY && !plantFlag`: throw `new CliUsageError("Remote host has no OpenCodex runtime; pass --plant alongside --expect-fingerprint to plant lightweight ocx-child.", usage)`. + - If `!expected` (TTY): prompt `Confirm ${alias} ${probe.keyType} ${probe.fingerprint} — plant lightweight ocx-child (${sizeLabel}) and connect? (type yes): ` and set `shouldPlant = true` on `"yes"`. + - Else if `!plantFlag` (TTY with `--expect-fingerprint`): prompt `Plant lightweight ocx-child (${sizeLabel}) on ${alias} and connect? (type yes): ` and set `shouldPlant = true` on `"yes"`. + - Else (`plantFlag`): `shouldPlant = true`. + - Call `POST /api/link/confirm-host` with `{ alias, fingerprint: probe.fingerprint, ...(shouldPlant || probe.runtimeKind ? { allowMissing: true } : {}) }`. + - Call `POST /api/link/apply` with `{ alias, fingerprint: probe.fingerprint, ...(shouldPlant ? { plant: true } : {}) }`. + +- `src/cli/link.ts`: + - `validateStatus`: `assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer", "tunnel", "childKind"], ...)`, validating `candidate.childKind === undefined || candidate.childKind === "full" || candidate.childKind === "ephemeral"`. + - `runRevoke`: + - `const purge = takeFlag(args, "--purge");` + - `if (purge && force) throw new CliUsageError("--purge cannot be combined with --force", LINK_USAGE);` + - When `force`: inspect local store (`(deps.readStore ?? (() => { try { return readLinkStore(linkStorePath()); } catch { return emptyLinkStore(); } }))()`) before `DELETE` to check if `record?.childKind === "ephemeral"`; if `"ephemeral"`, set `recovery = "Run ~/.cache/opencodex/bin/ocx-child detach on the remote client."`! + - When `purge`: send `DELETE /api/link/` with `{ method: "DELETE", redirect: "error", headers: { "content-type": "application/json" }, body: JSON.stringify({ purge: true }) }`. + +### 2.2 GUI `#remote` Wire & Components (`gui/src/remote-link-api.ts`, `RemoteLink.tsx`, `RemoteLinkSheet.tsx`, `RemoteLinkDialogs.tsx`) + +- `gui/src/remote-link-api.ts`: + ```ts + export type LinkRuntimeKind = "full" | "ephemeral" | "missing"; + export interface LinkConfirmHostView { + alias: string; + fingerprint: string; + ocxVersion: string | null; + runtimeKind: LinkRuntimeKind; + targetTriple: string | null; + plantable: boolean; + binarySizeBytes: number | null; + plantUnavailableReason: string | null; + } + export interface LinkRowWire { + id: string; + alias: string; + direction: LinkWireDirection; + state: LinkWireState; + since: string; + reason: string | null; + tunnelPort: number; + childKind: "full" | "ephemeral" | null; + appliedCodexSettingsDigest: string | null; + } + export interface RemoteLinkStatusWire { + ... + homeCodexSettingsDigest: string | null; + } + ``` +- New i18n keys (added identically across all 11 `gui/src/i18n/*.ts` files): + - `link.plant.title`: EN `"Ready to plant lightweight agent"` / KO `"경량 에이전트 설치 준비됨"` + - `link.plant.meta`: EN `"{arch} · {size} · ~/.cache/opencodex/bin/ocx-child"` / KO `"{arch} · {size} · ~/.cache/opencodex/bin/ocx-child"` + - `link.plant.body`: EN `"OpenCodex is not installed on this computer yet. Plant the standalone ocx-child binary (~1 MB, no Node or Bun required) into ~/.cache/opencodex/bin over SSH and connect immediately. You can remove it cleanly when disconnecting."` / KO `"원격 컴퓨터에 아직 OpenCodex가 없습니다. Node나 Bun 없이 동작하는 단일 경량 파일(~1 MB)을 ~/.cache/opencodex/bin에 심어 즉시 연결하며, 연결 해제 시 깔끔하게 지울 수 있습니다."` + - `link.plant.apply`: EN `"Plant lightweight agent & connect"` / KO `"경량 에이전트 심고 연결"` + - `link.plant.applying`: EN `"Planting & connecting…"` / KO `"경량 에이전트 심고 연결 중…"` + - `link.plant.unavailable`: EN `"OpenCodex was not found on this host ({arch}), and no verified ocx-child binary is available locally to plant."` / KO `"원격 컴퓨터({arch})에 OpenCodex가 없으며, 이 컴퓨터에서 심을 수 있는 검증된 경량 에이전트 파일을 찾지 못했습니다."` + - `link.ephemeralReady`: EN `"Lightweight agent ready (ocx-child {version})"` / KO `"경량 에이전트 준비됨 (ocx-child {version})"` + - `remoteLink.badge.ephemeral`: EN `"Lightweight"` / KO `"경량 모듈"` + - `remoteLink.badge.full`: EN `"Full install"` / KO `"전체 설치"` + - `remoteLink.parity.synced`: EN `"Home settings synced"` / KO `"Home 설정 일치"` + - `remoteLink.parity.outdated`: EN `"Sync needed"` / KO `"동기화 필요"` + - `remoteLink.disconnect.ephemeralBody`: EN `"Disconnect {alias}? Its Codex configuration will be restored and the relay process will stop immediately (0 background processes). By default, the cached ocx-child binary stays in ~/.cache/opencodex/bin for fast reconnects."` / KO `"{alias} 연결을 해제할까요? 원격 Codex 설정이 원래대로 복원되고 백그라운드 프로세스 0개 상태로 종료됩니다. 기본적으로 빠른 재연결을 위해 경량 에이전트 파일은 캐시에 남겨둡니다."` + - `remoteLink.disconnect.purgeLabel`: EN `"Also delete the planted lightweight agent binary from the remote computer"` / KO `"심어둔 경량 에이전트 파일까지 원격 컴퓨터에서 함께 삭제"` + - `remoteLink.disconnect.purgeConfirm`: EN `"Delete & disconnect"` / KO `"파일 삭제 후 연결 해제"` + - `remoteLink.error.child_port_busy`: EN `"The target port (10100) on the Child computer is already in use by another process. No files on the Child were modified. Free the port or stop the conflicting process, then retry."` / KO `"원격 컴퓨터의 대상 포트(10100)를 이미 다른 프로세스가 사용 중입니다. 원격 파일은 변경되지 않았습니다. 포트를 비운 뒤 다시 시도하세요."` + - `remoteLink.error.child_external_provider_conflict`: EN `"~/.codex/config.toml on the Child computer is already managed by another model provider or custom base URL. No files on the Child were modified."` / KO `"원격 컴퓨터의 ~/.codex/config.toml을 이미 다른 모델 제공자나 사용자 지정 주소가 소유하고 있습니다. 원격 파일은 변경되지 않았습니다."` + - `remoteLink.error.remote_plant_failed`: EN `"Could not plant the lightweight ocx-child agent over SSH. Check remote disk space and ~/.cache permissions, then retry."` / KO `"SSH를 통해 경량 에이전트(ocx-child)를 심지 못했습니다. 원격 디스크 여유 공간과 ~/.cache 권한을 확인한 뒤 다시 시도하세요."` + - `remoteLink.error.remote_plant_unavailable`: EN `"No verified ocx-child binary is available for the remote computer's architecture."` / KO `"원격 컴퓨터 아키텍처에 맞는 검증된 경량 에이전트 파일이 없습니다."` + +## 3. Conditional Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- Ready-to-Plant modal -> clicking `Plant lightweight agent & connect` sends `POST /api/link/apply` with `{ alias, plant: true }`. +- Connected list -> row with `childKind: "ephemeral"` and matching `appliedCodexSettingsDigest === homeCodexSettingsDigest` renders `Lightweight` + `Home settings synced`; mismatched digest renders `Sync needed`. +- Ephemeral Disconnect dialog -> checking purge checkbox sends `DELETE /api/link/` with `{ purge: true }`; unchecking sends bodyless `DELETE /api/link/`; `remote_disconnect_failed` transitions to Force Remove rendering `~/.cache/opencodex/bin/ocx-child detach`. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md b/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md new file mode 100644 index 00000000000..c73f2f381f2 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md @@ -0,0 +1,39 @@ +# Work-Phase 4 (`wp4`): SoT Docs Sync, `pr-assets` UI Screenshots, Full Gates, and Stacked PR Delivery + +> Depends on: `000_roadmap.md`, `010..030`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `structure/remote-link.md` | Update present-tense contract under `## Ephemeral Rust Child (ocx-child)` to document `src/link/child-binary-source.ts`, `src/link/remote-plant.ts`, `allowMissing` host inspection, `POST /api/link/apply` (`plant: true`) SSH `stdin` planting & rollback, `--purge` managed-path removal, `LinkRecord.childKind`, and `child_port_busy` / `child_external_provider_conflict` | +| MODIFY | `docs-site/src/content/docs/guides/remote-link.md` (and matching locale pages if present) | Document zero-preinstall SSH Child onboarding (`#remote` Ready-to-Plant & `ocx link add --plant`), two-tier disconnect (`--purge`), and conflict troubleshooting | +| EXTERNAL | `origin/pr-assets` (`pr-ocx-child-ux/*.png`) | Commit and push 3 crisp `#remote` UI screenshots ONLY to `origin/pr-assets` via a temporary git worktree outside the repo tree (zero image files on `feat/ocx-child-plant-ux`) | + +## 2. Execution & Delivery Steps + +1. **Documentation & SSOT gates**: + - Update `structure/remote-link.md` naming `src/link/child-binary-source.ts`, `src/link/remote-plant.ts`, and `tests/clients/link-child-plant-foundations.test.ts`; stage new files with `git add` and verify `bun run structure:check` (`exit 0`). + - Inspect `rg -l "remote-link|ocx-child" docs-site/`, update user-facing Remote Link docs, and verify `cd docs-site && bun run build` (`exit 0`). +2. **Full Local Verification Floor**: + - `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` + - `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` + - `cd gui && bun run lint:i18n && bun run lint && bun test tests && bun run build` + - `bun run build:gui` + - `bun run typecheck` + - `bun test tests/clients/link-*.test.ts tests/server/link-*.test.ts tests/cli/cli-link*.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/ci-workflows/repo-hygiene.test.ts tests/ci-workflows/structure-ssot.test.ts tests/ci-workflows/skill-ocx.test.ts tests/lab/core-lab-boundary.test.ts tests/lab/core-link-boundary.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` + - `bun run test:changed` + - `bun run privacy:scan` +3. **Real UI Screenshot Capture & `origin/pr-assets` Upload (`C-RENDER-GROUNDING-01`)**: + - Serve built `gui/dist` locally with deterministic `/api/link/*` fixtures and render in headless browser / GOAT browser at `1280x800`, capturing into `/tmp/ocx-child-ux-shots/`: + 1. `01-ready-to-plant-modal.png` (Add Child sheet after fingerprint check showing Ready-to-Plant card + CTA) + 2. `02-connected-children-badges.png` (Connected Children list showing Lightweight / Full mode badges & Home settings synced / Sync needed parity badges) + 3. `03-ephemeral-disconnect-purge.png` (Disconnect confirmation modal with fast-detach explanation and `--purge` checkbox checked) + - Visually inspect every screenshot with `view_image` (`C-RENDER-GROUNDING-01`) to verify crisp typography, spacing, contrast, and zero clipping/i18n leaks. + - Fetch `origin/pr-assets`, check out a detached temporary worktree in `mktemp -d`, place PNGs under `pr-ocx-child-ux/`, commit, push `HEAD:refs/heads/pr-assets`, record immutable ``, and remove the temporary worktree. Verify `git status` on `feat/ocx-child-plant-ux` has zero image files! +4. **Stacked Pull Request Publication & Watch (`DEV-STACK-03`)**: + - Check `gh pr view 8237 --json state,headRefName,headRefOid`: + - If `OPEN`: verify `git merge-base --is-ancestor HEAD`, push `feat/ocx-child-plant-ux`, and open stacked PR with `gh pr create --base feat/ocx-child-ephemeral-link --body-file ` including the `DEV-STACK-03` stack table, Summary, Verification, Checklist, and embedded `https://raw.githubusercontent.com/lidge-jun/opencodex//pr-ocx-child-ux/*.png` screenshots. + - If `MERGED`: rebase cleanly onto `origin/dev`, re-verify gates, push, and open PR with `--base dev`. + - Call `goat_pull_request_link`, watch via `goat_pull_request_watch` until required exact-head checks and automated reviews complete, address any valid findings, record `cxc loop pr-record --session 01a1b2d6-b6ca-76c3-8e6c-e00f7449b965 --pr `, run `cxc loop validate`, call `goat_pull_request_unwatch`, and leave PR unmerged. + From f04f80ae1f81c17f20eecd483cf656c9446316f5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 07:59:05 +0900 Subject: [PATCH 12/50] feat(link): add ocx-child --purge, conflict codes, and SSH plant foundations --- .../010_wp1_rust_purge_and_conflicts.md | 16 ++ native/ocx-child/src/cli.rs | 61 ++++- native/ocx-child/src/daemon.rs | 4 +- native/ocx-child/src/platform.rs | 85 +++++++ native/ocx-child/src/toml_surgeon.rs | 6 +- native/ocx-child/tests/relay_and_lifecycle.rs | 95 ++++++++ scripts/test-layout/layout-clients.json | 3 +- src/link/child-binary-source.ts | 215 ++++++++++++++++++ src/link/remote-plant.ts | 201 ++++++++++++++++ src/link/remote-windows.ts | 39 +++- src/link/store.ts | 6 +- .../link-child-plant-foundations.test.ts | 213 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- 13 files changed, 931 insertions(+), 16 deletions(-) create mode 100644 src/link/child-binary-source.ts create mode 100644 src/link/remote-plant.ts create mode 100644 tests/clients/link-child-plant-foundations.test.ts diff --git a/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md b/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md index c531a3e97bd..338b39a1f9b 100644 --- a/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md +++ b/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md @@ -179,3 +179,19 @@ In `native/ocx-child/src/cli.rs` (`cmd_detach`): - `SHA256SUMS` mismatch & symlink in `child-binary-source.ts`: unit test writes tampered `SHA256SUMS` -> observes `{ ok: false, reason: "checksum_mismatch" }`; writes symlink -> refused. - `child_port_busy` & `child_external_provider_conflict`: Rust test binds a dummy TCP listener on `listen_port` or writes `model_provider = "custom"` -> observes exact `ocx-child-conflict:...` stderr prefix and untouched `config.toml`. +## 4. `wp1` P-Phase Continuity & Architect Reflection Amendments (`01a1b2fe-fb7d-7f00-b3c9-756fbf5e0e0c`) + +- **Continuity from `wp0` D**: Resumed from `wp0` D conclusion ("Verified wp0 docs-only roadmap; next cycle wp1 starts foundations"). +- **Architect `01a1b2fe-fb7d-7f00-b3c9-756fbf5e0e0c` verdict**: `ALIGNED` on `W1-ARCH-01..W1-ARCH-03`. +- **Folded refinements for `B` implementation**: + 1. `src/link/store.ts` (`parseRecord`): validate `raw.childKind === undefined || raw.childKind === "full" || raw.childKind === "ephemeral"` and preserve `childKind` on the returned `LinkRecord` so `writeLinkStore` roundtrips it cleanly. + 2. `native/ocx-child/src/platform.rs`: verify `fs::symlink_metadata(exe_path)` is an existing regular file (`file_type().is_file()`), and on Windows pass `OCX_PURGE_TARGET` via `.env("OCX_PURGE_TARGET", &trashed)` to `cmd.exe /d /c "ping -n 2 127.0.0.1 >nul & del /f /q \"%OCX_PURGE_TARGET%\""` with `CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW | DETACHED_PROCESS`. + 3. `src/link/child-binary-source.ts`: verify `lstatSync` non-symlink status on candidate directory, `SHA256SUMS` (`<= 256 KiB`), and binary (`<= 32 MiB`, `> 0`, POSIX `(mode & 0o022) === 0`). + 4. `src/link/remote-plant.ts` & `src/link/remote-windows.ts`: include `remotePurgePlantedChildArgv(platform)` (invoking `detach --purge` when runnable and unlinking the managed binary after non-symlink/non-ReparsePoint checks) and `classifyChildAttachConflict(stderr)` matching `ocx-child-conflict:child_port_busy:` and `ocx-child-conflict:child_external_provider_conflict:`. + +## 5. `wp1` A-Phase Audit Fold-Backs (`01a1b309-5460-7c93-855b-d59ef8182224`, `GO-WITH-FIXES`) + +1. **`B1` (Folded — Windows `OcxJob` silent breakaway & `.old` sweep)**: Because `OcxJob` sets `JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, `purge_managed_executable` on Windows spawns `cmd.exe` with `CREATE_NO_WINDOW | DETACHED_PROCESS` (silent breakaway succeeds automatically; if a caller has explicit breakaway only, fallback is automatic), and both `windowsOcxArgv` (on `disconnect --purge`) and `windowsPurgePlantedChildArgv` sweep `.ocx-child.purge.*.old` in the managed bin dir after `Invoke-Native` exits. +2. **`B2` (Folded — Managed ancestor symlink/reparse checks)**: Canonicalize `user_home` / `local_appdata` root once, then reject symlinks/reparse points on every managed subpath component (`.cache`, `.cache/opencodex`, `.cache/opencodex/bin`, `ocx-child` and `opencodex`, `opencodex/bin`, `ocx-child.exe`) in Rust `is_managed_plant_executable` as well as POSIX and Windows plant/purge scripts. +3. **`B3` (Folded — `SHA256SUMS` fail-closed precedence)**: In `resolveChildBinarySource`, when a candidate binary exists in `env`, `sidecar`, or `cache` and fails `SHA256SUMS` (`checksum_mismatch`) or symlink/permission checks (including POSIX `(mode & 0o022) === 0` on `SHA256SUMS`), return `{ ok: false, reason: "checksum_mismatch" }` immediately without falling through to `local-release`. Also include `windowsPurgePlantedChildArgv()` in the `<= 8000` encoded-command budget test. + diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index ddac8270618..14bcb7747b0 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -1,5 +1,6 @@ use std::fs; use std::io::{self, Read}; +use std::path::{Path, PathBuf}; use std::time::Duration; use serde::Deserialize; @@ -15,8 +16,9 @@ use crate::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, write_private_atomic, write_regular_atomic, + FileLock, LockAcquireError, PurgeExecutableOutcome, SecretBytes, format_iso8601_utc, + now_epoch_ms, purge_managed_executable, random_hex, resolve_codex_home, + resolve_opencodex_home, resolve_user_home, write_private_atomic, write_regular_atomic, }; use crate::relay::{ RelayCredentials, fetch_home_authenticated, post_child_parity_report, serve_foreground, @@ -971,10 +973,26 @@ 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"); + detach_with_exe(args, None) +} + +pub fn detach_with_exe(args: &[String], current_exe_override: Option<&Path>) -> u8 { + let mut keep_catalog = false; + let mut json_mode = false; + let mut purge = false; + for arg in args { + match arg.as_str() { + "--keep-catalog" => keep_catalog = true, + "--json" => json_mode = true, + "--purge" => purge = true, + _ => { + eprintln!("Usage: ocx-child detach [--keep-catalog] [--purge] [--json]"); + return 2; + } + } + } let paths = current_paths(); - let _lock = match FileLock::acquire_blocking(&paths.lock_file) { + let lock = match FileLock::acquire_blocking(&paths.lock_file) { Ok(l) => l, Err(_) => { eprintln!("detach failed: child lifecycle lock busy"); @@ -1002,11 +1020,40 @@ fn cmd_detach(args: &[String]) -> u8 { }; let _ = fs::remove_file(&paths.key_file); let _ = fs::remove_file(&paths.state_file); + drop(lock); + let _ = fs::remove_file(&paths.lock_file); + let _ = fs::remove_dir(&paths.child_dir); + let _ = fs::remove_dir(&paths.opencodex_home); + let mut purged = false; + if purge { + let exe = match current_exe_override { + Some(p) => p.to_path_buf(), + None => match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + eprintln!("detach --purge failed resolving current executable: {e}"); + return 1; + } + }, + }; + let user_home = resolve_user_home(); + let local_appdata = std::env::var_os("LOCALAPPDATA") + .filter(|v| !v.is_empty()) + .map(PathBuf::from); + match purge_managed_executable(&exe, &user_home, local_appdata.as_deref()) { + Ok(PurgeExecutableOutcome::Removed) => purged = true, + Ok(PurgeExecutableOutcome::SkippedUnmanagedPath) => purged = false, + Err(e) => { + eprintln!("detach --purge failed: {e}"); + return 1; + } + } + } if json_mode { - println!("{{\"restored\":true,\"outcome\":\"{outcome:?}\",\"daemonRunning\":false}}"); + println!("{{\"restored\":true,\"outcome\":\"{outcome:?}\",\"daemonRunning\":false,\"purged\":{purged}}}"); } else { println!( - "Disconnected ephemeral Remote Link Child ({outcome:?}); zero background daemon running." + "Disconnected ephemeral Remote Link Child ({outcome:?}, purged={purged}); zero background daemon running." ); } 0 diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index d1200afba05..817f5779443 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -78,13 +78,13 @@ pub fn ensure_port_available_for_attach(port: u16, owned_pid: Option) -> Re return Ok(()); } return Err(format!( - "attach refused: 127.0.0.1:{port} is already served by foreign listener (pid {})", + "ocx-child-conflict:child_port_busy: 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" + "ocx-child-conflict:child_port_busy: attach refused: 127.0.0.1:{port} is already in use" )); } Ok(()) diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 3b6d689d3cd..f09f7c29253 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -878,3 +878,88 @@ pub fn resolve_codex_home() -> PathBuf { } resolve_user_home().join(".codex") } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PurgeExecutableOutcome { + Removed, + SkippedUnmanagedPath, +} + +fn reject_managed_subpath_symlinks(root: &Path, components: &[&str]) -> Result { + let mut cur = root.to_path_buf(); + for comp in components { + cur.push(comp); + reject_symlink(&cur)?; + } + Ok(cur) +} + +pub fn is_managed_plant_executable( + exe_path: &Path, + user_home: &Path, + local_appdata: Option<&Path>, +) -> Result { + reject_symlink(exe_path)?; + if !fs::symlink_metadata(exe_path).is_ok_and(|m| m.file_type().is_file()) { + return Ok(false); + } + let Some(parent) = exe_path.parent() else { + return Ok(false); + }; + reject_symlink(parent)?; + let expected_name = if cfg!(windows) { "ocx-child.exe" } else { "ocx-child" }; + if exe_path.file_name().and_then(|n| n.to_str()) != Some(expected_name) { + return Ok(false); + } + let canon_parent = fs::canonicalize(parent) + .map_err(|e| format!("failed canonicalizing {}: {e}", parent.display()))?; + let cache_bin = reject_managed_subpath_symlinks(user_home, &[".cache", "opencodex", "bin"])?; + if fs::canonicalize(&cache_bin).ok().as_ref() == Some(&canon_parent) { + return Ok(true); + } + if let Some(appdata) = local_appdata { + let win_bin = reject_managed_subpath_symlinks(appdata, &["opencodex", "bin"])?; + if fs::canonicalize(&win_bin).ok().as_ref() == Some(&canon_parent) { + return Ok(true); + } + } + Ok(false) +} + +pub fn purge_managed_executable( + exe_path: &Path, + user_home: &Path, + local_appdata: Option<&Path>, +) -> Result { + if !is_managed_plant_executable(exe_path, user_home, local_appdata)? { + return Ok(PurgeExecutableOutcome::SkippedUnmanagedPath); + } + #[cfg(unix)] + { + fs::remove_file(exe_path) + .map_err(|e| format!("failed removing planted ocx-child {}: {e}", exe_path.display()))?; + let _ = exe_path.parent().map(fs::remove_dir); + let _ = exe_path.parent().and_then(Path::parent).map(fs::remove_dir); + Ok(PurgeExecutableOutcome::Removed) + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const DETACHED_PROCESS: u32 = 0x0000_0008; + let parent = exe_path.parent().ok_or_else(|| "missing parent".to_owned())?; + let trashed = parent.join(format!(".ocx-child.purge.{}.old", std::process::id())); + let _ = fs::remove_file(&trashed); + fs::rename(exe_path, &trashed) + .map_err(|e| format!("failed renaming planted ocx-child for purge: {e}"))?; + let _ = Command::new("cmd.exe") + .args(["/d", "/c", "ping -n 2 127.0.0.1 >nul & del /f /q \"%OCX_PURGE_TARGET%\""]) + .env("OCX_PURGE_TARGET", &trashed) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) + .spawn(); + Ok(PurgeExecutableOutcome::Removed) + } +} diff --git a/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs index 075fda85490..b90e83d1e4e 100644 --- a/native/ocx-child/src/toml_surgeon.rs +++ b/native/ocx-child/src/toml_surgeon.rs @@ -397,7 +397,7 @@ pub fn external_codex_model_provider(content: &str) -> Option { 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" + "ocx-child-conflict:child_external_provider_conflict: attach refused: an external Codex model_provider ({ext}) owns config.toml" )); } let lf = apply_eol(content, "\n"); @@ -419,7 +419,7 @@ pub fn detect_unowned_root_routing_conflict(content: &str) -> Result<(), String> 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" + "ocx-child-conflict:child_external_provider_conflict: attach refused: user-owned root {key} already exists in config.toml" )); } } @@ -431,7 +431,7 @@ pub fn detect_unowned_root_routing_conflict(content: &str) -> Result<(), String> 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" + "ocx-child-conflict:child_external_provider_conflict: attach refused: user-owned model_catalog_json already exists in config.toml" .to_owned(), ); } diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 45f3ebee069..39a37954365 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -608,3 +608,98 @@ 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); } + +#[test] +fn detach_purge_removes_only_managed_binary_refuses_symlinks_and_emits_conflict_codes() { + let bin = Path::new(env!("CARGO_BIN_EXE_ocx-child")); + let scratch = std::env::temp_dir().join(format!( + "ocx-child-purge-test-{}-{}", + std::process::id(), + ocx_child::platform::now_epoch_ms() + )); + let fake_home = scratch.join("home"); + let ocx_home = fake_home.join(".opencodex"); + let codex_home = fake_home.join(".codex"); + fs::create_dir_all(&codex_home).unwrap(); + + // 1. Conflict code: child_port_busy before Home contact + let held = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let held_port = held.local_addr().unwrap().port(); + let cred = r#"{"apiKeyId":"key_1","key":"ocx_data_0123456789abcdef0123456789abcdef01234567"}"#; + let (rc_busy, _, err_busy) = run_bin( + &ocx_home, + &codex_home, + held_port, + &["connect", "--link", "--key-stdin", "--link-id", "lnk_0123456789abcdef", "--tunnel-port", "24500"], + Some(cred.as_bytes()), + ); + assert_eq!(rc_busy, 1); + assert!(err_busy.contains("ocx-child-conflict:child_port_busy:"), "got: {err_busy}"); + drop(held); + + // 2. Conflict code: child_external_provider_conflict across all 3 root conflict branches + for sample in [ + "model_provider = \"custom\"\n[model_providers.custom]\nbase_url = \"https://api.example.com/v1\"\n", + "openai_base_url = \"https://custom.example.com/v1\"\n", + "model_catalog_json = \"/tmp/custom-catalog.json\"\n", + ] { + let err = ocx_child::toml_surgeon::detect_unowned_root_routing_conflict(sample).unwrap_err(); + assert!(err.contains("ocx-child-conflict:child_external_provider_conflict:"), "got: {err}"); + } + + // 3. Foreign executable path: --purge keeps foreign binary intact + let foreign_dir = scratch.join("foreign"); + fs::create_dir_all(&foreign_dir).unwrap(); + let exe_name = if cfg!(windows) { "ocx-child.exe" } else { "ocx-child" }; + let foreign_exe = foreign_dir.join(exe_name); + fs::copy(bin, &foreign_exe).unwrap(); + let out_foreign = Command::new(&foreign_exe) + .args(["detach", "--purge", "--json"]) + .env("HOME", &fake_home) + .env("USERPROFILE", &fake_home) + .env("OPENCODEX_HOME", &ocx_home) + .env("CODEX_HOME", &codex_home) + .output() + .unwrap(); + assert_eq!(out_foreign.status.code(), Some(0)); + assert!(String::from_utf8_lossy(&out_foreign.stdout).contains("\"purged\":false")); + assert!(foreign_exe.exists(), "foreign binary must never be deleted by --purge"); + + // 4. Managed plant path: --purge deletes planted binary and empty child directory + let managed_bin_dir = fake_home.join(".cache").join("opencodex").join("bin"); + fs::create_dir_all(&managed_bin_dir).unwrap(); + let planted_exe = managed_bin_dir.join(exe_name); + fs::copy(bin, &planted_exe).unwrap(); + let out_managed = Command::new(&planted_exe) + .args(["disconnect", "--purge", "--json"]) + .env("HOME", &fake_home) + .env("USERPROFILE", &fake_home) + .env("OPENCODEX_HOME", &ocx_home) + .env("CODEX_HOME", &codex_home) + .output() + .unwrap(); + assert_eq!(out_managed.status.code(), Some(0)); + assert!(String::from_utf8_lossy(&out_managed.stdout).contains("\"purged\":true")); + #[cfg(unix)] + assert!(!planted_exe.exists(), "managed planted binary must be removed"); + assert!(!ocx_home.join("child").exists(), "empty child state dir must be removed"); + + // 5. Ancestor symlink guard on Unix: refuses when .cache/opencodex is a symlink to foreign_dir + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let sym_home = scratch.join("sym-home"); + fs::create_dir_all(sym_home.join(".cache")).unwrap(); + let target_root = scratch.join("real-target"); + fs::create_dir_all(target_root.join("bin")).unwrap(); + let target_exe = target_root.join("bin").join("ocx-child"); + fs::copy(bin, &target_exe).unwrap(); + symlink(&target_root, sym_home.join(".cache").join("opencodex")).unwrap(); + let via_sym = sym_home.join(".cache").join("opencodex").join("bin").join("ocx-child"); + let sym_err = ocx_child::platform::purge_managed_executable(&via_sym, &sym_home, None).unwrap_err(); + assert!(sym_err.contains("symlink"), "expected symlink refusal, got: {sym_err}"); + assert!(target_exe.exists(), "target behind ancestor symlink must remain intact"); + } + + let _ = fs::remove_dir_all(&scratch); +} diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 4db1d11ed7a..e6d867c3bd5 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -176,5 +176,6 @@ "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" + "link-ocx-child-e2e.test.ts": "clients", + "link-child-plant-foundations.test.ts": "clients" } diff --git a/src/link/child-binary-source.ts b/src/link/child-binary-source.ts new file mode 100644 index 00000000000..56446e6db14 --- /dev/null +++ b/src/link/child-binary-source.ts @@ -0,0 +1,215 @@ +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { packageVersion } from "../lib/package-version"; + +export const CHILD_TARGET_TRIPLES = [ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "linux-arm64", + "windows-x64", +] as const; + +export type ChildTargetTriple = typeof CHILD_TARGET_TRIPLES[number]; +export type ChildBinarySourceKind = "env" | "sidecar" | "local-release" | "cache"; +export type PlantUnavailableReason = "unsupported_target" | "artifact_not_found" | "checksum_mismatch"; + +export interface VerifiedChildBinaryArtifact { + readonly targetTriple: ChildTargetTriple; + readonly bytes: Uint8Array; + readonly sizeBytes: number; + readonly sha256: string; + readonly source: ChildBinarySourceKind; +} + +export type ChildBinaryResolution = + | { readonly ok: true; readonly artifact: VerifiedChildBinaryArtifact } + | { readonly ok: false; readonly reason: PlantUnavailableReason }; + +export interface ResolveChildBinaryOptions { + readonly env?: Record; + readonly hostPlatform?: NodeJS.Platform; + readonly hostArch?: string; + readonly opencodexHome?: string; + readonly version?: string; + readonly execPath?: string; + readonly repoRoot?: string; + readonly allowLocalRelease?: boolean; +} + +const MAX_CHILD_BINARY_BYTES = 32 * 1024 * 1024; +const MAX_SHA256SUMS_BYTES = 256 * 1024; +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export function isChildTargetTriple(value: unknown): value is ChildTargetTriple { + return typeof value === "string" && (CHILD_TARGET_TRIPLES as readonly string[]).includes(value); +} + +export function hostChildTargetTriple( + platform: NodeJS.Platform = process.platform, + architecture: string = process.arch, +): ChildTargetTriple | null { + if (platform === "darwin" && architecture === "arm64") return "darwin-arm64"; + if (platform === "darwin" && architecture === "x64") return "darwin-x64"; + if (platform === "linux" && architecture === "x64") return "linux-x64"; + if (platform === "linux" && architecture === "arm64") return "linux-arm64"; + if (platform === "win32" && architecture === "x64") return "windows-x64"; + return null; +} + +export function childArtifactFilename(targetTriple: ChildTargetTriple): string { + return targetTriple.startsWith("windows-") ? `ocx-child-${targetTriple}.exe` : `ocx-child-${targetTriple}`; +} + +export function childPlainExecutableName(targetTriple: ChildTargetTriple): string { + return targetTriple.startsWith("windows-") ? "ocx-child.exe" : "ocx-child"; +} + +function isSafeDirectory(dirPath: string): boolean { + try { + const st = lstatSync(dirPath); + return st.isDirectory() && !st.isSymbolicLink(); + } catch { + return false; + } +} + +function readSafeRegularFile(filePath: string, maxBytes: number, hostPlatform: NodeJS.Platform): Uint8Array | null { + let st; + try { + st = lstatSync(filePath); + } catch { + return null; + } + if (!st.isFile() || st.isSymbolicLink()) return null; + if (st.size <= 0 || st.size > maxBytes) return null; + if (hostPlatform !== "win32" && (st.mode & 0o022) !== 0) return null; + let buf: Buffer; + try { + buf = readFileSync(filePath); + } catch { + return null; + } + if (buf.byteLength <= 0 || buf.byteLength > maxBytes) return null; + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +} + +export function parseSha256Sums(text: string): Map { + const entries = new Map(); + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = /^([0-9A-Fa-f]{64})\s+\*?([^\s/\\]+)$/.exec(line); + if (!match) continue; + entries.set(match[2]!, match[1]!.toLowerCase()); + } + return entries; +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function verifyManifestCandidate( + candidatePath: string, + targetTriple: ChildTargetTriple, + source: ChildBinarySourceKind, + hostPlatform: NodeJS.Platform, +): ChildBinaryResolution | null { + let rawStat; + try { + rawStat = lstatSync(candidatePath); + } catch { + return null; + } + const dir = dirname(candidatePath); + if (!isSafeDirectory(dir) || !rawStat.isFile() || rawStat.isSymbolicLink()) { + return { ok: false, reason: "checksum_mismatch" }; + } + const sumsBytes = readSafeRegularFile(join(dir, "SHA256SUMS"), MAX_SHA256SUMS_BYTES, hostPlatform); + if (!sumsBytes) return { ok: false, reason: "checksum_mismatch" }; + const sums = parseSha256Sums(new TextDecoder("utf-8").decode(sumsBytes)); + const expected = sums.get(basename(candidatePath)); + if (!expected || !SHA256_HEX.test(expected)) return { ok: false, reason: "checksum_mismatch" }; + const bytes = readSafeRegularFile(candidatePath, MAX_CHILD_BINARY_BYTES, hostPlatform); + if (!bytes) return { ok: false, reason: "checksum_mismatch" }; + const actual = sha256Hex(bytes); + if (actual !== expected) return { ok: false, reason: "checksum_mismatch" }; + return { + ok: true, + artifact: { targetTriple, bytes, sizeBytes: bytes.byteLength, sha256: actual, source }, + }; +} + +export function resolveChildBinarySource( + targetTriple: ChildTargetTriple | null, + options: ResolveChildBinaryOptions = {}, +): ChildBinaryResolution { + if (!targetTriple || !isChildTargetTriple(targetTriple)) return { ok: false, reason: "unsupported_target" }; + const env = options.env ?? process.env; + const hostPlatform = options.hostPlatform ?? process.platform; + const hostArch = options.hostArch ?? process.arch; + const hostTriple = hostChildTargetTriple(hostPlatform, hostArch); + const artifactFile = childArtifactFilename(targetTriple); + const plainExe = childPlainExecutableName(targetTriple); + const repoRoot = options.repoRoot ?? resolve(import.meta.dir, "..", ".."); + + const envDir = env.OPENCODEX_CHILD_BIN_DIR?.trim(); + if (envDir) { + if (!isSafeDirectory(envDir)) { + try { lstatSync(envDir); return { ok: false, reason: "checksum_mismatch" }; } catch { /* absent */ } + } else { + const envCandidates = [ + join(envDir, artifactFile), + join(envDir, targetTriple, plainExe), + ...(targetTriple === hostTriple ? [join(envDir, plainExe)] : []), + ]; + for (const candidate of envCandidates) { + const res = verifyManifestCandidate(candidate, targetTriple, "env", hostPlatform); + if (res) return res; + } + } + } + + const execDir = dirname(options.execPath ?? process.execPath); + const sidecarCandidates = [ + join(repoRoot, "native-bin", targetTriple, plainExe), + join(repoRoot, "native-bin", artifactFile), + join(execDir, artifactFile), + join(execDir, "native-bin", targetTriple, plainExe), + ]; + for (const candidate of sidecarCandidates) { + const res = verifyManifestCandidate(candidate, targetTriple, "sidecar", hostPlatform); + if (res) return res; + } + + if (options.allowLocalRelease !== false && targetTriple === hostTriple) { + const localRelease = join(repoRoot, "native", "ocx-child", "target", "release", plainExe); + if (existsSync(localRelease)) { + const relDir = dirname(localRelease); + const bytes = isSafeDirectory(relDir) ? readSafeRegularFile(localRelease, MAX_CHILD_BINARY_BYTES, hostPlatform) : null; + if (!bytes) return { ok: false, reason: "checksum_mismatch" }; + return { + ok: true, + artifact: { + targetTriple, + bytes, + sizeBytes: bytes.byteLength, + sha256: sha256Hex(bytes), + source: "local-release", + }, + }; + } + } + + const ocxHome = options.opencodexHome ?? env.OPENCODEX_HOME?.trim() ?? join(env.HOME || homedir(), ".opencodex"); + const version = options.version ?? packageVersion("0.0.0"); + const cachedCandidate = join(ocxHome, "cache", "ocx-child", version, artifactFile); + const cacheRes = verifyManifestCandidate(cachedCandidate, targetTriple, "cache", hostPlatform); + if (cacheRes) return cacheRes; + + return { ok: false, reason: "artifact_not_found" }; +} + diff --git a/src/link/remote-plant.ts b/src/link/remote-plant.ts new file mode 100644 index 00000000000..b7a2e11764a --- /dev/null +++ b/src/link/remote-plant.ts @@ -0,0 +1,201 @@ +import { CHILD_TARGET_TRIPLES, type ChildTargetTriple } from "./child-binary-source"; +import { windowsInspectRuntimeArgv, windowsPlantChildArgv, windowsPurgePlantedChildArgv } from "./remote-windows"; +import { buildExecArgv } from "./ssh-argv"; +import type { SshRunner } from "./ssh-runner"; + +export type RemoteRuntimeKind = "full" | "ephemeral" | "missing"; +export type ChildAttachConflictCode = "child_port_busy" | "child_external_provider_conflict"; + +export interface ParsedRemoteRuntimeInspection { + readonly runtimeKind: RemoteRuntimeKind; + readonly targetTriple: ChildTargetTriple | null; + readonly rawArch: string; + readonly ocxVersionLine: string | null; +} + +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export function parseTargetTriple(platform: "posix" | "windows", rawArch: string): ChildTargetTriple | null { + const trimmed = rawArch.trim(); + if (platform === "windows") { + return /^(?:AMD64|x86_64|x64)$/i.test(trimmed) ? "windows-x64" : null; + } + const sep = trimmed.indexOf(":"); + if (sep <= 0) return null; + const kernel = trimmed.slice(0, sep).trim().toLowerCase(); + const machine = trimmed.slice(sep + 1).trim().toLowerCase(); + if (kernel === "darwin") { + if (machine === "arm64" || machine === "aarch64") return "darwin-arm64"; + if (machine === "x86_64" || machine === "amd64") return "darwin-x64"; + return null; + } + if (kernel === "linux") { + if (machine === "x86_64" || machine === "amd64") return "linux-x64"; + if (machine === "aarch64" || machine === "arm64") return "linux-arm64"; + return null; + } + return null; +} + +export const POSIX_INSPECT_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 125;; esac", + "printf 'ocx-inspect:uname:%s:%s\\n' \"$(uname -s 2>/dev/null)\" \"$(uname -m 2>/dev/null)\"", + "try_inspect() {", + " kind=$1; candidate=$2", + " if [ -x \"$candidate\" ]; then", + " ver=$(\"$candidate\" --version 2>/dev/null) || return 0", + " first=$(printf '%s\\n' \"$ver\" | sed -n '1p')", + " if [ -n \"$first\" ]; then", + " printf 'ocx-inspect:kind:%s\\n' \"$kind\"", + " printf 'ocx-inspect:version:%s\\n' \"$first\"", + " exit 0", + " fi", + " fi", + "}", + "inspect_ocx_child() {", + " for candidate in \"$HOME/.local/bin/ocx-child\" \"$HOME/.cache/opencodex/bin/ocx-child\"; do", + " try_inspect ephemeral \"$candidate\"", + " done", + " remaining=$PATH", + " while :; do", + " directory=${remaining%%:*}", + " try_inspect ephemeral \"${directory:-.}/ocx-child\"", + " case \"$remaining\" in *:*) remaining=${remaining#*:};; *) break;; esac", + " done", + "}", + "if [ -f \"${OPENCODEX_HOME:-$HOME/.opencodex}/child/state.json\" ]; then", + " inspect_ocx_child", + "fi", + "for candidate in \"$HOME/.opencodex-desktop/bin/ocx\" \"/Applications/OpenCodex.app/Contents/MacOS/ocx\" \"/usr/bin/ocx\"; do", + " try_inspect full \"$candidate\"", + "done", + "remaining=$PATH", + "while :; do", + " directory=${remaining%%:*}", + " try_inspect full \"${directory:-.}/ocx\"", + " case \"$remaining\" in *:*) remaining=${remaining#*:};; *) break;; esac", + "done", + "inspect_ocx_child", + "printf 'ocx-inspect:kind:missing\\n'", + "exit 0", +].join("\n"); + +export function remoteInspectRuntimeArgv(platform: "posix" | "windows" = "posix"): string[] { + return platform === "windows" ? windowsInspectRuntimeArgv() : ["sh", "-c", POSIX_INSPECT_SCRIPT, "ocx-inspect"]; +} + +export function parseRemoteRuntimeInspection(platform: "posix" | "windows", stdout: string): ParsedRemoteRuntimeInspection | null { + let rawArch: string | null = null; + let runtimeKind: RemoteRuntimeKind | null = null; + let ocxVersionLine: string | null = null; + for (const raw of stdout.split(/\r?\n/)) { + const line = raw.trim(); + if (platform === "posix" && line.startsWith("ocx-inspect:uname:")) { + rawArch = line.slice("ocx-inspect:uname:".length); + } else if (platform === "windows" && line.startsWith("ocx-inspect:arch:")) { + rawArch = line.slice("ocx-inspect:arch:".length); + } else if (line.startsWith("ocx-inspect:kind:")) { + const kind = line.slice("ocx-inspect:kind:".length); + if (kind === "full" || kind === "ephemeral" || kind === "missing") runtimeKind = kind; + } else if (line.startsWith("ocx-inspect:version:")) { + ocxVersionLine = line.slice("ocx-inspect:version:".length).trim(); + } + } + if (rawArch === null || runtimeKind === null) return null; + if (runtimeKind !== "missing" && !ocxVersionLine) return null; + const targetTriple = parseTargetTriple(platform, rawArch); + return { runtimeKind, targetTriple: targetTriple && CHILD_TARGET_TRIPLES.includes(targetTriple) ? targetTriple : null, rawArch, ocxVersionLine }; +} + +export async function inspectRemoteChildRuntime( + runner: Pick, + alias: string, + knownHostsFile: string, + platform: "posix" | "windows", + timeoutMs = 30_000, +): Promise { + const res = await runner.run(buildExecArgv({ alias, argv: remoteInspectRuntimeArgv(platform), knownHostsFile }), { timeoutMs }); + if (res.code !== 0) return null; + return parseRemoteRuntimeInspection(platform, res.stdout); +} + +export const POSIX_PLANT_SCRIPT = [ + "case \"$(uname -s 2>/dev/null)\" in Windows*|MSYS*|MINGW*|CYGWIN*) exit 125;; esac", + "umask 077", + "expected_bytes=$1", + "expected_sha=$2", + "for p in \"$HOME/.cache\" \"$HOME/.cache/opencodex\" \"$HOME/.cache/opencodex/bin\" \"$HOME/.cache/opencodex/bin/ocx-child\"; do", + " if [ -L \"$p\" ]; then printf 'Refusing symlink in managed plant path: %s\\n' \"$p\" >&2; exit 1; fi", + "done", + "mkdir -p \"$HOME/.cache/opencodex/bin\" || exit 1", + "chmod 0700 \"$HOME/.cache/opencodex\" \"$HOME/.cache/opencodex/bin\" 2>/dev/null || true", + "dest=\"$HOME/.cache/opencodex/bin/ocx-child\"", + "tmp=\"$HOME/.cache/opencodex/bin/.ocx-child.tmp.$$\"", + "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM", + "cat > \"$tmp\" || exit 1", + "actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]') || exit 1", + "if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then", + " printf 'Planted binary size mismatch: expected %s got %s\\n' \"$expected_bytes\" \"$actual_bytes\" >&2", + " exit 1", + "fi", + "if command -v sha256sum >/dev/null 2>&1; then", + " actual_sha=$(sha256sum \"$tmp\" | awk '{print tolower($1)}')", + " if [ \"$actual_sha\" != \"$expected_sha\" ]; then printf 'Planted binary SHA-256 mismatch\\n' >&2; exit 1; fi", + "elif command -v shasum >/dev/null 2>&1; then", + " actual_sha=$(shasum -a 256 \"$tmp\" | awk '{print tolower($1)}')", + " if [ \"$actual_sha\" != \"$expected_sha\" ]; then printf 'Planted binary SHA-256 mismatch\\n' >&2; exit 1; fi", + "fi", + "chmod 0700 \"$tmp\" || exit 1", + "mv -f \"$tmp\" \"$dest\" || exit 1", + "trap - EXIT HUP INT TERM", + "if ! \"$dest\" --version >/dev/null 2>&1; then", + " rm -f \"$dest\"", + " printf 'Planted ocx-child failed --version verification\\n' >&2", + " exit 1", + "fi", + "exit 0", +].join("\n"); + +export function remotePlantChildArgv( + platform: "posix" | "windows", + expectedBytes: number, + expectedSha256: string, +): string[] { + if (!Number.isSafeInteger(expectedBytes) || expectedBytes <= 0 || expectedBytes > 32 * 1024 * 1024) { + throw new Error("invalid expectedBytes for ocx-child plant"); + } + if (!SHA256_HEX.test(expectedSha56Safe(expectedSha256))) throw new Error("invalid expectedSha256 for ocx-child plant"); + return platform === "windows" + ? windowsPlantChildArgv(expectedBytes, expectedSha256) + : ["sh", "-c", POSIX_PLANT_SCRIPT, "ocx-plant", String(expectedBytes), expectedSha256]; +} + +function expectedSha56Safe(v: string): string { return v; } + +export const POSIX_PURGE_PLANTED_SCRIPT = [ + "case \"$(uname -s 2>/dev/null)\" in Windows*|MSYS*|MINGW*|CYGWIN*) exit 125;; esac", + "for p in \"$HOME/.cache\" \"$HOME/.cache/opencodex\" \"$HOME/.cache/opencodex/bin\" \"$HOME/.cache/opencodex/bin/ocx-child\"; do", + " if [ -L \"$p\" ]; then exit 1; fi", + "done", + "dest=\"$HOME/.cache/opencodex/bin/ocx-child\"", + "if [ -f \"$dest\" ]; then", + " if [ -x \"$dest\" ]; then \"$dest\" detach --purge >/dev/null 2>&1 || true; fi", + " rm -f \"$dest\" 2>/dev/null || true", + "fi", + "rmdir \"$HOME/.cache/opencodex/bin\" \"$HOME/.cache/opencodex\" 2>/dev/null || true", + "exit 0", +].join("\n"); + +export function remotePurgePlantedChildArgv(platform: "posix" | "windows" = "posix"): string[] { + return platform === "windows" + ? windowsPurgePlantedChildArgv() + : ["sh", "-c", POSIX_PURGE_PLANTED_SCRIPT, "ocx-purge-plant"]; +} + +export function classifyChildAttachConflict(stderr: string): ChildAttachConflictCode | null { + if (stderr.includes("ocx-child-conflict:child_port_busy:")) return "child_port_busy"; + if (stderr.includes("ocx-child-conflict:child_external_provider_conflict:")) return "child_external_provider_conflict"; + return null; +} + diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index ed774ab3035..5b58c9c5bfa 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -152,12 +152,49 @@ $k=@("$u\.local\bin\ocx-child.exe","$u\.cache\opencodex\bin\ocx-child.exe");if($ $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[] { + const sweep = args.includes("--purge") ? String.raw`foreach($r in @($env:LOCALAPPDATA,$env:USERPROFILE)){if($r){foreach($s in @("$r\opencodex\bin","$r\.cache\opencodex\bin")){if([IO.Directory]::Exists($s)){Get-ChildItem -LiteralPath $s -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{}}}}}};` : ""; + const run = sweep ? `try{$c=[OcxJob]::Run($x,((@($x)+$d.args|%{Quote-Native $_}) -join ' '))}catch{Fail};${sweep}exit $c` : `Invoke-Native $x @($d.args)`; return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args }) + OCX_CANDIDATES + String.raw` -foreach($x in ($c|select -Unique)){if([IO.File]::Exists($x)-and[OcxJob]::Ok($x)){Invoke-Native $x @($d.args)}} +foreach($x in ($c|select -Unique)){if([IO.File]::Exists($x)-and[OcxJob]::Ok($x)){` + run + String.raw`}} [Console]::Error.WriteLine('No runnable ocx.exe found; install the Windows standalone archive.');exit 127 `); } +export function windowsInspectRuntimeArgv(): string[] { + return packedPowerShellArgv(NATIVE_PROCESS + OCX_CANDIDATES + String.raw` +[Console]::Out.WriteLine('ocx-inspect:arch:'+[string]$env:PROCESSOR_ARCHITECTURE) +foreach($x in ($c|select -Unique)){if(![IO.File]::Exists($x)){continue};$v=[OcxJob]::V($x);if($null -ne $v){$k=if(([IO.Path]::GetFileName($x)).ToLowerInvariant().StartsWith('ocx-child')){'ephemeral'}else{'full'};[Console]::Out.WriteLine('ocx-inspect:kind:'+$k);[Console]::Out.WriteLine('ocx-inspect:version:'+$v.Trim());exit 0}} +[Console]::Out.WriteLine('ocx-inspect:kind:missing');exit 0 +`); +} + +const WINDOWS_MANAGED_BIN = String.raw`function Resolve-PlantDir{ +$r=if($env:LOCALAPPDATA){$env:LOCALAPPDATA}else{$env:USERPROFILE};$subs=if($env:LOCALAPPDATA){@('opencodex','bin')}else{@('.cache','opencodex','bin')};$cur=$r +foreach($s in $subs){$cur="$cur\$s";if([IO.Directory]::Exists($cur)-or[IO.File]::Exists($cur)){if((([IO.FileSystemInfo](Get-Item -LiteralPath $cur -Force)).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'reparse'}}else{[IO.Directory]::CreateDirectory($cur)|Out-Null}} +$dst="$cur\ocx-child.exe";if([IO.File]::Exists($dst)-or[IO.Directory]::Exists($dst)){if((([IO.FileSystemInfo](Get-Item -LiteralPath $dst -Force)).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw 'reparse'}} +return $cur} +`; + +export function windowsPlantChildArgv(expectedBytes: number, expectedSha256: string): string[] { + return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: [String(expectedBytes), expectedSha256] }) + WINDOWS_MANAGED_BIN + String.raw` +$tmp=$null;try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";$lim=[int]$d.args[0];$exp=$d.args[1] +$tmp="$dir\.ocx-child.tmp.$PID.$([Guid]::NewGuid().ToString('N').Substring(0,8)).exe" +$in=[Console]::OpenStandardInput();$fs=[IO.File]::Open($tmp,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);$h=[Security.Cryptography.SHA256]::Create();$buf=New-Object byte[] 16384;$tot=0 +try{while(($n=$in.Read($buf,0,$buf.Length)) -gt 0){$tot+=$n;if($tot -gt $lim){throw 'size'};$fs.Write($buf,0,$n);[void]$h.TransformBlock($buf,0,$n,$null,0)};[void]$h.TransformFinalBlock($buf,0,0)}finally{$fs.Dispose()} +$sha=([BitConverter]::ToString($h.Hash)).Replace('-','').ToLowerInvariant();$h.Dispose() +if($tot -ne $lim -or $sha -ne $exp){throw 'integrity'} +[IO.File]::Copy($tmp,$dst,$true);[IO.File]::Delete($tmp);$tmp=$null +$rc=[OcxJob]::Run($dst,('"'+$dst+'" --version'));if($rc -ne 0){[IO.File]::Delete($dst);exit 1};exit 0 +}catch{if($tmp -and [IO.File]::Exists($tmp)){try{[IO.File]::Delete($tmp)}catch{}};[Console]::Error.WriteLine('Windows ocx-child plant failed.');exit 1} +`); +} + +export function windowsPurgePlantedChildArgv(): string[] { + return packedPowerShellArgv(NATIVE_PROCESS + WINDOWS_MANAGED_BIN + String.raw` +try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){if([OcxJob]::Ok($dst)){[void][OcxJob]::Run($dst,('"'+$dst+'" detach --purge'))};try{[IO.File]::Delete($dst)}catch{}};Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{}}}catch{exit 1};exit 0 +`); +} + export function windowsPinnedSyncArgv(linkId: string): string[] { return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: ["sync", "--expect-link", linkId, "--json"] }) + OCX_CANDIDATES + String.raw` foreach($x in ($c|select -Unique)){ diff --git a/src/link/store.ts b/src/link/store.ts index a28d6b2ec1e..d8fcd4866a6 100644 --- a/src/link/store.ts +++ b/src/link/store.ts @@ -12,6 +12,7 @@ import { assertSshAlias } from "./ssh-argv"; * It never holds a key: keys live in the running proxy's `apiKeys`, and admission reads them there. */ export type LinkDirection = "hub-initiated" | "client-initiated"; +export type LinkChildKind = "full" | "ephemeral"; export interface LinkRecord { id: string; @@ -28,6 +29,7 @@ export interface LinkRecord { apiKeyId: string; createdAt: string; remotePlatform?: "posix" | "windows"; + childKind?: LinkChildKind; } export interface LinkStore { @@ -52,7 +54,7 @@ export function newLinkId(): string { return `lnk_${randomBytes(8).toString("hex")}`; } -const RECORD_KEYS = new Set(["id", "alias", "direction", "hostKeyFingerprint", "tunnelPort", "apiKeyId", "createdAt", "remotePlatform"]); +const RECORD_KEYS = new Set(["id", "alias", "direction", "hostKeyFingerprint", "tunnelPort", "apiKeyId", "createdAt", "remotePlatform", "childKind"]); const STORE_KEYS = new Set(["version", "listenerPort", "links"]); const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/; const FINGERPRINT = /^[A-Z0-9]+:[A-Za-z0-9+/=]{16,128}$/; @@ -77,6 +79,7 @@ function parseRecord(value: unknown, index: number): LinkRecord { try { assertSshAlias(raw.alias as string); } catch { fail("alias"); } if (raw.direction !== "hub-initiated" && raw.direction !== "client-initiated") fail("direction"); if (raw.remotePlatform !== undefined && raw.remotePlatform !== "posix" && raw.remotePlatform !== "windows") fail("remotePlatform"); + if (raw.childKind !== undefined && raw.childKind !== "full" && raw.childKind !== "ephemeral") fail("childKind"); const fingerprint = raw.hostKeyFingerprint; if (fingerprint === null ? raw.direction !== "client-initiated" : typeof fingerprint !== "string" || !FINGERPRINT.test(fingerprint)) fail("hostKeyFingerprint"); @@ -92,6 +95,7 @@ function parseRecord(value: unknown, index: number): LinkRecord { apiKeyId: raw.apiKeyId as string, createdAt: raw.createdAt as string, ...(raw.remotePlatform === undefined ? {} : { remotePlatform: raw.remotePlatform as "posix" | "windows" }), + ...(raw.childKind === undefined ? {} : { childKind: raw.childKind as LinkChildKind }), }; } diff --git a/tests/clients/link-child-plant-foundations.test.ts b/tests/clients/link-child-plant-foundations.test.ts new file mode 100644 index 00000000000..98161643038 --- /dev/null +++ b/tests/clients/link-child-plant-foundations.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + childArtifactFilename, + hostChildTargetTriple, + resolveChildBinarySource, +} from "../../src/link/child-binary-source"; +import { + classifyChildAttachConflict, + parseRemoteRuntimeInspection, + parseTargetTriple, + remoteInspectRuntimeArgv, + remotePlantChildArgv, + remotePurgePlantedChildArgv, +} from "../../src/link/remote-plant"; +import { + POWERSHELL_ENCODED_MAX, + windowsInspectRuntimeArgv, + windowsOcxArgv, + windowsPlantChildArgv, + windowsPurgePlantedChildArgv, +} from "../../src/link/remote-windows"; +import { quoteRemote } from "../../src/link/ssh-argv"; +import { parseLinkStore, readLinkStore, writeLinkStore } from "../../src/link/store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot } from "../helpers/repo-root"; + +let scratch: string; +let prevHome: string | undefined; +beforeEach(() => { + prevHome = process.env.OPENCODEX_HOME; + scratch = mkdtempSync(join(tmpdir(), "ocx-child-plant-foundations-")); + process.env.OPENCODEX_HOME = join(scratch, ".opencodex"); +}); +afterEach(() => { + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + removeTreeWithRetry(scratch); +}); + +describe("LinkRecord childKind persistence", () => { + test("roundtrips full, ephemeral, and omitted childKind while rejecting invalid values", () => { + const storePath = join(scratch, ".opencodex", "link", "links.json"); + writeLinkStore(storePath, { + version: 1, + listenerPort: 18181, + links: [ + { + id: "lnk_0123456789abcdef", + alias: "ephemeral-box", + direction: "hub-initiated", + hostKeyFingerprint: "SHA256:abcdefghijklmnop", + tunnelPort: 24500, + apiKeyId: "key_1", + createdAt: "2026-11-07T00:00:00.000Z", + remotePlatform: "posix", + childKind: "ephemeral", + }, + { + id: "lnk_fedcba9876543210", + alias: "legacy-box", + direction: "hub-initiated", + hostKeyFingerprint: "SHA256:abcdefghijklmnop", + tunnelPort: 24501, + apiKeyId: "key_2", + createdAt: "2026-11-07T00:00:00.000Z", + }, + ], + }); + const loaded = readLinkStore(storePath); + expect(loaded.links[0]?.childKind).toBe("ephemeral"); + expect(loaded.links[1]?.childKind).toBeUndefined(); + expect(() => + parseLinkStore(JSON.stringify({ ...loaded, links: [{ ...loaded.links[0], childKind: "bogus" }] })), + ).toThrow(/childKind/); + }); +}); + +describe("child-binary-source SHA256SUMS and boundary guards", () => { + test("verifies OPENCODEX_CHILD_BIN_DIR and cache SHA256SUMS, fails closed on mismatch or symlink without local-release fallthrough", () => { + const envDir = join(scratch, "bin-override"); + mkdirSync(envDir, { recursive: true, mode: 0o700 }); + const linuxArtifact = childArtifactFilename("linux-x64"); + const payload = Buffer.from("#!/bin/sh\necho opencodex 2.98.0\n", "utf8"); + const digest = createHash("sha256").update(payload).digest("hex"); + writeFileSync(join(envDir, linuxArtifact), payload, { mode: 0o700 }); + writeFileSync(join(envDir, "SHA256SUMS"), `${digest} ${linuxArtifact}\n`, { mode: 0o600 }); + + const verified = resolveChildBinarySource("linux-x64", { env: { OPENCODEX_CHILD_BIN_DIR: envDir }, allowLocalRelease: false }); + expect(verified.ok).toBe(true); + if (verified.ok) { + expect(verified.artifact.source).toBe("env"); + expect(verified.artifact.sha256).toBe(digest); + expect(verified.artifact.sizeBytes).toBe(payload.byteLength); + } + + // Tamper with SHA256SUMS for host triple: must fail closed with checksum_mismatch instead of falling through to local-release + const hostTriple = hostChildTargetTriple(); + if (hostTriple) { + const hostFile = childArtifactFilename(hostTriple); + writeFileSync(join(envDir, hostFile), payload, { mode: 0o700 }); + writeFileSync(join(envDir, "SHA256SUMS"), `${"0".repeat(64)} ${hostFile}\n`, { mode: 0o600 }); + const mismatch = resolveChildBinarySource(hostTriple, { + env: { OPENCODEX_CHILD_BIN_DIR: envDir }, + repoRoot: repoRoot(), + allowLocalRelease: true, + }); + expect(mismatch).toEqual({ ok: false, reason: "checksum_mismatch" }); + } + + // Symlinked SHA256SUMS on POSIX is rejected + if (process.platform !== "win32") { + const cacheDir = join(scratch, ".opencodex", "cache", "ocx-child", "2.98.0"); + mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + const armArtifact = childArtifactFilename("linux-arm64"); + writeFileSync(join(cacheDir, armArtifact), payload, { mode: 0o700 }); + const realSums = join(scratch, "real-sums"); + writeFileSync(realSums, `${digest} ${armArtifact}\n`, { mode: 0o600 }); + symlinkSync(realSums, join(cacheDir, "SHA256SUMS")); + const symRes = resolveChildBinarySource("linux-arm64", { + env: {}, + opencodexHome: join(scratch, ".opencodex"), + version: "2.98.0", + allowLocalRelease: false, + }); + expect(symRes).toEqual({ ok: false, reason: "checksum_mismatch" }); + } + + expect(resolveChildBinarySource(null)).toEqual({ ok: false, reason: "unsupported_target" }); + }); +}); + +describe("remote-plant POSIX & Windows command contracts", () => { + test("Windows inspect, plant, and purge commands fit under POWERSHELL_ENCODED_MAX and quoteRemote", () => { + const sha = "a".repeat(64); + for (const argv of [ + windowsInspectRuntimeArgv(), + windowsOcxArgv(["disconnect", "--purge"]), + windowsPlantChildArgv(950_000, sha), + windowsPurgePlantedChildArgv(), + ]) { + expect(argv[0]).toBe("powershell.exe"); + expect(argv[5]!.length).toBeLessThanOrEqual(POWERSHELL_ENCODED_MAX); + expect(() => quoteRemote(argv)).not.toThrow(); + } + expect(parseTargetTriple("windows", "AMD64")).toBe("windows-x64"); + expect(parseTargetTriple("windows", "ARM64")).toBeNull(); + expect(parseTargetTriple("posix", "Darwin:arm64")).toBe("darwin-arm64"); + expect(parseTargetTriple("posix", "Linux:x86_64")).toBe("linux-x64"); + expect(parseTargetTriple("posix", "Linux:aarch64")).toBe("linux-arm64"); + expect(classifyChildAttachConflict("ocx-child-conflict:child_port_busy: bound")).toBe("child_port_busy"); + expect(classifyChildAttachConflict("ocx-child-conflict:child_external_provider_conflict: ext")).toBe("child_external_provider_conflict"); + }); + + function sandboxInspectScript(script: string, rootDir: string): string { + const empty = join(rootDir, "empty-sys"); + mkdirSync(empty, { recursive: true }); + return script + .replaceAll("/Applications/OpenCodex.app/Contents/MacOS/ocx", join(empty, "ocx")) + .replaceAll("/usr/bin/ocx", join(empty, "ocx")) + .replaceAll("/opt/homebrew/bin", empty) + .replaceAll("/usr/local/bin", empty); + } + + test("POSIX inspect, stdin plant, and purge scripts operate end-to-end and refuse symlinked .cache", async () => { + if (process.platform === "win32") return; + const fakeHome = join(scratch, "remote-home"); + mkdirSync(fakeHome, { recursive: true, mode: 0o700 }); + const env = { HOME: fakeHome, PATH: "/usr/bin:/bin" }; + + const inspectArgv = remoteInspectRuntimeArgv("posix"); + inspectArgv[2] = sandboxInspectScript(inspectArgv[2]!, scratch); + const inspectMissing = Bun.spawnSync(["sh", ...inspectArgv.slice(1)], { env }); + expect(inspectMissing.exitCode).toBe(0); + const parsedMissing = parseRemoteRuntimeInspection("posix", inspectMissing.stdout.toString("utf8")); + expect(parsedMissing?.runtimeKind).toBe("missing"); + + const scriptPayload = Buffer.from("#!/bin/sh\necho \"opencodex 2.98.0\"\n", "utf8"); + const scriptSha = createHash("sha256").update(scriptPayload).digest("hex"); + const plantArgv = remotePlantChildArgv("posix", scriptPayload.byteLength, scriptSha); + const planted = Bun.spawnSync([plantArgv[0]!, ...plantArgv.slice(1)], { env, stdin: scriptPayload }); + expect(planted.exitCode).toBe(0); + const plantedPath = join(fakeHome, ".cache", "opencodex", "bin", "ocx-child"); + expect(existsSync(plantedPath)).toBe(true); + + const inspectEphemeral = Bun.spawnSync(["sh", ...inspectArgv.slice(1)], { env }); + const parsedEphemeral = parseRemoteRuntimeInspection("posix", inspectEphemeral.stdout.toString("utf8")); + expect(parsedEphemeral?.runtimeKind).toBe("ephemeral"); + expect(parsedEphemeral?.ocxVersionLine).toBe("opencodex 2.98.0"); + + const purgeArgv = remotePurgePlantedChildArgv("posix"); + const purged = Bun.spawnSync([purgeArgv[0]!, ...purgeArgv.slice(1)], { env }); + expect(purged.exitCode).toBe(0); + expect(existsSync(plantedPath)).toBe(false); + + // Refuses symlinked .cache ancestor + const symHome = join(scratch, "sym-remote-home"); + const targetCache = join(scratch, "real-cache"); + mkdirSync(symHome, { recursive: true, mode: 0o700 }); + mkdirSync(targetCache, { recursive: true, mode: 0o700 }); + symlinkSync(targetCache, join(symHome, ".cache")); + chmodSync(targetCache, 0o700); + const symPlant = Bun.spawnSync([plantArgv[0]!, ...plantArgv.slice(1)], { + env: { HOME: symHome, PATH: "/usr/bin:/bin" }, + stdin: scriptPayload, + }); + expect(symPlant.exitCode).not.toBe(0); + }); +}); + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3a72fdca921..5e739adb718 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1622,5 +1622,6 @@ "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" + "link-ocx-child-e2e.test.ts": "clients", + "link-child-plant-foundations.test.ts": "clients" } From 671fd2d63e274ce58e1642999d3b0643aaf7cf45 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 08:22:43 +0900 Subject: [PATCH 13/50] feat(server): add bare-host SSH plant, rollback, childKind status, and DELETE --purge --- .../020_wp2_home_plant_store_and_api.md | 14 ++ src/link/remote-plant.ts | 4 +- src/link/status-projection.ts | 2 + src/server/management/context.ts | 1 + src/server/management/link-enrollment.ts | 190 +++++++++++++++--- src/server/management/link-route-state.ts | 7 +- src/server/management/link-routes.ts | 22 +- tests/clients/link-ocx-child-e2e.test.ts | 170 ++++++++++++++++ .../link-enrollment-ephemeral-child.test.ts | 158 +++++++++++++-- 9 files changed, 515 insertions(+), 53 deletions(-) diff --git a/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md b/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md index 59516c624b4..2ac68ff1277 100644 --- a/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md +++ b/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md @@ -101,3 +101,17 @@ In `src/server/management/link-routes.ts`: - Bare host `apply` with `plant: true` + simulated `child_relay_failed` -> key revoked, tunnel stopped, and `remotePurgePlantedChildArgv` invoked to remove planted binary. - `ocx-child` `connect` returning `ocx-child-conflict:child_external_provider_conflict` -> `409 child_external_provider_conflict` without `residual: true` and with link compensated cleanly. +## 4. `wp2` P-Phase Continuity & Architect Reflection Amendments (`01a1b316-9e8c-7a23-845e-da43f6554f0e`) + +- **Continuity from `wp1` D**: Resumed from `wp1` D conclusion ("Next cycle wp2 implements Home Management API enrollment/plant/purge routes"). +- **Architect `01a1b316-9e8c-7a23-845e-da43f6554f0e` verdict**: `ALIGNED` on `W2-ARCH-01..W2-ARCH-03`. +- **Folded refinements**: + 1. Add `resolveChildBinarySource?: typeof import("../../link/child-binary-source").resolveChildBinarySource` to `ManagementApiDeps` in `src/server/management/context.ts` so server route tests can deterministically inject verified/unavailable binary resolutions across targets. + 2. In `apply` (`src/server/management/link-enrollment.ts`), route EVERY exit after `plantedFreshBinary = true` (including `remote_port` and `child_preflight` refusals such as `child_port_busy` and `child_curl_missing`) through `await failed(...)` so newly planted binaries are always purged on failure; keep `confirmedHosts` intact on pre-plant `409 remote_ocx_missing` / `remote_plant_unavailable`; and reset `remoteMayBeConnected = false` on classified pre-write attach conflicts (`child_port_busy`, `child_external_provider_conflict`). + 3. Enforce strict key sets: `probe` requires `includeVersion === true` when `allowMissing` is supplied; `confirmHost` accepts `{ alias, fingerprint }` or `{ alias, fingerprint, allowMissing }`; `apply` accepts `{ alias, fingerprint?, plant? }` (all 4 exact subsets); `DELETE /api/link/:id` accepts empty/null body or `{ force?, purge? }` and rejects `force === true && purge === true` with `400 invalid_body`. + +## 5. `wp2` A-Phase Audit Fold-Backs (`01a1b322-c1e1-7c41-a560-45f51e35dca3`, `GO-WITH-FIXES`) + +1. **`W2-BLK-01` (Folded — Purge exception safety & `residual` reconciliation)**: Wrap `remotePurgePlantedChildArgv` inside `failed()` in a once-only `try/catch` (`plantedFreshBinary = false`). If rollback purge exits `0`, reset `remoteMayBeConnected = false` (no residual left on Child); if rollback purge returns nonzero or throws, set `remoteMayBeConnected = true` with recovery `"Run ~/.cache/opencodex/bin/ocx-child detach --purge on the Child before retrying."`. +2. **`W2-BLK-02` (Folded — Public DTO projection, boolean guards, & `context.ts` seam)**: Add `src/server/management/context.ts` (`resolveChildBinarySource` seam) to the File Change Map, project only explicit public DTO fields from `confirmHost` (never leaking `knownHostLine`, `probedAt`, `sshLatencyMs`, or `remotePlatform`), validate `typeof allowMissing === "boolean"` in `probe`/`confirmHost` and `typeof plant === "boolean"` in `apply` (`400 invalid_body`), and wire `ctx.deps.resolveChildBinarySource ?? resolveChildBinarySource` in all three handlers. + diff --git a/src/link/remote-plant.ts b/src/link/remote-plant.ts index b7a2e11764a..1dcfee7ea9f 100644 --- a/src/link/remote-plant.ts +++ b/src/link/remote-plant.ts @@ -45,7 +45,7 @@ export const POSIX_INSPECT_SCRIPT = [ " kind=$1; candidate=$2", " if [ -x \"$candidate\" ]; then", " ver=$(\"$candidate\" --version 2>/dev/null) || return 0", - " first=$(printf '%s\\n' \"$ver\" | sed -n '1p')", + " first=$(printf '%s\\n' \"$ver\" | awk 'NR==1{print; exit}')", " if [ -n \"$first\" ]; then", " printf 'ocx-inspect:kind:%s\\n' \"$kind\"", " printf 'ocx-inspect:version:%s\\n' \"$first\"", @@ -134,7 +134,7 @@ export const POSIX_PLANT_SCRIPT = [ "tmp=\"$HOME/.cache/opencodex/bin/.ocx-child.tmp.$$\"", "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM", "cat > \"$tmp\" || exit 1", - "actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]') || exit 1", + "actual_bytes=$(wc -c < \"$tmp\" | awk '{print $1}') || exit 1", "if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then", " printf 'Planted binary size mismatch: expected %s got %s\\n' \"$expected_bytes\" \"$actual_bytes\" >&2", " exit 1", diff --git a/src/link/status-projection.ts b/src/link/status-projection.ts index 9b08dbfbd5e..360baf0c72b 100644 --- a/src/link/status-projection.ts +++ b/src/link/status-projection.ts @@ -20,6 +20,7 @@ export interface LinkStatusDto { since: string; reason: string | null; tunnelPort: number; + childKind?: "full" | "ephemeral"; /** Home only: what the link carried from this Child, and the compatibility verdict. */ peer?: LinkPeerStatus; /** Home-initiated links on request (`?diagnostics=1`): retry progress and the last failure. */ @@ -140,6 +141,7 @@ export function projectLinkStatus( direction: record.direction, ...stateDetails(record, byId.get(record.id), compensation), tunnelPort: record.tunnelPort, + ...(record.childKind ? { childKind: record.childKind } : {}), ...parity(record.id), })); const childRecord = config.runtimeRole === "client" diff --git a/src/server/management/context.ts b/src/server/management/context.ts index acb0dd512ae..003da93dc46 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -167,6 +167,7 @@ export interface ManagementApiDeps { issueApiKey?: (config: OcxConfig, name: string) => IssuedApiKey; revokeApiKey?: (config: OcxConfig, id: string) => boolean; loadLinkCandidates?: () => Array<{ alias: string; source: "ssh_config" | "tailscale" }>; + resolveChildBinarySource?: typeof import("../../link/child-binary-source").resolveChildBinarySource; /** Bound public inference port, not the ingress receiving this management request. */ liveListenPort?: () => number | undefined; now?: () => number; diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index a617a5dd575..0ee4f389613 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -2,10 +2,11 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; 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 { resolveChildBinarySource } from "../../link/child-binary-source"; import { parseFingerprintLine } from "../../link/fingerprint"; import { isDetachedEphemeralChildConfig } from "../../link/ephemeral-child"; +import { classifyChildAttachConflict, inspectRemoteChildRuntime, remotePlantChildArgv, remotePurgePlantedChildArgv } from "../../link/remote-plant"; 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"; import { newLinkId, type LinkStore } from "../../link/store"; import { SshRunnerError, boundHint, sshFailureHint, sshRunnerErrorHint, type SshRunResult } from "../../link/ssh-runner"; @@ -31,12 +32,27 @@ function remoteFailure(result: SshRunResult, code: string, message: string): Res return result.code === 0 ? null : fail(code, message, 502, sshFailureHint(result.stderr)); } +function parseVersion(stdout: string): { version: string; parts: number[] } | null { + const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? ""); + return match ? { version: match.slice(1, 4).join(".") + (match[4] ?? "") + (match[5] ?? ""), parts: match.slice(1, 4).map(Number) } : null; +} + +function isOutdatedVersion(parts: readonly number[]): boolean { + const cmp = parts.findIndex((part, i) => part !== MIN_REMOTE_OCX[i]); + return cmp >= 0 && parts[cmp]! < MIN_REMOTE_OCX[cmp]!; +} + export async function probe(ctx: ManagementContext, state: LinkRouteState): Promise { const value = await readManagementJsonBodyOr(ctx.req, null); - const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]); - if (!body || typeof body.alias !== "string" || (body.includeVersion !== undefined && body.includeVersion !== true)) return fail("invalid_body", "alias is required.", 400); + const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "includeVersion"]) ?? exactBody(value, ["alias", "includeVersion", "allowMissing"]); + if (!body || typeof body.alias !== "string" + || (body.includeVersion !== undefined && body.includeVersion !== true) + || (body.allowMissing !== undefined && (typeof body.allowMissing !== "boolean" || body.includeVersion !== true))) { + return fail("invalid_body", "alias is required.", 400); + } const alias = body.alias; try { assertSshAlias(alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); } + const resolveBinary = ctx.deps.resolveChildBinarySource ?? resolveChildBinarySource; return serialized(state, alias, async () => { const dir = mkdtempSync(join(tmpdir(), "ocx-link-probe-")); const path = join(dir, "known_hosts"); @@ -53,6 +69,38 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom const line = readFileSync(path, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1); if (!line) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502); const remotePlatform = await requireRemotePlatform(runner, alias, path); + if (body.includeVersion === true && body.allowMissing === true) { + const inspected = await inspectRemoteChildRuntime(runner, alias, path, remotePlatform); + if (!inspected) return fail("version_probe_failed", "The remote OpenCodex runtime could not be inspected.", 502); + if (inspected.runtimeKind === "missing") { + const source = resolveBinary(inspected.targetTriple); + state.pendingHosts.set(alias, { alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine: line, probedAt: now(), sshLatencyMs: latency, remotePlatform, runtimeKind: "missing", targetTriple: inspected.targetTriple }); + state.confirmedHosts?.delete(alias); + return Response.json({ + alias, + fingerprint: parsed.fingerprint, + keyType: parsed.keyType, + ocxVersion: null, + runtimeKind: "missing", + targetTriple: inspected.targetTriple, + plantable: source.ok, + ...(source.ok ? { binarySizeBytes: source.artifact.sizeBytes } : { plantUnavailableReason: source.reason }), + }); + } + const parsedVersion = parseVersion(inspected.ocxVersionLine ?? ""); + if (!parsedVersion) return fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502); + state.pendingHosts.set(alias, { alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine: line, probedAt: now(), sshLatencyMs: latency, remotePlatform, runtimeKind: inspected.runtimeKind, targetTriple: inspected.targetTriple }); + state.confirmedHosts?.delete(alias); + return Response.json({ + alias, + fingerprint: parsed.fingerprint, + keyType: parsed.keyType, + ocxVersion: parsedVersion.version, + runtimeKind: inspected.runtimeKind, + targetTriple: inspected.targetTriple, + plantable: false, + }); + } let ocxVersion: string | undefined; if (body.includeVersion === true) { const versionResult = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"], remotePlatform), knownHostsFile: path }), { timeoutMs: 30_000 }); @@ -70,15 +118,14 @@ export async function probe(ctx: ManagementContext, state: LinkRouteState): Prom }); } -function parseVersion(stdout: string): { version: string; parts: number[] } | null { - const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? ""); - return match ? { version: match.slice(1, 4).join(".") + (match[4] ?? "") + (match[5] ?? ""), parts: match.slice(1, 4).map(Number) } : null; -} - export async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promise { - const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "fingerprint"]); - if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string") return fail("invalid_body", "alias and fingerprint are required.", 400); + const value = await readManagementJsonBodyOr(ctx.req, null); + const body = exactBody(value, ["alias", "fingerprint"]) ?? exactBody(value, ["alias", "fingerprint", "allowMissing"]); + if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string" || (body.allowMissing !== undefined && typeof body.allowMissing !== "boolean")) { + return fail("invalid_body", "alias and fingerprint are required.", 400); + } const alias = body.alias; + const resolveBinary = ctx.deps.resolveChildBinarySource ?? resolveChildBinarySource; return serialized(state, alias, async () => { const pending = state.pendingHosts.get(alias), now = ctx.deps.now ?? Date.now; if (!pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe has expired.", 409); @@ -89,13 +136,58 @@ export async function confirmHost(ctx: ManagementContext, state: LinkRouteState) const started = now(); const runner = runnerFor(ctx); const remotePlatform = pending.remotePlatform ?? await requireRemotePlatform(runner, alias, path); + if (body.allowMissing === true) { + const inspected = await inspectRemoteChildRuntime(runner, alias, path, remotePlatform); + if (!inspected) return fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502); + if (inspected.runtimeKind === "missing") { + const source = resolveBinary(inspected.targetTriple); + if (state.pendingHosts.get(alias) !== pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe changed or expired.", 409); + withConfigMutationLockSync(() => putKnownHost(knownHostsFile(ctx), alias, pending.knownHostLine)); + const confirmed = { + ...pending, + remotePlatform, + sshLatencyMs: Math.max(pending.sshLatencyMs ?? 0, now() - started), + ocxVersion: null, + runtimeKind: "missing" as const, + targetTriple: inspected.targetTriple, + plantable: source.ok, + ...(source.ok ? { binarySizeBytes: source.artifact.sizeBytes } : { plantUnavailableReason: source.reason }), + }; + (state.confirmedHosts ??= new Map()).set(alias, confirmed); + state.pendingHosts.delete(alias); + return Response.json({ + alias, + fingerprint: pending.fingerprint, + ocxVersion: null, + runtimeKind: "missing", + targetTriple: inspected.targetTriple, + plantable: source.ok, + ...(source.ok ? { binarySizeBytes: source.artifact.sizeBytes } : { plantUnavailableReason: source.reason }), + }); + } + const version = parseVersion(inspected.ocxVersionLine ?? ""); + if (!version) return fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502); + if (isOutdatedVersion(version.parts)) return fail("remote_ocx_outdated", "The remote OpenCodex is older than 2.66.0.", 409, boundHint("opencodex " + version.version)); + if (state.pendingHosts.get(alias) !== pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe changed or expired.", 409); + withConfigMutationLockSync(() => putKnownHost(knownHostsFile(ctx), alias, pending.knownHostLine)); + const confirmed = { ...pending, remotePlatform, sshLatencyMs: Math.max(pending.sshLatencyMs ?? 0, now() - started), ocxVersion: version.version, runtimeKind: inspected.runtimeKind, targetTriple: inspected.targetTriple, plantable: false }; + (state.confirmedHosts ??= new Map()).set(alias, confirmed); + state.pendingHosts.delete(alias); + return Response.json({ + alias, + fingerprint: pending.fingerprint, + ocxVersion: version.version, + runtimeKind: inspected.runtimeKind, + targetTriple: inspected.targetTriple, + plantable: false, + }); + } const result = await runner.run(buildExecArgv({ alias, argv: remoteOcxArgv(["--version"], remotePlatform), knownHostsFile: path }), { timeoutMs: 30_000 }); const denied = remoteFailure(result, "version_probe_failed", "The remote ocx version could not be confirmed."); if (denied) return denied; const version = parseVersion(result.stdout); if (!version) return fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502); - const cmp = version.parts.findIndex((part, i) => part !== MIN_REMOTE_OCX[i]); - if (cmp >= 0 && version.parts[cmp]! < MIN_REMOTE_OCX[cmp]!) return fail("remote_ocx_outdated", "The remote OpenCodex is older than 2.66.0.", 409, boundHint("opencodex " + version.version)); + if (isOutdatedVersion(version.parts)) return fail("remote_ocx_outdated", "The remote OpenCodex is older than 2.66.0.", 409, boundHint("opencodex " + version.version)); if (state.pendingHosts.get(alias) !== pending || now() - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe changed or expired.", 409); withConfigMutationLockSync(() => putKnownHost(knownHostsFile(ctx), alias, pending.knownHostLine)); const confirmed = { ...pending, remotePlatform, sshLatencyMs: Math.max(pending.sshLatencyMs ?? 0, now() - started), ocxVersion: version.version }; @@ -114,15 +206,21 @@ function parsePort(stdout: string): number | null { export async function apply(ctx: ManagementContext, state: LinkRouteState): Promise { const value = await readManagementJsonBodyOr(ctx.req, null); - const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "fingerprint"]); - if (!body || typeof body.alias !== "string" || (body.fingerprint !== undefined && typeof body.fingerprint !== "string")) return fail("invalid_body", "alias and optional fingerprint are required.", 400); + const body = exactBody(value, ["alias"]) ?? exactBody(value, ["alias", "fingerprint"]) ?? exactBody(value, ["alias", "plant"]) ?? exactBody(value, ["alias", "fingerprint", "plant"]); + if (!body || typeof body.alias !== "string" || (body.fingerprint !== undefined && typeof body.fingerprint !== "string") || (body.plant !== undefined && typeof body.plant !== "boolean")) { + return fail("invalid_body", "alias and optional fingerprint/plant are required.", 400); + } const alias = body.alias; + const resolveBinary = ctx.deps.resolveChildBinarySource ?? resolveChildBinarySource; return serialized(state, alias, async () => { const confirmed = state.confirmedHosts?.get(alias), now = ctx.deps.now ?? Date.now; if (!confirmed) return fail("host_not_confirmed", "Confirm the SSH host before applying the link.", 409); if (now() - confirmed.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409); if (body.fingerprint !== undefined && body.fingerprint !== confirmed.fingerprint) return fail("host_fingerprint_mismatch", "The apply fingerprint does not match the confirmed host.", 409); if (readStoreFor(ctx).links.some(link => link.alias === alias && link.direction === "hub-initiated")) return fail("link_exists", "A link for this alias already exists.", 409); + if (confirmed.runtimeKind === "missing" && body.plant !== true) { + return fail("remote_ocx_missing", "ocx was not found on the remote host; confirm planting ocx-child before applying.", 409); + } const dir = mkdtempSync(join(tmpdir(), "ocx-link-apply-")), knownHosts = join(dir, "known_hosts"); writeFileSync(knownHosts, confirmed.knownHostLine + "\n", { mode: 0o600 }); const runner = runnerFor(ctx), budget = linkCommandBudget(confirmed.sshLatencyMs); @@ -130,45 +228,71 @@ 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 ephemeralChild = false, remoteMayBeConnected = false, phase = "remote_port"; + let ephemeralChild = confirmed.runtimeKind === "ephemeral", plantedFreshBinary = false, purgeRollbackFailed = false, remoteMayBeConnected = false, phase = "remote_port"; const failed = async (response: Response): Promise => { const compensation = record ? await compensateNewLink(ctx, state, record) : null; + if (plantedFreshBinary) { + plantedFreshBinary = false; + try { + const purged = await run(remotePurgePlantedChildArgv(remotePlatform)); + if (purged.code === 0) remoteMayBeConnected = false; + else { remoteMayBeConnected = true; purgeRollbackFailed = true; } + } catch { + remoteMayBeConnected = true; + purgeRollbackFailed = true; + } + } const outcome = compensation ?? response; if (!remoteMayBeConnected) return outcome; const payload = await outcome.json() as Record; - 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."; + const recovery = purgeRollbackFailed + ? "Run ~/.cache/opencodex/bin/ocx-child detach --purge on the Child before retrying." + : 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); + if (body.plant === true && confirmed.runtimeKind === "missing") { + phase = "remote_plant"; + const source = resolveBinary(confirmed.targetTriple ?? null); + if (!source.ok) return fail("remote_plant_unavailable", "No verified ocx-child binary is available for this host architecture.", 409, source.reason); + const planted = await runner.run( + buildExecArgv({ alias, argv: remotePlantChildArgv(remotePlatform, source.artifact.sizeBytes, source.artifact.sha256), knownHostsFile: knownHosts }), + { stdin: source.artifact.bytes, timeoutMs: Math.max(budget, 60_000) }, + ); + if (planted.code !== 0) return fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshFailureHint(planted.stderr)); + plantedFreshBinary = true; + ephemeralChild = true; + } + phase = "remote_port"; const chosen = await run(ocx(["link", "port"])); const denied = remoteFailure(chosen, "remote_port_failed", "The remote link port could not be determined."); - if (denied) return denied; + if (denied) return await failed(denied); const remotePort = parsePort(chosen.stdout); - if (!remotePort) return fail("remote_port_failed", "The remote link port could not be determined.", 502); + if (!remotePort) return await failed(fail("remote_port_failed", "The remote link port could not be determined.", 502)); phase = "child_preflight"; const configured = await run(ocx(["config", "show", "--json"])); let childConfig: unknown = null; try { if (configured.code === 0) childConfig = JSON.parse(configured.stdout); } catch { /* fail closed */ } - if (!isRecord(childConfig) || !port(childConfig.port)) return fail("child_preflight_failed", "The Child effective configuration could not be read.", 502); - if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409); + if (!isRecord(childConfig) || !port(childConfig.port)) return await failed(fail("child_preflight_failed", "The Child effective configuration could not be read.", 502)); + if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return await failed(fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409)); 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); + if (remotePlatform === "posix" && (await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return await failed(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)); 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); + if (before.code === 255) return await failed(fail("child_preflight_failed", "The Child loopback port could not be inspected.", 502, sshFailureHint(before.stderr))); + if (before.code === 0) return await failed(fail("child_port_busy", "The Child's target port is already in use; no Child files were modified.", 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); + if (!isChildHealth(beforeHealth, "standalone", childPort)) return await failed(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); - record = { id, alias, direction: "hub-initiated", hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date(now()).toISOString(), remotePlatform }; + record = { id, alias, direction: "hub-initiated", hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date(now()).toISOString(), remotePlatform, childKind: ephemeralChild ? "ephemeral" : "full" }; withConfigMutationLockSync(() => { const current = readStoreFor(ctx); if (current.links.some(link => link.alias === alias && link.direction === "hub-initiated")) throw new Error("link exists"); @@ -182,6 +306,17 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key })); remoteMayBeConnected = true; const connected = await runner.run(buildExecArgv({ alias, argv: ocx(["connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id]), knownHostsFile: knownHosts }), { stdin: input, timeoutMs: budget }); + if (connected.code !== 0) { + const conflict = classifyChildAttachConflict(connected.stderr); + if (conflict === "child_port_busy") { + remoteMayBeConnected = false; + return await failed(fail("child_port_busy", "The Child's target port is already in use; no Child files were modified.", 409, sshFailureHint(connected.stderr))); + } + if (conflict === "child_external_provider_conflict") { + remoteMayBeConnected = false; + return await failed(fail("child_external_provider_conflict", "The Child's ~/.codex/config.toml is already managed by another model provider or custom base URL; no Child files were modified.", 409, sshFailureHint(connected.stderr))); + } + } const connectDenied = remoteFailure(connected, "remote_connect_failed", "The Child enrollment command failed."); if (connectDenied) return await failed(connectDenied); input.fill(0); @@ -207,3 +342,4 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom } finally { input?.fill(0); rmSync(dir, { recursive: true, force: true }); } }); } + diff --git a/src/server/management/link-route-state.ts b/src/server/management/link-route-state.ts index 3e4593ff294..d58522417c4 100644 --- a/src/server/management/link-route-state.ts +++ b/src/server/management/link-route-state.ts @@ -17,10 +17,15 @@ export interface PendingLinkHost { probedAt: number; sshLatencyMs?: number; remotePlatform?: "posix" | "windows"; + runtimeKind?: "full" | "ephemeral" | "missing"; + targetTriple?: import("../../link/child-binary-source").ChildTargetTriple | null; } export interface ConfirmedLinkHost extends PendingLinkHost { - ocxVersion: string; + ocxVersion: string | null; + plantable?: boolean; + binarySizeBytes?: number; + plantUnavailableReason?: import("../../link/child-binary-source").PlantUnavailableReason; } export interface LinkRouteListener { diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index ed3fe679e66..3c56170f6bc 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -9,6 +9,7 @@ import { newLinkId, type LinkStore } from "../../link/store"; import { sshFailureHint, sshRunnerErrorHint } from "../../link/ssh-runner"; import { attachLinkPeers, attachLinkTunnelDiagnostics, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; import { homeLinkChildParity } from "../../link/child-parity"; +import { codexSettingsDigest, projectHomeCodexSettings } from "../../link/codex-settings"; import { localLinkPeer } from "../../link/compatibility"; import { packageVersion } from "../../lib/package-version"; import { buildPeerFeatures } from "../../remote/features"; @@ -161,7 +162,7 @@ async function handleJoin(ctx: ManagementContext, state: LinkRouteState): Promis runner: runnerFor(ctx), knownHostsFile: knownHostsFile(ctx), scheduleRestart: () => { acceptSystemRestart(); }, - ...(confirmed ? { confirmedHost: confirmed } : {}), + ...(confirmed && confirmed.runtimeKind !== "missing" && confirmed.ocxVersion !== null ? { confirmedHost: confirmed } : {}), }; const result = await (overrides.joinHome ?? joinHome)(deps, { alias: body.alias }); return Response.json({ linkId: result.linkId, alias: body.alias, restarting: true }, { status: 202 }); @@ -202,8 +203,16 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) const record = current.links.find(link => link.id === id); if (!record) return fail("link_not_found", "The link was not found.", 404); const body = await readManagementJsonBodyOr(ctx.req, {}); - if (!isRecord(body) || Object.keys(body).some(key => key !== "force") || (body.force !== undefined && typeof body.force !== "boolean")) return fail("invalid_body", "force must be a boolean.", 400); - const force = body.force === true; + const payload = body === null ? {} : body; + if (!isRecord(payload) + || Object.keys(payload).some(key => key !== "force" && key !== "purge") + || (payload.force !== undefined && typeof payload.force !== "boolean") + || (payload.purge !== undefined && typeof payload.purge !== "boolean") + || (payload.force === true && payload.purge === true)) { + return fail("invalid_body", "force and purge must be booleans and cannot both be true.", 400); + } + const force = payload.force === true; + const purge = payload.purge === true; const release = state.supervisor.holdLink?.(id); try { await state.supervisor.stopLink(id); @@ -220,7 +229,8 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) try { const runner = runnerFor(ctx); const platform = record.remotePlatform ?? await requireRemotePlatform(runner, record.alias, knownHostsFile(ctx)); - const result = await runner.run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"], platform), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 }); + const disconnectArgs = purge && record.childKind === "ephemeral" ? ["disconnect", "--purge"] : ["disconnect"]; + const result = await runner.run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(disconnectArgs, platform), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 }); if (result.code !== 0) { await restartTunnel(); return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr)); @@ -297,8 +307,8 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L // A dashboard session also learns why it cannot join as a Child. The admin-token answer stays // the exact K16 document that `ocx link status` validates key by key. const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null; - const body: LinkStatusDto & { joinAvailable?: boolean; joinDenied?: LinkJoinDenied | null } = ctx.principal === "gui-session" - ? { ...dto, joinAvailable: denial === null, joinDenied: denial } : dto; + const body: LinkStatusDto & { joinAvailable?: boolean; joinDenied?: LinkJoinDenied | null; homeCodexSettingsDigest?: string } = ctx.principal === "gui-session" + ? { ...dto, joinAvailable: denial === null, joinDenied: denial, homeCodexSettingsDigest: codexSettingsDigest(projectHomeCodexSettings(ctx.config, packageVersion("0.0.0")).settings) } : dto; return Response.json(body, { headers: { "cache-control": "no-store" } }); } if (url.pathname === "/api/link/candidates" && req.method === "GET") { diff --git a/tests/clients/link-ocx-child-e2e.test.ts b/tests/clients/link-ocx-child-e2e.test.ts index 6ab05b7d68f..07d67e0d189 100644 --- a/tests/clients/link-ocx-child-e2e.test.ts +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -468,4 +468,174 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- await homeHubLinkServer.stop(true); } }, 30_000); + + test("streams verified ocx-child binary onto a bare Child host over SSH stdin, relays requests, and removes the planted binary on DELETE --purge", async () => { + findOrBuildOcxChildBinary(); + const childUserHome = join(scratch, "bare-child-home"); + const childOcxHome = join(childUserHome, ".opencodex"); + const childCodexHome = join(childUserHome, ".codex"); + mkdirSync(childCodexHome, { recursive: true }); + writeFileSync(join(childCodexHome, "config.toml"), "approval_policy = \"on-request\"\n"); + const plantedPath = join(childUserHome, ".cache", "opencodex", "bin", "ocx-child"); + expect(existsSync(plantedPath)).toBe(false); + + const childListenPort = await pickFreeLoopbackPort(); + const privateUtils = join(scratch, "bare-private-utils"); + mkdirSync(privateUtils, { recursive: true }); + for (const util of ["sh", "uname", "mktemp", "mkdir", "chmod", "mv", "rmdir", "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 POSIX utility: ${util}`); + symlinkSync(resolved, join(privateUtils, util)); + } + const resolvedSh = join(privateUtils, "sh"); + const homeConfig: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + runtimeRole: "standalone", + defaultProvider: "openai", + 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-bare-gen:1", + }); + const homeHubLinkServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 60, + fetch(req, server) { + const u = new URL(req.url); + 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 }); + if (url.pathname === "/readyz") 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") { for (const cb of catalogCallbacks) cb(admittedKey.id); return Response.json({ models: [{ slug: "planted-model" }] }); } + const mgmt = await handleLinkManagementRequest(innerReq, url, homeConfig, {} as never, undefined, admittedKey.id); + return mgmt ?? Response.json({ error: "not_found" }, { status: 404 }); + }); + }, + }); + const tunnelPort = homeHubLinkServer.port!; + writeLinkStore(linkStorePath(), { version: 1, listenerPort: tunnelPort, links: [] }); + const childEnv = { + HOME: childUserHome, + OPENCODEX_HOME: childOcxHome, + CODEX_HOME: childCodexHome, + OCX_CHILD_LISTEN_PORT: String(childListenPort), + PATH: privateUtils, + }; + const runShellAsync = async (argv: string[], stdin?: Uint8Array): Promise => { + const proc = Bun.spawn(argv, { cwd: childUserHome, env: childEnv, stdin: stdin ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe" }); + if (stdin && proc.stdin) { proc.stdin.write(stdin); proc.stdin.end(); } + const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); + return { code, stdout, stderr }; + }; + const sshRunner: SshRunner = { + async run(argv, options) { + if (argv[0] === "ssh-keygen") return { code: 0, stdout: "256 SHA256:barefingerprint01 barebox (ED25519)\n", stderr: "" }; + const cmd = argv.at(-1) ?? ""; + if (cmd === "echo ocx-link-probe") { + const khArg = argv.find(a => a.startsWith("UserKnownHostsFile="))!; + writeFileSync(khArg.slice("UserKnownHostsFile=".length).replace(/^"|"$/g, ""), "barebox ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBARE\n"); + return { code: 0, stdout: "ocx-link-probe\n", stderr: "" }; + } + const patched = sandboxRemoteSh(cmd, scratch); + if (patched.includes("'link' 'port'")) return { code: 0, stdout: JSON.stringify({ port: tunnelPort }) + "\n", stderr: "" }; + const bytes = typeof options?.stdin === "string" ? new TextEncoder().encode(options.stdin) : options?.stdin; + return runShellAsync([resolvedSh, "-c", patched], bytes); + }, + spawnTunnel() { throw new Error("unused"); }, + }; + let issuedCounter = 0; + const knownHostsPath = join(scratch, "home-ocx", "bare_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, direction: "hub-initiated" as const, 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, version: "2.98.0", principal: "admin-token", trustedLoopbackIngress: true, guiSessionIssuance: null, + deps: { + sshRunner, + readLinkStore: () => readLinkStore(linkStorePath()), + writeLinkStore: next => writeLinkStore(linkStorePath(), next), + linkKnownHostsPath: () => knownHostsPath, + liveListenPort: () => 10100, + issueApiKey: (cfg, name) => { + const hex = String(++issuedCounter).padStart(40, "0"); + const entry = { id: `bare-key-${issuedCounter}`, name, key: `ocx_data_${hex}`, createdAt: new Date().toISOString() }; + cfg.apiKeys = [...(cfg.apiKeys ?? []), entry]; + return entry; + }, + revokeApiKey: (cfg, id) => { cfg.apiKeys = (cfg.apiKeys ?? []).filter(k => k.id !== id); return true; }, + }, + sessionControl: { isCurrent: () => true, isPaired: () => true, revokeCurrent: () => true }, + convergeCodexCatalog: async () => { throw new Error("unused"); }, + syncClaudeAgentDefsBestEffort: async () => {}, + }; + const r = await handleLinkRoutes(ctx, linkState); + if (!r) throw new Error("unhandled"); + return r; + }; + try { + const probeRes = await mgmtCall("POST", "/api/link/probe", { alias: "barebox" }); + expect(probeRes.status).toBe(200); + const probeBody = await probeRes.json() as { fingerprint: string }; + + const confirmRes = await mgmtCall("POST", "/api/link/confirm-host", { alias: "barebox", fingerprint: probeBody.fingerprint, allowMissing: true }); + expect(confirmRes.status).toBe(200); + const confirmBody = await confirmRes.json() as { runtimeKind: string; plantable: boolean; binarySizeBytes: number }; + expect(confirmBody.runtimeKind).toBe("missing"); + expect(confirmBody.plantable).toBe(true); + expect(confirmBody.binarySizeBytes).toBeGreaterThan(100_000); + + const applyRes = await mgmtCall("POST", "/api/link/apply", { alias: "barebox", plant: true }); + expect(applyRes.status).toBe(202); + const { linkId } = await applyRes.json() as { linkId: string }; + expect(existsSync(plantedPath)).toBe(true); + expect(readLinkStore(linkStorePath()).links[0]?.childKind).toBe("ephemeral"); + + const delRes = await mgmtCall("DELETE", `/api/link/${linkId}`, { purge: true }); + expect(delRes.status).toBe(200); + expect(existsSync(plantedPath)).toBe(false); + expect(existsSync(join(childOcxHome, "child"))).toBe(false); + expect(readFileSync(join(childCodexHome, "config.toml"), "utf8")).toBe("approval_policy = \"on-request\"\n"); + } finally { + if (existsSync(plantedPath)) await runShellAsync([plantedPath, "disconnect", "--purge"]); + relaySessions.abortReservations(); + await homeHubLinkServer.stop(true); + } + }, 30_000); }); diff --git a/tests/server/link-enrollment-ephemeral-child.test.ts b/tests/server/link-enrollment-ephemeral-child.test.ts index 61101c19e32..665ddb89f0a 100644 --- a/tests/server/link-enrollment-ephemeral-child.test.ts +++ b/tests/server/link-enrollment-ephemeral-child.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +9,7 @@ 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 { remoteInspectRuntimeArgv, remotePlantChildArgv, remotePurgePlantedChildArgv } from "../../src/link/remote-plant"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let root: string; @@ -24,12 +26,17 @@ afterEach(() => { }); const fingerprint = "SHA256:abcdefghijklmnop"; +const fakeBinary = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 1, 2, 3, 4]); +const fakeSha = createHash("sha256").update(fakeBinary).digest("hex"); 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"; +type Step = "inspect" | "plant" | "purgePlant" | "port" | "config" | "curl" | "before" | "connect" | "restart" | "health" | "catalog" | "disconnect" | "disconnectPurge"; function buildHarness() { const remoteOcx = (args: string[]) => quoteRemote(remoteOcxArgv(args, "posix")); + const inspectCmd = quoteRemote(remoteInspectRuntimeArgv("posix")); + const plantCmd = quoteRemote(remotePlantChildArgv("posix", fakeBinary.byteLength, fakeSha)); + const purgePlantCmd = quoteRemote(remotePurgePlantedChildArgv("posix")); const config: OcxConfig = { port: 10100, hostname: "127.0.0.1", @@ -44,6 +51,7 @@ function buildHarness() { let issued = 0; let restarted = false; let enrollmentKeyId = ""; + let plantedBytes: Uint8Array | undefined; const events: string[] = []; const callbacks = new Set<(id: string) => void>(); const overrides: Partial SshRunResult>> = {}; @@ -76,23 +84,31 @@ function buildHarness() { const runner: SshRunner = { async run(argv, options) { const command = argv.at(-1) ?? ""; - const step: Step = command === remoteOcx(["link", "port"]) ? "port" + const step: Step = command === inspectCmd ? "inspect" + : command === plantCmd ? "plant" + : command === purgePlantCmd ? "purgePlant" + : 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 === remoteOcx(["disconnect", "--purge"]) ? "disconnectPurge" : 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 === "plant") plantedBytes = options?.stdin as Uint8Array; 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 "inspect": return ok("ocx-inspect:uname:Linux:x86_64\nocx-inspect:kind:missing\n"); + case "plant": return ok(); + case "purgePlant": return ok(); case "port": return ok('{"port":24500}'); case "config": return ok('{"port":10100,"runtimeRole":"standalone","ephemeralChild":true}\n'); case "curl": return ok(); @@ -105,6 +121,7 @@ function buildHarness() { return http200({ models: [{ slug: "home-m1" }] }); } case "disconnect": return ok(); + case "disconnectPurge": return ok(); } }, spawnTunnel() { throw new Error("unused"); }, @@ -117,6 +134,9 @@ function buildHarness() { writeLinkStore: next => { store = next; events.push("store"); }, linkKnownHostsPath: () => knownHosts, liveListenPort: () => 10100, + resolveChildBinarySource: target => target === "linux-x64" + ? { ok: true, artifact: { targetTriple: "linux-x64", bytes: fakeBinary, sizeBytes: fakeBinary.byteLength, sha256: fakeSha, source: "cache" } } + : { ok: false, reason: "artifact_not_found" }, 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]; @@ -141,7 +161,7 @@ function buildHarness() { ocxVersion: "2.98.0", }); - async function request(method: string, path: string, body?: unknown) { + async function request(method: string, path: string, body?: unknown, principal: "admin-token" | "gui-session" = "admin-token") { const url = new URL(`http://127.0.0.1:10100${path}`); const req = new Request(url, { method, @@ -153,10 +173,10 @@ function buildHarness() { config, deps, version: "2.98.0", - principal: "admin-token", + principal, trustedLoopbackIngress: true, - guiSessionIssuance: null, - sessionControl: { isCurrent: () => true, isPaired: () => false, revokeCurrent: () => true }, + guiSessionIssuance: principal === "gui-session" ? "loopback" : null, + sessionControl: { isCurrent: () => true, isPaired: () => true, revokeCurrent: () => true }, convergeCodexCatalog: async () => { throw new Error("unused"); }, syncClaudeAgentDefsBestEffort: async () => {}, }; @@ -165,32 +185,135 @@ function buildHarness() { return resp; } - return { events, overrides, request, get store() { return store; } }; + return { state, deps, events, overrides, request, get store() { return store; }, get plantedBytes() { return plantedBytes; } }; } -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 () => { +describe("Home Remote Link enrollment, SSH plant, and --purge removal with ephemeral ocx-child", () => { + test("enrolls a clean detached ocx-child, persists childKind=ephemeral, supports DELETE --purge, and isolates homeCodexSettingsDigest to gui-session", 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); + expect(h.store.links[0]?.childKind).toBe("ephemeral"); - const del = await h.request("DELETE", `/api/link/${body.linkId}`); + const adminStatus = await (await h.request("GET", "/api/link/status", undefined, "admin-token")).json() as Record; + expect(adminStatus).not.toHaveProperty("homeCodexSettingsDigest"); + expect((adminStatus.links as Array<{ childKind?: string }>)[0]?.childKind).toBe("ephemeral"); + + const guiStatus = await (await h.request("GET", "/api/link/status", undefined, "gui-session")).json() as Record; + expect(typeof guiStatus.homeCodexSettingsDigest).toBe("string"); + + const badDel = await h.request("DELETE", `/api/link/${body.linkId}`, { force: true, purge: true }); + expect(badDel.status).toBe(400); + + const del = await h.request("DELETE", `/api/link/${body.linkId}`, { purge: true }); expect(del.status).toBe(200); - expect(h.events).toContain("disconnect"); + expect(h.events).toContain("disconnectPurge"); expect(h.store.links).toHaveLength(0); }); - test("refuses enrollment with 409 before issuing a key when a foreign listener occupies childPort", async () => { + test("confirm-host with allowMissing inspects bare host without leaking knownHostLine, refuses apply without plant, and streams plant when authorized", async () => { + const h = buildHarness(); + h.state.pendingHosts.set("barebox", { + alias: "barebox", + fingerprint, + keyType: "ED25519", + knownHostLine: "barebox ssh-ed25519 SECRET_LINE", + probedAt: 1_800_000_000_000, + remotePlatform: "posix", + }); + const confirmRes = await h.request("POST", "/api/link/confirm-host", { alias: "barebox", fingerprint, allowMissing: true }); + expect(confirmRes.status).toBe(200); + const confirmBody = await confirmRes.json() as Record; + expect(confirmBody).toEqual({ + alias: "barebox", + fingerprint, + ocxVersion: null, + runtimeKind: "missing", + targetTriple: "linux-x64", + plantable: true, + binarySizeBytes: fakeBinary.byteLength, + }); + expect(confirmBody).not.toHaveProperty("knownHostLine"); + + // Apply without plant: true is refused with 409 remote_ocx_missing and leaves confirmedHosts intact + const missingPlant = await h.request("POST", "/api/link/apply", { alias: "barebox" }); + expect(missingPlant.status).toBe(409); + expect((await missingPlant.json() as { error: { code: string } }).error.code).toBe("remote_ocx_missing"); + expect(h.events).not.toContain("plant"); + + // Apply with plant: true streams exact verified binary bytes over SSH stdin and enrolls + const applied = await h.request("POST", "/api/link/apply", { alias: "barebox", plant: true }); + expect(applied.status).toBe(202); + expect(h.events).toContain("plant"); + expect(Array.from(h.plantedBytes ?? [])).toEqual(Array.from(fakeBinary)); + expect(h.store.links[0]?.childKind).toBe("ephemeral"); + }); + + test("rolls back freshly planted binary on post-plant failure without residual when purge succeeds, and reports residual if purge fails", async () => { + const h = buildHarness(); + h.state.confirmedHosts!.set("barebox", { + alias: "barebox", + fingerprint, + keyType: "ED25519", + knownHostLine: "barebox ssh-ed25519 AAAA", + probedAt: 1_800_000_000_000, + remotePlatform: "posix", + ocxVersion: null, + runtimeKind: "missing", + targetTriple: "linux-x64", + plantable: true, + binarySizeBytes: fakeBinary.byteLength, + }); + h.overrides.catalog = () => http200({ malformed: true }); + const res = await h.request("POST", "/api/link/apply", { alias: "barebox", plant: true }); + expect(res.status).toBe(502); + const body = await res.json() as Record; + expect(h.events).toContain("purgePlant"); + expect(body.residual).toBeUndefined(); + expect(h.store.links).toHaveLength(0); + + // Now simulate purge failure during rollback -> residual: true with --purge recovery + const h2 = buildHarness(); + h2.state.confirmedHosts!.set("barebox", { + alias: "barebox", + fingerprint, + keyType: "ED25519", + knownHostLine: "barebox ssh-ed25519 AAAA", + probedAt: 1_800_000_000_000, + remotePlatform: "posix", + ocxVersion: null, + runtimeKind: "missing", + targetTriple: "linux-x64", + plantable: true, + }); + h2.overrides.before = () => http200({ service: "foreign" }); + h2.overrides.purgePlant = () => { throw new Error("ssh dropped"); }; + const res2 = await h2.request("POST", "/api/link/apply", { alias: "barebox", plant: true }); + expect(res2.status).toBe(409); + const body2 = await res2.json() as { error: { code: string }; residual?: boolean; recovery?: string }; + expect(body2.error.code).toBe("child_port_busy"); + expect(body2.residual).toBe(true); + expect(body2.recovery).toContain("detach --purge"); + }); + + test("maps child_port_busy and child_external_provider_conflict to 409 without residual", 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"); + const resBusy = await h.request("POST", "/api/link/apply", { alias: "devbox" }); + expect(resBusy.status).toBe(409); + expect((await resBusy.json() as { error: { code: string } }).error.code).toBe("child_port_busy"); expect(h.events).not.toContain("issue"); + + const hExt = buildHarness(); + hExt.overrides.connect = () => ok("", 1, "ocx-child-conflict:child_external_provider_conflict: attach refused: an external Codex model_provider (custom) owns config.toml"); + const resExt = await hExt.request("POST", "/api/link/apply", { alias: "devbox" }); + expect(resExt.status).toBe(409); + const extBody = await resExt.json() as { error: { code: string }; residual?: boolean }; + expect(extBody.error.code).toBe("child_external_provider_conflict"); + expect(extBody.residual).toBeUndefined(); + expect(hExt.store.links).toHaveLength(0); }); test("refuses enrollment with 502 child_preflight_failed when SSH drops (code 255) during before probe", async () => { @@ -223,3 +346,4 @@ describe("Home Remote Link enrollment and removal with ephemeral ocx-child", () expect(body.recovery).not.toContain("ocx restart"); }); }); + From f62b488d2710a25d178a4b80ccdb97e89d412cfe Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 08:40:12 +0900 Subject: [PATCH 14/50] feat(gui,cli): add #remote Ready-to-Plant, parity badges, and --purge UX across 11 locales --- .../030_wp3_cli_and_gui_remote_ux.md | 15 ++ gui/src/i18n/de.ts | 19 ++ gui/src/i18n/en.ts | 19 ++ gui/src/i18n/fr.ts | 19 ++ gui/src/i18n/ja.ts | 19 ++ gui/src/i18n/ko.ts | 19 ++ gui/src/i18n/pt.ts | 19 ++ gui/src/i18n/ru.ts | 19 ++ gui/src/i18n/tr.ts | 19 ++ gui/src/i18n/vi.ts | 19 ++ gui/src/i18n/zh-TW.ts | 19 ++ gui/src/i18n/zh.ts | 19 ++ gui/src/pages/RemoteLink.tsx | 110 ++++++----- gui/src/pages/RemoteLinkDialogs.tsx | 67 +++++++ gui/src/pages/RemoteLinkSheet.tsx | 179 ++++++++++++++++++ gui/src/remote-link-api.ts | 62 +++++- gui/src/styles-remote-link.css | 8 + gui/tests/remote-link.test.tsx | 115 ++++++++++- src/cli/link-enrollment.ts | 170 ++++++++++++++--- src/cli/link.ts | 29 ++- src/server/management/link-enrollment.ts | 16 +- tests/cli/cli-link-enrollment.test.ts | 41 ++++ tests/cli/cli-link-force.test.ts | 16 ++ 23 files changed, 949 insertions(+), 88 deletions(-) create mode 100644 gui/src/pages/RemoteLinkDialogs.tsx create mode 100644 gui/src/pages/RemoteLinkSheet.tsx diff --git a/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md b/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md index e908596854d..d8c42f9edb1 100644 --- a/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md +++ b/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md @@ -107,3 +107,18 @@ - Connected list -> row with `childKind: "ephemeral"` and matching `appliedCodexSettingsDigest === homeCodexSettingsDigest` renders `Lightweight` + `Home settings synced`; mismatched digest renders `Sync needed`. - Ephemeral Disconnect dialog -> checking purge checkbox sends `DELETE /api/link/` with `{ purge: true }`; unchecking sends bodyless `DELETE /api/link/`; `remote_disconnect_failed` transitions to Force Remove rendering `~/.cache/opencodex/bin/ocx-child detach`. +## 4. `wp3` P-Phase Continuity & Architect Reflection Amendments (`01a1b32b-f0db-7e73-93d5-8578c4fbf55d`) + +- **Continuity from `wp2` D**: Resumed from `wp2` D conclusion ("Next cycle wp3 implements CLI (--plant, --purge) and #remote GUI + 11 locales"). +- **Architect `01a1b32b-f0db-7e73-93d5-8578c4fbf55d` verdict**: `ALIGNED` on `W3-ARCH-01..W3-ARCH-03`. +- **Folded refinements**: + 1. Gate CLI `confirm-host` `{ allowMissing: true }` on `shouldPlant || probe.runtimeKind === "missing"` so existing full-runtime CLI expectations stay unchanged, and accept both legacy 3-key and extended `allowMissing` confirmation payloads. + 2. Map `child_port_busy`, `child_external_provider_conflict`, `remote_plant_failed`, `remote_plant_timeout`, and `remote_plant_unavailable` in CLI `PHASES` / actionable error messages, and emit `~/.cache/opencodex/bin/ocx-child detach` residual recovery when `shouldPlant` or `runtimeKind === "ephemeral"` is active. + 3. Explicitly parse and null-default `childKind`, `appliedCodexSettingsDigest`, and `homeCodexSettingsDigest` in `parseRemoteLinkStatus` (`gui/src/remote-link-api.ts`), and reset `purge` checkbox state to `false` whenever the Disconnect dialog opens or closes. + +## 5. `wp3` A-Phase Audit Fold-Backs (`01a1b32d-f03c-79f0-ba27-9cf46b2708ec`, `GO-WITH-FIXES`) + +1. **`remote_plant_timeout` & zero hardcoded strings in `src/pages/*`**: Add `remote_plant_timeout` to `LINK_ERROR_CODES`, `ERROR_TKEY`, and all 11 locale catalogs (`remoteLink.error.remote_plant_timeout`); add `link.plant.unknownArch` across all 11 locales for `targetTriple === null`; and place `formatBinaryMegabytes` inside `gui/src/remote-link-api.ts` so `src/pages/*` passes `bun run lint:i18n` with zero allowlist additions. +2. **Strict `probe.runtimeKind === "missing"` CLI gate**: Gate CLI `confirm-host` `{ allowMissing: true }` strictly on `shouldPlant || probe.runtimeKind === "missing"`. +3. **Null guards & force/purge isolation**: Render parity badge only when `row.appliedCodexSettingsDigest !== null && status.homeCodexSettingsDigest !== null`; omit Apply CTA when `runtimeKind === "missing" && !plantable`; send `{ purge: true }` only when `!pending.force && purge && pending.row.childKind === "ephemeral"`, and send `{ force: true }` exclusively when `pending.force` is true. + diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d09851ddcb0..e9110f48219 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4267,4 +4267,23 @@ export const de: Record = { "fallback.devices.chains": "Fallback-Ketten", "fallback.devices.localFor": "Lokaler Fallback für {name}", "fallback.devices.chainsFor": "Fallback-Ketten für {name}", + "link.plant.title": "Bereit zum Bereitstellen des Leichtgewicht-Agenten", + "link.plant.body": "OpenCodex ist auf diesem Computer noch nicht installiert. Übertragen Sie die eigenständige ocx-child-Binärdatei (~0,9 MB, kein Node oder Bun erforderlich) per SSH nach ~/.cache/opencodex/bin und verbinden Sie sofort. Beim Trennen lässt sie sich rückstandsfrei entfernen.", + "link.plant.apply": "Leichtgewicht-Agent bereitstellen & verbinden", + "link.plant.applying": "Wird bereitgestellt & verbunden…", + "link.plant.unavailable": "Auf diesem Host ({arch}) wurde kein OpenCodex gefunden und lokal ist keine verifizierte ocx-child-Binärdatei verfügbar.", + "link.plant.unknownArch": "unbekannte Architektur", + "link.ephemeralReady": "Leichtgewicht-Agent bereit (ocx-child {version})", + "remoteLink.badge.ephemeral": "Leichtgewicht", + "remoteLink.badge.full": "Vollinstallation", + "remoteLink.parity.synced": "Home-Einstellungen synchron", + "remoteLink.parity.outdated": "Synchronisierung nötig", + "remoteLink.disconnect.ephemeralBody": "{alias} trennen? Die Codex-Konfiguration wird wiederhergestellt und der Relay-Prozess sofort beendet (0 Hintergrundprozesse). Standardmäßig bleibt ocx-child für schnelle Wiederverbindungen in ~/.cache/opencodex/bin zwischengespeichert.", + "remoteLink.disconnect.purgeLabel": "Bereitgestellte Leichtgewicht-Agent-Datei ebenfalls vom Remote-Computer löschen", + "remoteLink.disconnect.purgeConfirm": "Löschen & trennen", + "remoteLink.error.child_port_busy": "Der Zielport (10100) auf dem Child-Computer wird bereits von einem anderen Prozess belegt. Es wurden keine Dateien auf dem Child geändert. Geben Sie den Port frei und versuchen Sie es erneut.", + "remoteLink.error.child_external_provider_conflict": "~/.codex/config.toml auf dem Child-Computer wird bereits von einem anderen Modellanbieter oder einer eigenen Basis-URL verwaltet. Es wurden keine Dateien auf dem Child geändert.", + "remoteLink.error.remote_plant_failed": "Der Leichtgewicht-Agent (ocx-child) konnte nicht per SSH bereitgestellt werden. Prüfen Sie Speicherplatz und ~/.cache-Berechtigungen und versuchen Sie es erneut.", + "remoteLink.error.remote_plant_timeout": "Zeitüberschreitung beim Bereitstellen des Leichtgewicht-Agenten (ocx-child) per SSH. Prüfen Sie die SSH-Verbindung und versuchen Sie es erneut.", + "remoteLink.error.remote_plant_unavailable": "Für die Architektur des Remote-Computers ist lokal keine verifizierte ocx-child-Binärdatei verfügbar.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9905eace401..a5c910bdb99 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4301,6 +4301,25 @@ export const en = { "fallback.devices.chains": "Fallback chains", "fallback.devices.localFor": "Local fallback for {name}", "fallback.devices.chainsFor": "Fallback chains for {name}", + "link.plant.title": "Ready to plant lightweight agent", + "link.plant.body": "OpenCodex is not installed on this computer yet. Plant the standalone ocx-child binary (~0.9 MB, no Node or Bun required) into ~/.cache/opencodex/bin over SSH and connect immediately. You can remove it cleanly when disconnecting.", + "link.plant.apply": "Plant lightweight agent & connect", + "link.plant.applying": "Planting & connecting…", + "link.plant.unavailable": "OpenCodex was not found on this host ({arch}), and no verified ocx-child binary is available locally to plant.", + "link.plant.unknownArch": "unknown architecture", + "link.ephemeralReady": "Lightweight agent ready (ocx-child {version})", + "remoteLink.badge.ephemeral": "Lightweight", + "remoteLink.badge.full": "Full install", + "remoteLink.parity.synced": "Home settings synced", + "remoteLink.parity.outdated": "Sync needed", + "remoteLink.disconnect.ephemeralBody": "Disconnect {alias}? Its Codex configuration will be restored and the relay process will stop immediately (0 background processes). By default, the cached ocx-child binary stays in ~/.cache/opencodex/bin for fast reconnects.", + "remoteLink.disconnect.purgeLabel": "Also delete the planted lightweight agent binary from the remote computer", + "remoteLink.disconnect.purgeConfirm": "Delete & disconnect", + "remoteLink.error.child_port_busy": "The target port (10100) on the Child computer is already in use by another process. No files on the Child were modified. Free the port or stop the conflicting process, then retry.", + "remoteLink.error.child_external_provider_conflict": "~/.codex/config.toml on the Child computer is already managed by another model provider or custom base URL. No files on the Child were modified.", + "remoteLink.error.remote_plant_failed": "Could not plant the lightweight ocx-child agent over SSH. Check remote disk space and ~/.cache permissions, then retry.", + "remoteLink.error.remote_plant_timeout": "Planting the lightweight ocx-child agent over SSH timed out. Check the SSH connection and retry.", + "remoteLink.error.remote_plant_unavailable": "No verified ocx-child binary is available locally for the remote computer's architecture.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 16970c283ee..e00cbc24cbd 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4256,4 +4256,23 @@ export const fr: Record = { "fallback.devices.chains": "Chaînes de repli", "fallback.devices.localFor": "Repli local pour {name}", "fallback.devices.chainsFor": "Chaînes de repli pour {name}", + "link.plant.title": "Prêt à déployer l’agent léger", + "link.plant.body": "OpenCodex n’est pas encore installé sur cet ordinateur. Déployez le binaire autonome ocx-child (~0,9 Mo, sans Node ni Bun) dans ~/.cache/opencodex/bin via SSH et connectez-le immédiatement. Vous pourrez le supprimer proprement lors de la déconnexion.", + "link.plant.apply": "Déployer l’agent léger et connecter", + "link.plant.applying": "Déploiement et connexion…", + "link.plant.unavailable": "OpenCodex est introuvable sur cet hôte ({arch}) et aucun binaire ocx-child vérifié n’est disponible localement.", + "link.plant.unknownArch": "architecture inconnue", + "link.ephemeralReady": "Agent léger prêt (ocx-child {version})", + "remoteLink.badge.ephemeral": "Léger", + "remoteLink.badge.full": "Complet", + "remoteLink.parity.synced": "Réglages Home synchronisés", + "remoteLink.parity.outdated": "Synchronisation requise", + "remoteLink.disconnect.ephemeralBody": "Déconnecter {alias} ? Sa configuration Codex sera restaurée et le relais s’arrêtera immédiatement (0 processus en arrière-plan). Par défaut, le binaire ocx-child reste en cache dans ~/.cache/opencodex/bin pour une reconnexion rapide.", + "remoteLink.disconnect.purgeLabel": "Supprimer également le binaire de l’agent léger déployé sur l’ordinateur distant", + "remoteLink.disconnect.purgeConfirm": "Supprimer et déconnecter", + "remoteLink.error.child_port_busy": "Le port cible (10100) sur l’ordinateur Child est déjà utilisé par un autre processus. Aucun fichier n’a été modifié sur le Child. Libérez le port puis réessayez.", + "remoteLink.error.child_external_provider_conflict": "~/.codex/config.toml sur l’ordinateur Child est déjà géré par un autre fournisseur de modèles ou une URL personnalisée. Aucun fichier n’a été modifié sur le Child.", + "remoteLink.error.remote_plant_failed": "Impossible de déployer l’agent léger (ocx-child) via SSH. Vérifiez l’espace disque distant et les permissions de ~/.cache puis réessayez.", + "remoteLink.error.remote_plant_timeout": "Délai dépassé lors du déploiement de l’agent léger (ocx-child) via SSH. Vérifiez la connexion SSH et réessayez.", + "remoteLink.error.remote_plant_unavailable": "Aucun binaire ocx-child vérifié n’est disponible localement pour l’architecture de l’ordinateur distant.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ec377277851..0a0d37cd808 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4289,4 +4289,23 @@ export const ja: Record = { "fallback.devices.chains": "フォールバックチェーン", "fallback.devices.localFor": "{name} のローカルフォールバック", "fallback.devices.chainsFor": "{name} のフォールバックチェーン", + "link.plant.title": "軽量エージェントの配置準備完了", + "link.plant.body": "このコンピュータにはまだ OpenCodex がインストールされていません。Node や Bun 不要の単一軽量バイナリ(約 0.9 MB)を SSH 経由で ~/.cache/opencodex/bin に配置して即座に接続します。切断時にきれいに削除できます。", + "link.plant.apply": "軽量エージェントを配置して接続", + "link.plant.applying": "配置して接続中…", + "link.plant.unavailable": "リモートホスト({arch})に OpenCodex が見つからず、配置可能な検証済み ocx-child バイナリもローカルにありません。", + "link.plant.unknownArch": "不明なアーキテクチャ", + "link.ephemeralReady": "軽量エージェント準備完了(ocx-child {version})", + "remoteLink.badge.ephemeral": "軽量モジュール", + "remoteLink.badge.full": "フルインストール", + "remoteLink.parity.synced": "Home 設定一致", + "remoteLink.parity.outdated": "同期が必要", + "remoteLink.disconnect.ephemeralBody": "{alias} の接続を解除しますか?Codex 設定は元の状態に復元され、中継プロセスは即座に停止します(バックグラウンドプロセス 0)。既定では素早い再接続のため ocx-child バイナリをキャッシュに残します。", + "remoteLink.disconnect.purgeLabel": "配置した軽量エージェントファイルもリモートコンピュータから削除する", + "remoteLink.disconnect.purgeConfirm": "削除して切断", + "remoteLink.error.child_port_busy": "Child コンピュータの対象ポート(10100)は既に別のプロセスが使用しています。Child 側のファイルは変更されていません。ポートを空けてから再試行してください。", + "remoteLink.error.child_external_provider_conflict": "Child コンピュータの ~/.codex/config.toml は既に別のモデルプロバイダまたはカスタム URL によって管理されています。Child 側のファイルは変更されていません。", + "remoteLink.error.remote_plant_failed": "SSH 経由で軽量エージェント(ocx-child)を配置できませんでした。リモートの空き容量と ~/.cache の権限を確認して再試行してください。", + "remoteLink.error.remote_plant_timeout": "SSH 経由での軽量エージェント(ocx-child)配置がタイムアウトしました。SSH 接続を確認して再試行してください。", + "remoteLink.error.remote_plant_unavailable": "リモートコンピュータのアーキテクチャに対応する検証済み ocx-child バイナリがありません。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 770a9c87096..5dc243f1222 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4289,4 +4289,23 @@ export const ko: Record = { "fallback.devices.chains": "폴백 체인", "fallback.devices.localFor": "{name}의 로컬 폴백", "fallback.devices.chainsFor": "{name}의 폴백 체인", + "link.plant.title": "경량 에이전트 설치 준비됨", + "link.plant.body": "원격 컴퓨터에 아직 OpenCodex가 없습니다. Node나 Bun 없이 동작하는 단일 경량 파일(~0.9 MB)을 SSH를 통해 ~/.cache/opencodex/bin에 심고 즉시 연결하며, 연결 해제 시 깔끔하게 삭제할 수 있습니다.", + "link.plant.apply": "경량 에이전트 심고 연결", + "link.plant.applying": "경량 에이전트 심고 연결 중…", + "link.plant.unavailable": "원격 컴퓨터({arch})에 OpenCodex가 없으며, 이 컴퓨터에서 심을 수 있는 검증된 경량 에이전트 파일을 찾지 못했습니다.", + "link.plant.unknownArch": "알 수 없는 아키텍처", + "link.ephemeralReady": "경량 에이전트 준비됨 (ocx-child {version})", + "remoteLink.badge.ephemeral": "경량 모듈", + "remoteLink.badge.full": "전체 설치", + "remoteLink.parity.synced": "Home 설정 일치", + "remoteLink.parity.outdated": "동기화 필요", + "remoteLink.disconnect.ephemeralBody": "{alias} 연결을 해제할까요? 원격 Codex 설정이 원래대로 복원되고 백그라운드 프로세스 0개 상태로 즉시 종료됩니다. 기본적으로 빠른 재연결을 위해 경량 에이전트 파일은 캐시에 남겨둡니다.", + "remoteLink.disconnect.purgeLabel": "심어둔 경량 에이전트 파일까지 원격 컴퓨터에서 함께 삭제", + "remoteLink.disconnect.purgeConfirm": "파일 삭제 후 연결 해제", + "remoteLink.error.child_port_busy": "원격 컴퓨터의 대상 포트(10100)를 이미 다른 프로세스가 사용 중입니다. 원격 파일은 변경되지 않았습니다. 포트를 비운 뒤 다시 시도하세요.", + "remoteLink.error.child_external_provider_conflict": "원격 컴퓨터의 ~/.codex/config.toml을 이미 다른 모델 제공자나 사용자 지정 주소가 소유하고 있습니다. 원격 파일은 변경되지 않았습니다.", + "remoteLink.error.remote_plant_failed": "SSH를 통해 경량 에이전트(ocx-child)를 심지 못했습니다. 원격 디스크 여유 공간과 ~/.cache 권한을 확인한 뒤 다시 시도하세요.", + "remoteLink.error.remote_plant_timeout": "SSH를 통해 경량 에이전트(ocx-child)를 전송하는 중 시간이 초과되었습니다. SSH 연결 상태를 확인한 뒤 다시 시도하세요.", + "remoteLink.error.remote_plant_unavailable": "원격 컴퓨터 아키텍처에 맞는 검증된 경량 에이전트 파일이 없습니다.", }; diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index c2871c9b07b..ca91acaed68 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -4262,4 +4262,23 @@ export const pt: Record = { "fallback.devices.chains": "Cadeias de fallback", "fallback.devices.localFor": "Fallback local de {name}", "fallback.devices.chainsFor": "Cadeias de fallback de {name}", + "link.plant.title": "Pronto para instalar o agente leve", + "link.plant.body": "O OpenCodex ainda não está instalado neste computador. Envie o binário autónomo ocx-child (~0,9 MB, sem Node ou Bun) para ~/.cache/opencodex/bin via SSH e conecte imediatamente. Pode removê-lo por completo ao desconectar.", + "link.plant.apply": "Instalar agente leve e conectar", + "link.plant.applying": "Instalando e conectando…", + "link.plant.unavailable": "O OpenCodex não foi encontrado neste host ({arch}) e não há binário ocx-child verificado disponível localmente.", + "link.plant.unknownArch": "arquitetura desconhecida", + "link.ephemeralReady": "Agente leve pronto (ocx-child {version})", + "remoteLink.badge.ephemeral": "Leve", + "remoteLink.badge.full": "Instalação completa", + "remoteLink.parity.synced": "Configurações do Home sincronizadas", + "remoteLink.parity.outdated": "Sincronização necessária", + "remoteLink.disconnect.ephemeralBody": "Desconectar {alias}? A configuração do Codex será restaurada e o processo de retransmissão será encerrado imediatamente (0 processos em segundo plano). Por padrão, o binário ocx-child permanece em cache em ~/.cache/opencodex/bin para reconexões rápidas.", + "remoteLink.disconnect.purgeLabel": "Excluir também o binário do agente leve instalado no computador remoto", + "remoteLink.disconnect.purgeConfirm": "Excluir e desconectar", + "remoteLink.error.child_port_busy": "A porta de destino (10100) no computador Child já está em uso por outro processo. Nenhum arquivo no Child foi modificado. Libere a porta e tente novamente.", + "remoteLink.error.child_external_provider_conflict": "O arquivo ~/.codex/config.toml no computador Child já é gerenciado por outro provedor de modelos ou URL personalizada. Nenhum arquivo no Child foi modificado.", + "remoteLink.error.remote_plant_failed": "Não foi possível enviar o agente leve (ocx-child) via SSH. Verifique o espaço em disco remoto e as permissões de ~/.cache e tente novamente.", + "remoteLink.error.remote_plant_timeout": "Tempo esgotado ao enviar o agente leve (ocx-child) via SSH. Verifique a conexão SSH e tente novamente.", + "remoteLink.error.remote_plant_unavailable": "Não há binário ocx-child verificado disponível localmente para a arquitetura do computador remoto.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 3033fc66dcf..f0cd06abbaa 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4290,4 +4290,23 @@ export const ru: Record = { "fallback.devices.chains": "Цепочки резерва", "fallback.devices.localFor": "Локальный резерв для {name}", "fallback.devices.chainsFor": "Цепочки резерва для {name}", + "link.plant.title": "Готово к установке лёгкого агента", + "link.plant.body": "На этом компьютере ещё не установлен OpenCodex. Передайте автономный бинарный файл ocx-child (~0,9 МБ, без Node и Bun) в ~/.cache/opencodex/bin по SSH и сразу подключитесь. При отключении его можно полностью удалить.", + "link.plant.apply": "Установить лёгкий агент и подключить", + "link.plant.applying": "Установка и подключение…", + "link.plant.unavailable": "OpenCodex не найден на этом хосте ({arch}), а проверенный бинарный файл ocx-child локально отсутствует.", + "link.plant.unknownArch": "неизвестная архитектура", + "link.ephemeralReady": "Лёгкий агент готов (ocx-child {version})", + "remoteLink.badge.ephemeral": "Лёгкий модуль", + "remoteLink.badge.full": "Полная установка", + "remoteLink.parity.synced": "Настройки Home синхронизированы", + "remoteLink.parity.outdated": "Требуется синхронизация", + "remoteLink.disconnect.ephemeralBody": "Отключить {alias}? Конфигурация Codex будет восстановлена, а процесс ретрансляции немедленно остановится (0 фоновых процессов). По умолчанию файл ocx-child остаётся в кэше ~/.cache/opencodex/bin для быстрого повторного подключения.", + "remoteLink.disconnect.purgeLabel": "Также удалить установленный бинарный файл лёгкого агента с удалённого компьютера", + "remoteLink.disconnect.purgeConfirm": "Удалить и отключить", + "remoteLink.error.child_port_busy": "Целевой порт (10100) на компьютере Child уже занят другим процессом. Файлы на Child не изменялись. Освободите порт и повторите попытку.", + "remoteLink.error.child_external_provider_conflict": "Файл ~/.codex/config.toml на компьютере Child уже управляется другим провайдером моделей или пользовательским URL. Файлы на Child не изменялись.", + "remoteLink.error.remote_plant_failed": "Не удалось передать лёгкий агент (ocx-child) по SSH. Проверьте свободное место и права на ~/.cache на удалённой машине и повторите попытку.", + "remoteLink.error.remote_plant_timeout": "Превышено время ожидания при передаче лёгкого агента (ocx-child) по SSH. Проверьте подключение SSH и повторите попытку.", + "remoteLink.error.remote_plant_unavailable": "Локально нет проверенного бинарного файла ocx-child для архитектуры удалённого компьютера.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 00d7a5f2769..0f0de67bcd8 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -4290,4 +4290,23 @@ export const tr: Record = { "fallback.devices.chains": "Yedek zincirleri", "fallback.devices.localFor": "{name} için yerel yedek", "fallback.devices.chainsFor": "{name} için yedek zincirleri", + "link.plant.title": "Hafif aracıyı yerleştirmeye hazır", + "link.plant.body": "OpenCodex bu bilgisayarda henüz kurulu değil. Bağımsız ocx-child ikili dosyasını (~0,9 MB, Node veya Bun gerekmez) SSH üzerinden ~/.cache/opencodex/bin dizinine yerleştirip hemen bağlanın. Bağlantıyı keserken temiz biçimde kaldırabilirsiniz.", + "link.plant.apply": "Hafif aracıyı yerleştir ve bağlan", + "link.plant.applying": "Yerleştiriliyor ve bağlanıyor…", + "link.plant.unavailable": "Bu ana bilgisayarda ({arch}) OpenCodex bulunamadı ve yerel olarak doğrulanmış ocx-child ikili dosyası mevcut değil.", + "link.plant.unknownArch": "bilinmeyen mimari", + "link.ephemeralReady": "Hafif aracı hazır (ocx-child {version})", + "remoteLink.badge.ephemeral": "Hafif", + "remoteLink.badge.full": "Tam kurulum", + "remoteLink.parity.synced": "Home ayarları eşleşiyor", + "remoteLink.parity.outdated": "Eşitleme gerekli", + "remoteLink.disconnect.ephemeralBody": "{alias} bağlantısı kesilsin mi? Codex yapılandırması geri yüklenecek ve aktarma işlemi hemen durdurulacak (0 arka plan işlemi). Varsayılan olarak hızlı yeniden bağlantı için ocx-child dosyası ~/.cache/opencodex/bin içinde önbellekte tutulur.", + "remoteLink.disconnect.purgeLabel": "Yerleştirilen hafif aracı dosyasını da uzak bilgisayardan sil", + "remoteLink.disconnect.purgeConfirm": "Sil ve bağlantıyı kes", + "remoteLink.error.child_port_busy": "Child bilgisayardaki hedef bağlantı noktası (10100) başka bir işlem tarafından kullanılıyor. Child üzerinde hiçbir dosya değiştirilmedi. Bağlantı noktasını boşaltıp yeniden deneyin.", + "remoteLink.error.child_external_provider_conflict": "Child bilgisayardaki ~/.codex/config.toml zaten başka bir model sağlayıcısı veya özel URL tarafından yönetiliyor. Child üzerinde hiçbir dosya değiştirilmedi.", + "remoteLink.error.remote_plant_failed": "Hafif aracı (ocx-child) SSH üzerinden yerleştirilemedi. Uzak disk alanını ve ~/.cache izinlerini kontrol edip yeniden deneyin.", + "remoteLink.error.remote_plant_timeout": "Hafif aracının (ocx-child) SSH üzerinden yerleştirilmesi zaman aşımına uğradı. SSH bağlantısını kontrol edip yeniden deneyin.", + "remoteLink.error.remote_plant_unavailable": "Uzak bilgisayarın mimarisi için yerel olarak doğrulanmış ocx-child ikili dosyası bulunmuyor.", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 263d12b2644..71e376ede3f 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -4259,4 +4259,23 @@ export const vi: Record = { "fallback.devices.chains": "Chuỗi dự phòng", "fallback.devices.localFor": "Dự phòng cục bộ cho {name}", "fallback.devices.chainsFor": "Chuỗi dự phòng cho {name}", + "link.plant.title": "Sẵn sàng cài tác nhân gọn nhẹ", + "link.plant.body": "OpenCodex chưa được cài đặt trên máy tính này. Truyền tệp thực thi độc lập ocx-child (~0,9 MB, không cần Node hay Bun) vào ~/.cache/opencodex/bin qua SSH và kết nối ngay lập tức. Bạn có thể xóa sạch khi ngắt kết nối.", + "link.plant.apply": "Cài tác nhân gọn nhẹ & kết nối", + "link.plant.applying": "Đang cài & kết nối…", + "link.plant.unavailable": "Không tìm thấy OpenCodex trên máy chủ này ({arch}) và không có tệp ocx-child đã xác minh trên máy cục bộ.", + "link.plant.unknownArch": "kiến trúc không xác định", + "link.ephemeralReady": "Tác nhân gọn nhẹ sẵn sàng (ocx-child {version})", + "remoteLink.badge.ephemeral": "Gọn nhẹ", + "remoteLink.badge.full": "Cài đặt đầy đủ", + "remoteLink.parity.synced": "Đồng bộ cài đặt Home", + "remoteLink.parity.outdated": "Cần đồng bộ", + "remoteLink.disconnect.ephemeralBody": "Ngắt kết nối {alias}? Cấu hình Codex sẽ được khôi phục và tiến trình chuyển tiếp sẽ dừng ngay lập tức (0 tiến trình nền). Mặc định giữ tệp ocx-child trong ~/.cache/opencodex/bin để kết nối lại nhanh.", + "remoteLink.disconnect.purgeLabel": "Đồng thời xóa tệp tác nhân gọn nhẹ đã cài trên máy tính từ xa", + "remoteLink.disconnect.purgeConfirm": "Xóa & ngắt kết nối", + "remoteLink.error.child_port_busy": "Cổng đích (10100) trên máy Child đang được sử dụng bởi tiến trình khác. Không có tệp nào trên Child bị thay đổi. Hãy giải phóng cổng rồi thử lại.", + "remoteLink.error.child_external_provider_conflict": "Tệp ~/.codex/config.toml trên máy Child đã do nhà cung cấp mô hình khác hoặc URL tùy chỉnh quản lý. Không có tệp nào trên Child bị thay đổi.", + "remoteLink.error.remote_plant_failed": "Không thể cài tác nhân gọn nhẹ (ocx-child) qua SSH. Hãy kiểm tra dung lượng đĩa từ xa và quyền ~/.cache rồi thử lại.", + "remoteLink.error.remote_plant_timeout": "Quá thời gian khi cài tác nhân gọn nhẹ (ocx-child) qua SSH. Hãy kiểm tra kết nối SSH rồi thử lại.", + "remoteLink.error.remote_plant_unavailable": "Không có tệp ocx-child đã xác minh trên máy cục bộ cho kiến trúc của máy tính từ xa.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 26460d0bc48..5566528ff4f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -4253,4 +4253,23 @@ export const zhTW: Record = { "fallback.devices.chains": "備援鏈", "fallback.devices.localFor": "{name} 的本機備援", "fallback.devices.chainsFor": "{name} 的備援鏈", + "link.plant.title": "已準備好植入輕量代理", + "link.plant.body": "此遠端電腦尚未安裝 OpenCodex。透過 SSH 將獨立輕量執行檔 ocx-child(約 0.9 MB,無需 Node 或 Bun)植入 ~/.cache/opencodex/bin 並立即連線;中斷連線時可乾淨移除。", + "link.plant.apply": "植入輕量代理並連線", + "link.plant.applying": "正在植入並連線…", + "link.plant.unavailable": "遠端主機({arch})未安裝 OpenCodex,且本機沒有可植入的已驗證 ocx-child 執行檔。", + "link.plant.unknownArch": "未知架構", + "link.ephemeralReady": "輕量代理已就緒(ocx-child {version})", + "remoteLink.badge.ephemeral": "輕量模組", + "remoteLink.badge.full": "完整安裝", + "remoteLink.parity.synced": "Home 設定一致", + "remoteLink.parity.outdated": "需要同步", + "remoteLink.disconnect.ephemeralBody": "確定中斷 {alias}?其 Codex 設定將還原為原始狀態,轉發行程將立即停止(0 個背景行程)。預設保留 ~/.cache/opencodex/bin 中的快取檔以便快速重新連線。", + "remoteLink.disconnect.purgeLabel": "同時從遠端電腦刪除已植入的輕量代理檔案", + "remoteLink.disconnect.purgeConfirm": "刪除並中斷連線", + "remoteLink.error.child_port_busy": "Child 電腦的目標連接埠(10100)已被其他行程佔用。未修改任何遠端檔案。請釋放連接埠後重試。", + "remoteLink.error.child_external_provider_conflict": "Child 電腦的 ~/.codex/config.toml 已由其他模型供應商或自訂網址管理。未修改任何遠端檔案。", + "remoteLink.error.remote_plant_failed": "無法透過 SSH 植入輕量代理(ocx-child)。請檢查遠端磁碟空間與 ~/.cache 權限後重試。", + "remoteLink.error.remote_plant_timeout": "透過 SSH 植入輕量代理(ocx-child)逾時。請檢查 SSH 連線後重試。", + "remoteLink.error.remote_plant_unavailable": "本機沒有適用於該遠端電腦架構的已驗證 ocx-child 執行檔。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c7247b4896a..d6a9c2af235 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4288,4 +4288,23 @@ export const zh: Record = { "fallback.devices.chains": "回退链", "fallback.devices.localFor": "{name} 的本地回退", "fallback.devices.chainsFor": "{name} 的回退链", + "link.plant.title": "已准备好植入轻量代理", + "link.plant.body": "该远程计算机尚未安装 OpenCodex。通过 SSH 将独立轻量二进制文件 ocx-child(约 0.9 MB,无需 Node 或 Bun)植入 ~/.cache/opencodex/bin 并立即连接;断开连接时可彻底移除。", + "link.plant.apply": "植入轻量代理并连接", + "link.plant.applying": "正在植入并连接…", + "link.plant.unavailable": "远程主机({arch})未安装 OpenCodex,且本地没有可植入的已验证 ocx-child 二进制文件。", + "link.plant.unknownArch": "未知架构", + "link.ephemeralReady": "轻量代理已就绪(ocx-child {version})", + "remoteLink.badge.ephemeral": "轻量模块", + "remoteLink.badge.full": "完整安装", + "remoteLink.parity.synced": "Home 设置一致", + "remoteLink.parity.outdated": "需要同步", + "remoteLink.disconnect.ephemeralBody": "确定断开 {alias}?其 Codex 配置将恢复原状,中继进程将立即停止(0 个后台进程)。默认保留 ~/.cache/opencodex/bin 中的缓存文件以便快速重连。", + "remoteLink.disconnect.purgeLabel": "同时从远程计算机删除已植入的轻量代理文件", + "remoteLink.disconnect.purgeConfirm": "删除并断开连接", + "remoteLink.error.child_port_busy": "Child 计算机的目标端口(10100)已被其他进程占用。未修改任何远程文件。请释放该端口后重试。", + "remoteLink.error.child_external_provider_conflict": "Child 计算机的 ~/.codex/config.toml 已由其他模型服务商或自定义地址接管。未修改任何远程文件。", + "remoteLink.error.remote_plant_failed": "无法通过 SSH 植入轻量代理(ocx-child)。请检查远程磁盘空间与 ~/.cache 权限后重试。", + "remoteLink.error.remote_plant_timeout": "通过 SSH 植入轻量代理(ocx-child)超时。请检查 SSH 连接后重试。", + "remoteLink.error.remote_plant_unavailable": "本地没有适用于该远程计算机架构的已验证 ocx-child 二进制文件。", }; diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 02c2986294a..cb9224494cd 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -1,15 +1,16 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState, type ReactElement } from "react"; import { - LinkApiError, parseRemoteLinkStatus, readRuntimeHealth, requestLinkJson, waitForChildRuntime, type ChildRestartWaitDeps, type LinkCandidateView, type LinkConfirmHostView, + LinkApiError, parseRemoteLinkConfirmation, parseRemoteLinkStatus, readRuntimeHealth, requestLinkJson, waitForChildRuntime, type ChildRestartWaitDeps, type LinkCandidateView, type LinkConfirmHostView, type LinkErrorCode, type LinkJoinDenied, type LinkProbeView, type LinkRowWire, type LinkWireState, type RemoteLinkStatusWire, } from "../remote-link-api"; -import { IconLink, IconPlus, IconRefresh, IconTrash, IconX } from "../icons"; -import { Trans } from "../i18n/provider"; +import { IconPlus, IconRefresh, IconTrash } from "../icons"; import { type TKey, useT } from "../i18n/shared"; import { Notice } from "../ui"; import { isStandaloneRuntime, standaloneApiTargets } from "../api-targets"; import { ConnectPairingForm } from "../connect-pairing"; import FallbackPanel from "../components/FallbackPanel"; +import { RemoteLinkDisconnectDialog } from "./RemoteLinkDialogs"; +import { LinkErrorNotice, RemoteLinkSheet, type LinkActionErrorView } from "./RemoteLinkSheet"; import "../styles-remote-link.css"; type RemoteLinkRole = "home" | "child"; @@ -42,6 +43,8 @@ const STATUS_LABEL: Record = { const ERROR_TKEY: Record = { admission_failed: "remoteLink.error.admission_failed", admission_timeout: "remoteLink.error.admission_timeout", + child_external_provider_conflict: "remoteLink.error.child_external_provider_conflict", + child_port_busy: "remoteLink.error.child_port_busy", compensation_failed: "remoteLink.error.compensation_failed", fingerprint_failed: "remoteLink.error.fingerprint_failed", forbidden: "remoteLink.error.forbidden", @@ -75,6 +78,9 @@ const ERROR_TKEY: Record = { remote_ocx_missing: "remoteLink.error.remote_ocx_missing", remote_ocx_outdated: "remoteLink.error.remote_ocx_outdated", remote_ocx_unrecognized: "remoteLink.error.remote_ocx_unrecognized", + remote_plant_failed: "remoteLink.error.remote_plant_failed", + remote_plant_timeout: "remoteLink.error.remote_plant_timeout", + remote_plant_unavailable: "remoteLink.error.remote_plant_unavailable", remote_port_failed: "remoteLink.error.remote_port_failed", standalone_required: "remoteLink.error.standalone_required", tailscale_session_refused: "remoteLink.error.tailscale_session_refused", @@ -115,22 +121,12 @@ function parseProbe(value: unknown): LinkProbeView { return { alias: value.alias, fingerprint: value.fingerprint, keyType: value.keyType }; } -function parseConfirmation(value: unknown): LinkConfirmHostView { - if (!isRecord(value) || !nonEmpty(value.alias) || !nonEmpty(value.fingerprint) || !nonEmpty(value.ocxVersion)) throw new Error("invalid confirmation"); - return { alias: value.alias, fingerprint: value.fingerprint, ocxVersion: value.ocxVersion }; -} - function errorKey(error: unknown): TKey { if (error instanceof LinkApiError && error.code in ERROR_TKEY) return ERROR_TKEY[error.code as LinkErrorCode]; return "remoteLink.error.generic"; } -type LinkActionError = { key: TKey; hint: string | null }; -function linkActionError(error: unknown): LinkActionError { return { key: errorKey(error), hint: error instanceof LinkApiError ? error.hint : null }; } -function LinkErrorNotice({ error }: { error: LinkActionError }): ReactElement { - const t = useT(); - return {t(error.key)}{error.hint && {t("remoteLink.reason.generic")} {error.hint}}; -} +function linkActionError(error: unknown): LinkActionErrorView { return { key: errorKey(error), hint: error instanceof LinkApiError ? error.hint : null }; } export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvailable = false, onRequestPairing, workspaceAvailable = false, onOpenWorkspace, onChildReady = reloadDashboard, restartWait }: RemoteLinkProps): ReactElement { const t = useT(); @@ -147,9 +143,10 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail const [confirmation, setConfirmation] = useState(null); const [checkedFingerprint, setCheckedFingerprint] = useState(false); const [busy, setBusy] = useState<"candidates" | "probe" | "confirm" | "apply" | "join" | "remove" | "reconnect" | null>(null); - const [actionError, setActionError] = useState(null); + const [actionError, setActionError] = useState(null); const [failedAction, setFailedAction] = useState(null); const [confirming, setConfirming] = useState<{ row: LinkRowWire; force: boolean } | null>(null); + const [purgeOnDisconnect, setPurgeOnDisconnect] = useState(false); const [forceError, setForceError] = useState(null); const addButtonRef = useRef(null); const sheetRef = useRef(null); @@ -319,10 +316,13 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail const attempt = linkAttemptRef.current; if (!attempt) return; setBusy("confirm"); setActionError(null); + const confirmBody = role === "home" + ? { alias: probe.alias, fingerprint: probe.fingerprint, allowMissing: true } + : { alias: probe.alias, fingerprint: probe.fingerprint }; try { - const result = await requestLinkJson(apiBase, "/api/link/confirm-host", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: probe.alias, fingerprint: probe.fingerprint }), signal: attempt.controller.signal }); + const result = await requestLinkJson(apiBase, "/api/link/confirm-host", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(confirmBody), signal: attempt.controller.signal }); if (!isCurrentLinkAttempt(attempt)) return; - setConfirmation(parseConfirmation(result)); + setConfirmation(parseRemoteLinkConfirmation(result)); } catch (error) { if (!isCurrentLinkAttempt(attempt)) return; setActionError(linkActionError(error)); @@ -334,8 +334,9 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail const confirmed = confirmation; if (!confirmed || !attempt) return; setBusy("apply"); setActionError(null); setFailedAction(null); setUiState("applying"); + const applyBody = confirmed.runtimeKind === "missing" ? { alias: confirmed.alias, plant: true } : { alias: confirmed.alias }; try { - await requestLinkJson<{ linkId: string }>(apiBase, "/api/link/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: confirmed.alias }), signal: attempt.controller.signal }); + await requestLinkJson<{ linkId: string }>(apiBase, "/api/link/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(applyBody), signal: attempt.controller.signal }); if (!isCurrentLinkAttempt(attempt)) return; closeSheet(); void refreshStatus(); @@ -394,6 +395,7 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail const closeConfirmation = () => { setConfirming(null); + setPurgeOnDisconnect(false); setForceError(null); window.setTimeout(() => disconnectTriggerRef.current?.focus(), 0); }; @@ -409,7 +411,12 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail if (!confirming) return; const pending = confirming; setBusy("remove"); setForceError(null); - try { await requestLinkJson<{ linkId: string }>(apiBase, `/api/link/${encodeURIComponent(pending.row.id)}`, pending.force ? { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ force: true }) } : { method: "DELETE" }); closeConfirmation(); await refreshStatus(); } + const deleteInit: RequestInit = pending.force + ? { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ force: true }) } + : purgeOnDisconnect && pending.row.childKind === "ephemeral" + ? { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ purge: true }) } + : { method: "DELETE" }; + try { await requestLinkJson<{ linkId: string }>(apiBase, `/api/link/${encodeURIComponent(pending.row.id)}`, deleteInit); closeConfirmation(); await refreshStatus(); } catch (error) { if (!pending.force && error instanceof LinkApiError && error.code === "remote_disconnect_failed") setConfirming({ row: pending.row, force: true }); else setForceError(errorKey(error)); @@ -436,36 +443,45 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail {uiState === "role-select" && canPairLocally && status?.joinAvailable === false && status.joinDenied === "pairing_required" && ( { void refreshStatus(); }} /> )} - {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || uiState === "restart-waiting" || status?.role === "child" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying" || uiState === "joining") &&

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.title") : t("link.children")}

{uiState === "restart-waiting" ? t("remoteLink.restart.waiting") : t(roleLabel)}

{uiState === "restart-waiting" ?
{t("remoteLink.restart.title")}

{t("remoteLink.restart.body")}

{restartSlow && {t("remoteLink.restart.slow")}}
: status?.role === "child" ?
{status.child?.alias ?? t("remoteLink.role.child")}{status.child &&
{t(STATUS_LABEL[status.child.state])}
}
: statusRows.length > 0 ?
{statusRows.map(row =>
{row.alias}
{t(STATUS_LABEL[row.state])}{row.direction === "hub-initiated" ? t("remoteLink.direction.hub") : t("remoteLink.direction.client")}{row.reason && {row.reason in REASON_TKEY ? t(REASON_TKEY[row.reason]) : <>{t("remoteLink.reason.generic")} {row.reason}}}
)}
:

{t(role === "child" && standaloneRuntime ? "remoteLink.findHome.empty" : "link.noChildren")}

}{(!sheetOpen && (uiState === "reconnecting" || (uiState === "failed" && ((failedAction !== null && actionError?.key !== "remoteLink.error.join_restart_failed") || statusRows.some(row => row.state === "failed"))))) &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

}{!sheetOpen && actionError && }
} + {(uiState === "connected" || uiState === "reconnecting" || uiState === "failed" || uiState === "restart-waiting" || status?.role === "child" || statusRows.length > 0 || uiState === "adding-child" || uiState === "confirming-host" || uiState === "applying" || uiState === "joining") &&

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.title") : t("link.children")}

{uiState === "restart-waiting" ? t("remoteLink.restart.waiting") : t(roleLabel)}

{uiState === "restart-waiting" ?
{t("remoteLink.restart.title")}

{t("remoteLink.restart.body")}

{restartSlow && {t("remoteLink.restart.slow")}}
: status?.role === "child" ?
{status.child?.alias ?? t("remoteLink.role.child")}{status.child &&
{t(STATUS_LABEL[status.child.state])}
}
: statusRows.length > 0 ?
{statusRows.map(row => { const homeDigest = status?.homeCodexSettingsDigest ?? null; const childDigest = row.appliedCodexSettingsDigest ?? null; const paritySynced = homeDigest !== null && childDigest !== null ? homeDigest === childDigest : null; return (
{row.alias}
{row.childKind && {t(row.childKind === "ephemeral" ? "remoteLink.badge.ephemeral" : "remoteLink.badge.full")}}{paritySynced !== null && {t(paritySynced ? "remoteLink.parity.synced" : "remoteLink.parity.outdated")}}
{t(STATUS_LABEL[row.state])}{row.direction === "hub-initiated" ? t("remoteLink.direction.hub") : t("remoteLink.direction.client")}{row.reason && {row.reason in REASON_TKEY ? t(REASON_TKEY[row.reason]) : <>{t("remoteLink.reason.generic")} {row.reason}}}
); })}
:

{t(role === "child" && standaloneRuntime ? "remoteLink.findHome.empty" : "link.noChildren")}

}{(!sheetOpen && (uiState === "reconnecting" || (uiState === "failed" && ((failedAction !== null && actionError?.key !== "remoteLink.error.join_restart_failed") || statusRows.some(row => row.state === "failed"))))) &&
{t(STATUS_LABEL[uiState === "failed" ? "failed" : "reconnecting"])}
}{uiState === "joining" &&

{t("remoteLink.joining")}

}{!sheetOpen && actionError && }
} {(status?.role === "child" || statusRows.length > 0) && } - { event.preventDefault(); closeSheet(); }}> -
-
- {role === "home" &&

{t("link.setup.relationship")}

{t("link.setup.requirements")}

} -
-

{role === "child" && standaloneRuntime ? t("remoteLink.findHome.body") : t("link.candidates")}

- {discovery === "loading" &&

{t("link.loading")}

} - {discovery === "failed" && <>

{t("link.discoveryFailed")}

{sheetOpen && actionError && }} - {discovery === "ready" && (candidates.length > 0 ?
{candidates.map(candidate => )}
:
-

{t("link.noCandidates")}

-

{t(role === "home" ? "link.setup.emptyHome" : "link.setup.emptyLocal")}

-
{"Host devbox\n  HostName devbox.example.com\n  User you\n  IdentityFile ~/.ssh/id_ed25519"}
-
)} - {t("link.setup.guide")} - -
-
setAlias(event.target.value)} placeholder={t("link.aliasPlaceholder")} autoComplete="off" />
{probe &&
{t("link.hostFingerprint")}

{probe.fingerprint}

{probe.keyType}
}{confirmation &&

{t("link.ocxVersion", { version: confirmation.ocxVersion })}

{role === "child" && standaloneRuntime && {t("remoteLink.findHome.notice")}}
}{sheetOpen && discovery !== "failed" && actionError && } - {failedAction && actionError?.key !== "remoteLink.error.join_restart_failed" &&
- {failedAction.phase === "probe" && <>

{t("link.probeHelp")}

{"ssh -o BatchMode=yes  true"}
} - -
} -
-
- - { event.preventDefault(); closeConfirmation(); }}> - {confirming && <>

{confirming.force ? : t("link.disconnectConfirm", { alias: confirming.row.alias })}

{forceError &&

{t(forceError)}

}
} -
+ void loadCandidates()} + onProbe={() => void runProbe()} + onConfirmHost={() => void confirmHost()} + onApplyLink={() => void applyLink()} + onJoinLink={() => void joinLink()} + onRetryFailedAction={retryFailedAction} + /> + + void removeLink()} + /> ); } diff --git a/gui/src/pages/RemoteLinkDialogs.tsx b/gui/src/pages/RemoteLinkDialogs.tsx new file mode 100644 index 00000000000..af297484dfb --- /dev/null +++ b/gui/src/pages/RemoteLinkDialogs.tsx @@ -0,0 +1,67 @@ +import type { ReactElement, RefObject } from "react"; +import type { LinkRowWire } from "../remote-link-api"; +import { Trans } from "../i18n/provider"; +import { type TKey, useT } from "../i18n/shared"; + +export interface RemoteLinkDisconnectDialogProps { + confirmRef: RefObject; + confirming: { row: LinkRowWire; force: boolean } | null; + purge: boolean; + onPurgeChange: (checked: boolean) => void; + forceError: TKey | null; + busy: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export function RemoteLinkDisconnectDialog({ + confirmRef, + confirming, + purge, + onPurgeChange, + forceError, + busy, + onClose, + onConfirm, +}: RemoteLinkDisconnectDialogProps): ReactElement { + const t = useT(); + const isEphemeral = confirming?.row.childKind === "ephemeral"; + const forceCmd = isEphemeral ? "~/.cache/opencodex/bin/ocx-child detach" : "ocx disconnect"; + + return ( + { event.preventDefault(); onClose(); }}> + {confirming && ( + <> + +

+ {confirming.force + ? + : isEphemeral + ? t("remoteLink.disconnect.ephemeralBody", { alias: confirming.row.alias }) + : t("link.disconnectConfirm", { alias: confirming.row.alias })} +

+ {!confirming.force && isEphemeral && ( + + )} + {forceError &&

{t(forceError)}

} +
+ + +
+ + )} +
+ ); +} + diff --git a/gui/src/pages/RemoteLinkSheet.tsx b/gui/src/pages/RemoteLinkSheet.tsx new file mode 100644 index 00000000000..6886dace29a --- /dev/null +++ b/gui/src/pages/RemoteLinkSheet.tsx @@ -0,0 +1,179 @@ +import type { ReactElement, RefObject } from "react"; +import { + formatBinaryMegabytes, + type LinkCandidateView, + type LinkConfirmHostView, + type LinkProbeView, +} from "../remote-link-api"; +import { IconLink, IconX } from "../icons"; +import { type TKey, useT } from "../i18n/shared"; +import { Notice } from "../ui"; + +export interface LinkActionErrorView { + readonly key: TKey; + readonly hint: string | null; +} + +export function LinkErrorNotice({ error }: { error: LinkActionErrorView }): ReactElement { + const t = useT(); + return ( + + {t(error.key)} + {error.hint && {t("remoteLink.reason.generic")} {error.hint}} + + ); +} + +export interface RemoteLinkSheetProps { + sheetRef: RefObject; + role: "home" | "child"; + standaloneRuntime: boolean; + childSelectable: boolean; + discovery: "idle" | "loading" | "ready" | "failed"; + candidates: LinkCandidateView[]; + alias: string; + onAliasChange: (value: string) => void; + probe: LinkProbeView | null; + checkedFingerprint: boolean; + onCheckedFingerprintChange: (checked: boolean) => void; + confirmation: LinkConfirmHostView | null; + busy: "candidates" | "probe" | "confirm" | "apply" | "join" | "remove" | "reconnect" | null; + sheetOpen: boolean; + actionError: LinkActionErrorView | null; + failedAction: { phase: "probe" | "apply" | "join"; alias: string } | null; + onClose: () => void; + onRescan: () => void; + onProbe: () => void; + onConfirmHost: () => void; + onApplyLink: () => void; + onJoinLink: () => void; + onRetryFailedAction: () => void; +} + +export function RemoteLinkSheet({ + sheetRef, + role, + standaloneRuntime, + childSelectable, + discovery, + candidates, + alias, + onAliasChange, + probe, + checkedFingerprint, + onCheckedFingerprintChange, + confirmation, + busy, + sheetOpen, + actionError, + failedAction, + onClose, + onRescan, + onProbe, + onConfirmHost, + onApplyLink, + onJoinLink, + onRetryFailedAction, +}: RemoteLinkSheetProps): ReactElement { + const t = useT(); + const isChildFlow = role === "child" && standaloneRuntime; + + return ( + { event.preventDefault(); onClose(); }}> +
+ + +
+
+ {role === "home" &&

{t("link.setup.relationship")}

{t("link.setup.requirements")}

} +
+

{isChildFlow ? t("remoteLink.findHome.body") : t("link.candidates")}

+ {discovery === "loading" &&

{t("link.loading")}

} + {discovery === "failed" && <>

{t("link.discoveryFailed")}

{sheetOpen && actionError && }} + {discovery === "ready" && (candidates.length > 0 ? ( +
+ {candidates.map(candidate => ( + + ))} +
+ ) : ( +
+

{t("link.noCandidates")}

+

{t(role === "home" ? "link.setup.emptyHome" : "link.setup.emptyLocal")}

+
{"Host devbox\n  HostName devbox.example.com\n  User you\n  IdentityFile ~/.ssh/id_ed25519"}
+
+ ))} + {t("link.setup.guide")} + +
+
+ + onAliasChange(event.target.value)} placeholder={t("link.aliasPlaceholder")} autoComplete="off" /> + + +
+ {probe && ( +
+
+ {t("link.hostFingerprint")} +

{probe.fingerprint}

+ {probe.keyType} +
+ + +
+ )} + {confirmation && ( + isChildFlow ? ( +
+

{t("link.ocxVersion", { version: confirmation.ocxVersion ?? "" })}

+ {t("remoteLink.findHome.notice")} + +
+ ) : confirmation.runtimeKind === "missing" ? ( + confirmation.plantable ? ( +
+

{t("link.plant.title")}

+
+ {confirmation.targetTriple ?? t("link.plant.unknownArch")} + {formatBinaryMegabytes(confirmation.binarySizeBytes)} + {"~/.cache/opencodex/bin/ocx-child"} +
+

{t("link.plant.body")}

+ +
+ ) : ( + {t("link.plant.unavailable", { arch: confirmation.targetTriple ?? t("link.plant.unknownArch") })} + ) + ) : ( +
+

+ {confirmation.runtimeKind === "ephemeral" + ? t("link.ephemeralReady", { version: confirmation.ocxVersion ?? "" }) + : t("link.ocxVersion", { version: confirmation.ocxVersion ?? "" })} +

+ +
+ ) + )} + {sheetOpen && discovery !== "failed" && actionError && } + {failedAction && actionError?.key !== "remoteLink.error.join_restart_failed" && ( +
+ {failedAction.phase === "probe" && <>

{t("link.probeHelp")}

{"ssh -o BatchMode=yes  true"}
} + +
+ )} +
+
+
+ ); +} + diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index f61911555ea..8e28b6b056a 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -1,6 +1,8 @@ export const LINK_ERROR_CODES = [ "admission_timeout", "admission_failed", + "child_external_provider_conflict", + "child_port_busy", "compensation_failed", "fingerprint_failed", "forbidden", @@ -33,6 +35,9 @@ export const LINK_ERROR_CODES = [ "remote_ocx_missing", "remote_ocx_outdated", "remote_ocx_unrecognized", + "remote_plant_failed", + "remote_plant_timeout", + "remote_plant_unavailable", "remote_port_failed", "standalone_required", "tailscale_session_refused", @@ -44,13 +49,33 @@ export type LinkErrorCode = typeof LINK_ERROR_CODES[number]; export type LinkWireDirection = "hub-initiated" | "client-initiated"; export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle"; export type LinkListenerState = "off" | "listening" | "failed"; +export type LinkRuntimeKind = "full" | "ephemeral" | "missing"; export const LINK_JOIN_DENIALS = ["pairing_required", "standalone_required", "join_port_mismatch"] as const; export type LinkJoinDenied = typeof LINK_JOIN_DENIALS[number]; export interface LinkCandidateView { alias: string; source: string } export interface LinkProbeView { alias: string; fingerprint: string; keyType: string } -export interface LinkConfirmHostView { alias: string; fingerprint: string; ocxVersion: string } -export interface LinkRowWire { id: string; alias: string; direction: LinkWireDirection; state: LinkWireState; since: string; reason: string | null; tunnelPort: number } +export interface LinkConfirmHostView { + alias: string; + fingerprint: string; + ocxVersion: string | null; + runtimeKind: LinkRuntimeKind; + targetTriple: string | null; + plantable: boolean; + binarySizeBytes: number | null; + plantUnavailableReason: string | null; +} +export interface LinkRowWire { + id: string; + alias: string; + direction: LinkWireDirection; + state: LinkWireState; + since: string; + reason: string | null; + tunnelPort: number; + childKind?: "full" | "ephemeral" | null; + appliedCodexSettingsDigest?: string | null; +} export interface RemoteLinkStatusWire { role: "standalone" | "home" | "child"; listener: { state: LinkListenerState; port: number | null }; @@ -64,6 +89,7 @@ export interface RemoteLinkStatusWire { joinAvailable: boolean; /** GUI-only gate explanation. Older servers may omit it; unknown reasons normalize to null. */ joinDenied?: LinkJoinDenied | null; + homeCodexSettingsDigest?: string | null; } const LINK_STATES: readonly LinkWireState[] = ["connecting", "connected", "reconnecting", "failed", "idle"]; @@ -118,7 +144,9 @@ export function parseRemoteLinkStatus(value: unknown): RemoteLinkStatusWire { if (!Array.isArray(value.links)) throw new Error("invalid links"); const links = value.links.map(item => { if (!isRecord(item) || !nonEmpty(item.id) || !nonEmpty(item.alias) || !["hub-initiated", "client-initiated"].includes(String(item.direction)) || !isLinkState(item.state) || !nonEmpty(item.since) || (item.reason !== null && typeof item.reason !== "string") || typeof item.tunnelPort !== "number") throw new Error("invalid link"); - return { id: item.id, alias: item.alias, direction: item.direction as LinkWireDirection, state: item.state, since: item.since, reason: item.reason as string | null, tunnelPort: item.tunnelPort }; + const childKind: "full" | "ephemeral" | null = item.childKind === "full" || item.childKind === "ephemeral" ? item.childKind : null; + const appliedCodexSettingsDigest = nonEmpty(item.appliedCodexSettingsDigest) ? item.appliedCodexSettingsDigest : null; + return { id: item.id, alias: item.alias, direction: item.direction as LinkWireDirection, state: item.state, since: item.since, reason: item.reason as string | null, tunnelPort: item.tunnelPort, childKind, appliedCodexSettingsDigest }; }); let child: RemoteLinkStatusWire["child"] = null; if (value.child !== null) { @@ -126,7 +154,33 @@ export function parseRemoteLinkStatus(value: unknown): RemoteLinkStatusWire { child = { alias: value.child.alias, state: value.child.state, since: value.child.since, reason: value.child.reason as string | null }; } const joinDenied = LINK_JOIN_DENIALS.includes(value.joinDenied as LinkJoinDenied) ? value.joinDenied as LinkJoinDenied : null; - return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child, joinAvailable: value.joinAvailable === true, joinDenied }; + const homeCodexSettingsDigest = nonEmpty(value.homeCodexSettingsDigest) ? value.homeCodexSettingsDigest : null; + return { role: value.role as RemoteLinkStatusWire["role"], listener: { state: listener.state as LinkListenerState, port: listener.port as number | null }, links, child, joinAvailable: value.joinAvailable === true, joinDenied, homeCodexSettingsDigest }; +} + +export function parseRemoteLinkConfirmation(value: unknown): LinkConfirmHostView { + if (!isRecord(value) || !nonEmpty(value.alias) || !nonEmpty(value.fingerprint)) throw new Error("invalid confirmation"); + const runtimeKind: LinkRuntimeKind = value.runtimeKind === "ephemeral" || value.runtimeKind === "missing" ? value.runtimeKind : "full"; + if (runtimeKind === "missing") { + if (value.ocxVersion !== null && value.ocxVersion !== undefined) throw new Error("invalid confirmation"); + } else if (!nonEmpty(value.ocxVersion)) { + throw new Error("invalid confirmation"); + } + return { + alias: value.alias, + fingerprint: value.fingerprint, + ocxVersion: runtimeKind === "missing" ? null : String(value.ocxVersion), + runtimeKind, + targetTriple: nonEmpty(value.targetTriple) ? value.targetTriple : null, + plantable: value.plantable === true, + binarySizeBytes: typeof value.binarySizeBytes === "number" && Number.isFinite(value.binarySizeBytes) && value.binarySizeBytes > 0 ? value.binarySizeBytes : null, + plantUnavailableReason: nonEmpty(value.plantUnavailableReason) ? value.plantUnavailableReason : null, + }; +} + +export function formatBinaryMegabytes(bytes: number | null): string { + if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return "~0.9 MB"; + return `~${Math.max(0.1, bytes / (1024 * 1024)).toFixed(1)} MB`; } /** Read link-route JSON and preserve the server's machine-readable error code. */ diff --git a/gui/src/styles-remote-link.css b/gui/src/styles-remote-link.css index 4ccae5aeeb2..15b80981a87 100644 --- a/gui/src/styles-remote-link.css +++ b/gui/src/styles-remote-link.css @@ -67,3 +67,11 @@ @media (prefers-reduced-motion: reduce) { .remote-link-page *, .remote-link-page *::before, .remote-link-page *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } } +.remote-link-row-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.remote-link-badges { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-1-5); } +.remote-link-plant-card { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); } +.remote-link-plant-card h4 { margin: 0; font-size: var(--text-body); font-weight: 600; color: var(--text); } +.remote-link-plant-card p { margin: 0; font-size: var(--text-label); line-height: 1.5; color: var(--muted); } +.remote-link-plant-meta { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); font-family: var(--font-code); font-size: var(--text-caption); color: var(--text); } +.remote-link-purge-option { display: flex; align-items: flex-start; gap: var(--space-2); margin: var(--space-3) 0; padding: var(--space-3); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); font-size: var(--text-control); color: var(--text); cursor: pointer; } +.remote-link-purge-option input { margin-top: 2px; flex: 0 0 auto; } diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index a91a71e5bc7..42c1f5c9fe4 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -1024,7 +1024,7 @@ test("rescan after host confirmation preserves alias and requires fresh fingerpr expect([...sheet.querySelectorAll("button")].some(button => button.textContent === "Connect child")).toBe(false); expect(posts).toEqual([ { path: "/api/link/probe", body: { alias: "manual-child" } }, - { path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:old" } }, + { path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:old", allowMissing: true } }, ]); await act(async () => { sheetButton(sheet, "Test connection").click(); }); @@ -1043,9 +1043,9 @@ test("rescan after host confirmation preserves alias and requires fresh fingerpr await flush(); expect(posts).toEqual([ { path: "/api/link/probe", body: { alias: "manual-child" } }, - { path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:old" } }, + { path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:old", allowMissing: true } }, { path: "/api/link/probe", body: { alias: "manual-child" } }, - { path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:fresh" } }, + { path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:fresh", allowMissing: true } }, { path: "/api/link/apply", body: { alias: "manual-child" } }, ]); expect(sheet.open).toBe(false); @@ -1108,7 +1108,7 @@ test("network discovery failure still permits a manual alias and explicit finger await act(async () => { (sheet.querySelector('[type="checkbox"]') as HTMLInputElement).click(); }); await act(async () => { sheetButton(sheet, "Confirm host").click(); }); await flush(); - expect(posts[1]).toEqual({ path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:manual" } }); + expect(posts[1]).toEqual({ path: "/api/link/confirm-host", body: { alias: "manual-child", fingerprint: "SHA256:manual", allowMissing: true } }); await act(async () => { sheetButton(sheet, "Connect child").click(); }); await flush(); expect(posts[2]).toEqual({ path: "/api/link/apply", body: { alias: "manual-child" } }); @@ -1129,3 +1129,110 @@ test("Find Home empty guidance names this computer rather than claiming it is Ho expect(sheet.textContent).toContain("this computer's ~/.ssh/config"); expect(sheet.textContent).not.toContain("this Home's"); }); + +test("Ready-to-Plant, cached ephemeral, connected badges, conflict notices, and ephemeral disconnect --purge flows", async () => { + const calls: Array<{ method: string; path: string; body: unknown }> = []; + let mode: "bare" | "unplantable" | "ephemeral" | "conflict" = "bare"; + let disconnectFailOnce = false; + const digestA = "a".repeat(64); + const digestB = "b".repeat(64); + let currentStatus: RemoteLinkStatusWire = { + ...baseStatus, + homeCodexSettingsDigest: digestA, + links: [ + { id: "lnk_eph", alias: "devbox-eph", direction: "hub-initiated", state: "connected", since: "2026-11-07T00:00:00Z", reason: null, tunnelPort: 24500, childKind: "ephemeral", appliedCodexSettingsDigest: digestA }, + { id: "lnk_full", alias: "devbox-full", direction: "hub-initiated", state: "connected", since: "2026-11-07T00:00:00Z", reason: null, tunnelPort: 24501, childKind: "full", appliedCodexSettingsDigest: digestB }, + ], + }; + globalThis.fetch = (async (input, init) => { + const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; + const parsedBody = init?.body ? JSON.parse(String(init.body)) : undefined; + if (method !== "GET") calls.push({ method, path, body: parsedBody }); + if (path.endsWith("/candidates")) return response({ candidates: [{ alias: "barebox", source: "ssh_config" }] }); + if (path.endsWith("/probe")) return response({ alias: "barebox", fingerprint: "SHA256:barefp", keyType: "ed25519" }); + if (path.endsWith("/confirm-host")) { + if (mode === "bare") return response({ alias: "barebox", fingerprint: "SHA256:barefp", ocxVersion: null, runtimeKind: "missing", targetTriple: "linux-x64", plantable: true, binarySizeBytes: 952_000 }); + if (mode === "unplantable") return response({ alias: "barebox", fingerprint: "SHA256:barefp", ocxVersion: null, runtimeKind: "missing", targetTriple: null, plantable: false, plantUnavailableReason: "unsupported_target" }); + return response({ alias: "barebox", fingerprint: "SHA256:barefp", ocxVersion: "2.98.0", runtimeKind: "ephemeral", targetTriple: "linux-x64", plantable: false }); + } + if (path.endsWith("/apply")) { + if (mode === "conflict") return response({ error: { code: "child_port_busy" } }, 409); + return response({ linkId: "lnk_planted" }, 202); + } + if (method === "DELETE" && path.endsWith("/lnk_eph")) { + if (disconnectFailOnce) { + disconnectFailOnce = false; + return response({ error: { code: "remote_disconnect_failed" } }, 502); + } + return response({ linkId: "lnk_eph" }); + } + return response(currentStatus); + }) as typeof fetch; + + const host = await mount(); + // 1. Connected list badges: Lightweight + Home settings synced on devbox-eph, Full install + Sync needed on devbox-full + expect(host.textContent).toContain("Lightweight"); + expect(host.textContent).toContain("Home settings synced"); + expect(host.textContent).toContain("Full install"); + expect(host.textContent).toContain("Sync needed"); + + // 2. Open Add Child sheet -> Ready-to-Plant card -> sends { alias: 'barebox', plant: true } + const addBtn = [...host.querySelectorAll("button")].find(b => b.textContent?.includes("Add child")) as HTMLButtonElement; + await act(async () => { addBtn.click(); }); + await flush(); + const sheet = host.querySelector(".remote-link-sheet") as HTMLDialogElement; + const candBtn = [...sheet.querySelectorAll(".remote-link-candidate")][0] as HTMLButtonElement; + await act(async () => { candBtn.click(); }); + const probeBtn = [...sheet.querySelectorAll("button")].find(b => b.textContent?.includes("Test connection")) as HTMLButtonElement; + await act(async () => { probeBtn.click(); }); + await flush(); + await act(async () => { (sheet.querySelector('[type="checkbox"]') as HTMLInputElement).click(); }); + const confirmBtn = [...sheet.querySelectorAll("button")].find(b => b.textContent?.includes("Confirm host")) as HTMLButtonElement; + await act(async () => { confirmBtn.click(); }); + await flush(); + expect(sheet.textContent).toContain("Ready to plant lightweight agent"); + expect(sheet.textContent).toContain("linux-x64"); + expect(sheet.textContent).toContain("~0.9 MB"); + const plantBtn = [...sheet.querySelectorAll("button")].find(b => b.textContent?.includes("Plant lightweight agent & connect")) as HTMLButtonElement; + await act(async () => { plantBtn.click(); }); + await flush(); + expect(calls).toContainEqual({ method: "POST", path: "/api/link/apply", body: { alias: "barebox", plant: true } }); + + // 3. Cached ephemeral & child_port_busy conflict notice + mode = "conflict"; + await act(async () => { addBtn.click(); }); + await flush(); + await act(async () => { ([...sheet.querySelectorAll(".remote-link-candidate")][0] as HTMLButtonElement).click(); }); + await act(async () => { ([...sheet.querySelectorAll("button")].find(b => b.textContent?.includes("Test connection")) as HTMLButtonElement).click(); }); + await flush(); + await act(async () => { (sheet.querySelector('[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { ([...sheet.querySelectorAll("button")].find(b => b.textContent?.includes("Confirm host")) as HTMLButtonElement).click(); }); + await flush(); + expect(sheet.textContent).toContain("Lightweight agent ready (ocx-child 2.98.0)"); + await act(async () => { ([...sheet.querySelectorAll("button")].find(b => b.textContent?.includes("Connect child")) as HTMLButtonElement).click(); }); + await flush(); + expect(sheet.textContent).toContain("already in use by another process"); + await act(async () => { ([...sheet.querySelectorAll("button")].find(b => b.textContent === "Cancel") as HTMLButtonElement).click(); }); + + // 4. Ephemeral Disconnect --purge checkbox -> sends { purge: true }, and on remote_disconnect_failed shows ocx-child detach force modal + const disconnectBtns = [...host.querySelectorAll(".remote-link-row button")]; + await act(async () => { (disconnectBtns[0] as HTMLButtonElement).click(); }); + await flush(); + const confirmDialog = host.querySelector(".remote-link-confirm-dialog") as HTMLDialogElement; + expect(confirmDialog.textContent).toContain("0 background processes"); + const purgeCheckbox = confirmDialog.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(purgeCheckbox.checked).toBe(false); + await act(async () => { purgeCheckbox.click(); }); + const delBtn = [...confirmDialog.querySelectorAll("button")].find(b => b.textContent?.includes("Delete & disconnect")) as HTMLButtonElement; + disconnectFailOnce = true; + await act(async () => { delBtn.click(); }); + await flush(); + expect(calls).toContainEqual({ method: "DELETE", path: "/api/link/lnk_eph", body: { purge: true } }); + // Force remove modal now shown with ocx-child detach guidance and exclusive { force: true } body + expect(confirmDialog.textContent).toContain("~/.cache/opencodex/bin/ocx-child detach"); + const forceBtn = [...confirmDialog.querySelectorAll("button")].find(b => b.textContent?.includes("Remove here only")) as HTMLButtonElement; + await act(async () => { forceBtn.click(); }); + await flush(); + expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/api/link/lnk_eph", body: { force: true } }); +}); diff --git a/src/cli/link-enrollment.ts b/src/cli/link-enrollment.ts index b7c29d21058..3842fa0d200 100644 --- a/src/cli/link-enrollment.ts +++ b/src/cli/link-enrollment.ts @@ -1,13 +1,18 @@ import { createInterface } from "node:readline/promises"; import { assertSshAlias } from "../link/ssh-argv"; -import { CliUsageError, RuntimeApiError, runtimeBaseUrl, rejectArgs, takeJsonFlag, takeOption, printData } from "./runtime-api"; +import { CliUsageError, RuntimeApiError, runtimeBaseUrl, rejectArgs, takeFlag, takeJsonFlag, takeOption, printData } from "./runtime-api"; import type { LinkCliDeps } from "./link"; const FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{16,88}={0,2}$/; +const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/; const ENROLLMENT_DEADLINE_MS = 360_000; const PHASES: Record = { remote_port_failed: "remote port selection", remote_port_timeout: "remote port selection", + remote_plant_failed: "remote ocx-child planting", remote_plant_timeout: "remote ocx-child planting", + remote_plant_unavailable: "local ocx-child binary resolution", child_preflight_failed: "Child preflight", child_preflight_timeout: "Child preflight", + child_port_busy: "Child port availability preflight", + child_external_provider_conflict: "Child Codex config ownership check", child_restart_unsupported: "Child restart preflight", child_curl_missing: "Child curl preflight", home_setup_failed: "Home setup", home_setup_timeout: "Home setup", remote_connect_failed: "Child enrollment", remote_connect_timeout: "Child enrollment", @@ -16,28 +21,110 @@ const PHASES: Record = { child_runtime_not_client: "Child runtime role verification", remote_platform_unsupported: "remote platform verification", remote_ocx_missing: "remote OpenCodex discovery", }; -const RECOVERY = "Run ocx disconnect and ocx restart on the Child before retrying."; +const RECOVERY_FULL = "Run ocx disconnect and ocx restart on the Child before retrying."; +const RECOVERY_EPHEMERAL = "Run ~/.cache/opencodex/bin/ocx-child detach on the Child before retrying."; type LinkRequest = (path: string, init: RequestInit, deps: LinkCliDeps) => Promise; -function record(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function offered(value: unknown): { alias: string; fingerprint: string; keyType: string; ocxVersion: string } { - if (!record(value) || Object.keys(value).sort().join(",") !== "alias,fingerprint,keyType,ocxVersion" - || typeof value.alias !== "string" || typeof value.fingerprint !== "string" || !FINGERPRINT.test(value.fingerprint) - || typeof value.keyType !== "string" || !/^[A-Za-z0-9@._+-]{1,64}$/.test(value.keyType)) throw new Error("Invalid host probe response"); - if (typeof value.ocxVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/.test(value.ocxVersion)) throw new Error("Invalid host version response"); - return { alias: value.alias, fingerprint: value.fingerprint, keyType: value.keyType, ocxVersion: value.ocxVersion }; +interface ParsedProbeOffer { + alias: string; + fingerprint: string; + keyType: string; + ocxVersion: string | null; + runtimeKind?: "full" | "ephemeral" | "missing"; + targetTriple?: string | null; + plantable?: boolean; + binarySizeBytes?: number; + plantUnavailableReason?: string; + legacyExact: boolean; +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +const PROBE_ALLOWED_KEYS = new Set([ + "alias", "fingerprint", "keyType", "ocxVersion", + "runtimeKind", "targetTriple", "plantable", "binarySizeBytes", "plantUnavailableReason", +]); +const CONFIRM_ALLOWED_KEYS = new Set([ + "alias", "fingerprint", "ocxVersion", + "runtimeKind", "targetTriple", "plantable", "binarySizeBytes", "plantUnavailableReason", +]); + +function parseOffered(value: unknown, allowMissing: boolean): ParsedProbeOffer { + if (!record(value)) throw new Error("Invalid host probe response"); + const keys = Object.keys(value); + if (keys.some(k => !PROBE_ALLOWED_KEYS.has(k))) throw new Error("Invalid host probe response"); + if (typeof value.alias !== "string" || typeof value.fingerprint !== "string" || !FINGERPRINT.test(value.fingerprint) + || typeof value.keyType !== "string" || !/^[A-Za-z0-9@._+-]{1,64}$/.test(value.keyType)) { + throw new Error("Invalid host probe response"); + } + const legacyExact = keys.slice().sort().join(",") === "alias,fingerprint,keyType,ocxVersion"; + if (!allowMissing && !legacyExact) throw new Error("Invalid host probe response"); + if (value.ocxVersion === null) { + if (!allowMissing || value.runtimeKind !== "missing") throw new Error("Invalid host version response"); + } else if (typeof value.ocxVersion !== "string" || !SEMVER.test(value.ocxVersion)) { + throw new Error("Invalid host version response"); + } + if (value.runtimeKind !== undefined && value.runtimeKind !== "full" && value.runtimeKind !== "ephemeral" && value.runtimeKind !== "missing") { + throw new Error("Invalid host probe response"); + } + if (value.targetTriple !== undefined && value.targetTriple !== null && (typeof value.targetTriple !== "string" || value.targetTriple.length > 64)) { + throw new Error("Invalid host probe response"); + } + if (value.plantable !== undefined && typeof value.plantable !== "boolean") throw new Error("Invalid host probe response"); + if (value.binarySizeBytes !== undefined && (typeof value.binarySizeBytes !== "number" || !Number.isSafeInteger(value.binarySizeBytes) || value.binarySizeBytes <= 0)) { + throw new Error("Invalid host probe response"); + } + if (value.plantUnavailableReason !== undefined && typeof value.plantUnavailableReason !== "string") { + throw new Error("Invalid host probe response"); + } + return { + alias: value.alias, + fingerprint: value.fingerprint, + keyType: value.keyType, + ocxVersion: value.ocxVersion, + ...(value.runtimeKind !== undefined ? { runtimeKind: value.runtimeKind } : {}), + ...(value.targetTriple !== undefined ? { targetTriple: value.targetTriple } : {}), + ...(value.plantable !== undefined ? { plantable: value.plantable } : {}), + ...(value.binarySizeBytes !== undefined ? { binarySizeBytes: value.binarySizeBytes } : {}), + ...(value.plantUnavailableReason !== undefined ? { plantUnavailableReason: value.plantUnavailableReason } : {}), + legacyExact, + }; +} + +function validateConfirmed(value: unknown, alias: string, fingerprint: string, allowMissing: boolean): void { + if (!record(value)) throw new Error("Invalid host confirmation response"); + const keys = Object.keys(value); + if (keys.some(k => !CONFIRM_ALLOWED_KEYS.has(k))) throw new Error("Invalid host confirmation response"); + if (value.alias !== alias || value.fingerprint !== fingerprint) throw new Error("Invalid host confirmation response"); + const legacyExact = keys.slice().sort().join(",") === "alias,fingerprint,ocxVersion"; + if (!allowMissing && !legacyExact) throw new Error("Invalid host confirmation response"); + if (value.ocxVersion === null) { + if (!allowMissing || value.runtimeKind !== "missing") throw new Error("Invalid host confirmation response"); + } else if (typeof value.ocxVersion !== "string" || !SEMVER.test(value.ocxVersion)) { + throw new Error("Invalid host confirmation response"); + } +} + +function formatSizeMb(bytes?: number): string { + if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return "~0.9 MB"; + return `~${Math.max(0.1, bytes / (1024 * 1024)).toFixed(1)} MB`; } export async function runLinkEnrollment(command: "probe" | "add", args: string[], deps: LinkCliDeps, request: LinkRequest, usage: string): Promise { const wantsJson = takeJsonFlag(args); + const plantFlag = takeFlag(args, "--plant"); const expected = takeOption(args, "--expect-fingerprint"); const alias = args.shift(); if (!alias) throw new CliUsageError(command + " requires an SSH alias", usage); try { assertSshAlias(alias); } catch { throw new CliUsageError("Invalid SSH alias", usage); } rejectArgs(args, usage); if (command === "probe" && expected !== undefined) throw new CliUsageError("--expect-fingerprint is only for link add", usage); + if (command === "probe" && plantFlag) throw new CliUsageError("--plant is only for link add", usage); if (expected !== undefined && !FINGERPRINT.test(expected)) throw new CliUsageError("Expected a SHA256 SSH fingerprint", usage); - if (command === "add" && !expected && !(deps.stdinImpl ?? process.stdin).isTTY) { + const isTTY = Boolean((deps.stdinImpl ?? process.stdin).isTTY); + if (command === "add" && !expected && !isTTY) { throw new CliUsageError("Non-interactive link add requires --expect-fingerprint", usage); } const timeout = deps.enrollmentTimeoutMs ?? ENROLLMENT_DEADLINE_MS; @@ -48,6 +135,7 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] timer = setTimeout(() => { controller.abort(); reject(new Error("Link enrollment timed out; its outcome is uncertain. Run ocx link status on the Home and ocx connect status on the Child before retrying.")); }, timeout); }); let boundDeps = deps; + let ephemeralFlow = false; const post = async (path: string, body: unknown): Promise => { controller.signal.throwIfAborted(); const result = await request(path, { method: "POST", redirect: "error", signal: controller.signal, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, boundDeps); @@ -55,38 +143,71 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] return result; }; let rl: ReturnType | undefined; + const askYes = async (promptText: string): Promise => { + rl ??= createInterface({ input: deps.stdinImpl ?? process.stdin, output: process.stderr }); + return (await rl.question(promptText, { signal: controller.signal })).trim() === "yes"; + }; const work = async () => { boundDeps = { ...deps, baseUrl: await runtimeBaseUrl(deps) }; controller.signal.throwIfAborted(); - const probe = offered(await post("/api/link/probe", { alias, includeVersion: true })); + const probeReq = command === "add" ? { alias, includeVersion: true, allowMissing: true } : { alias, includeVersion: true }; + const probe = parseOffered(await post("/api/link/probe", probeReq), command === "add"); if (probe.alias !== alias) throw new Error("Host probe alias did not match"); - if (command === "probe") { printData(probe, wantsJson, [probe.alias + " " + probe.keyType + " " + probe.fingerprint + " opencodex " + probe.ocxVersion]); return; } + if (command === "probe") { + const payload = { alias: probe.alias, fingerprint: probe.fingerprint, keyType: probe.keyType, ocxVersion: probe.ocxVersion! }; + printData(payload, wantsJson, [probe.alias + " " + probe.keyType + " " + probe.fingerprint + " opencodex " + probe.ocxVersion]); + return; + } if (expected && expected !== probe.fingerprint) throw new CliUsageError("The offered host fingerprint does not match --expect-fingerprint"); - if (!expected) { - let approved: boolean; - if (deps.confirmFingerprint) approved = await deps.confirmFingerprint(probe); - else { - rl = createInterface({ input: deps.stdinImpl ?? process.stdin, output: process.stderr }); - approved = (await rl.question("Confirm " + alias + " " + probe.keyType + " " + probe.fingerprint + " opencodex " + probe.ocxVersion + " (type yes): ", { signal: controller.signal })).trim() === "yes"; + let shouldPlant = false; + ephemeralFlow = probe.runtimeKind === "ephemeral" || probe.runtimeKind === "missing"; + if (probe.runtimeKind === "missing") { + if (probe.plantable !== true) { + throw new Error(`ocx was not found on ${alias} (${probe.targetTriple ?? "unknown target"}) and no verified ocx-child binary is available locally (${probe.plantUnavailableReason ?? "unavailable"}).`); + } + if (!isTTY && !plantFlag) { + throw new CliUsageError("Remote host has no OpenCodex runtime; pass --plant alongside --expect-fingerprint to plant lightweight ocx-child.", usage); } + const sizeLabel = formatSizeMb(probe.binarySizeBytes); + if (!expected) { + const approved = deps.confirmFingerprint + ? await deps.confirmFingerprint({ alias: probe.alias, fingerprint: probe.fingerprint, keyType: probe.keyType, ocxVersion: null, runtimeKind: "missing", binarySizeBytes: probe.binarySizeBytes }) + : await askYes(`Confirm ${alias} ${probe.keyType} ${probe.fingerprint} — plant lightweight ocx-child (${sizeLabel}) and connect? (type yes): `); + controller.signal.throwIfAborted(); + if (!approved) throw new CliUsageError("Host fingerprint and ocx-child plant were not confirmed"); + } else if (!plantFlag) { + const approved = deps.confirmFingerprint + ? await deps.confirmFingerprint({ alias: probe.alias, fingerprint: probe.fingerprint, keyType: probe.keyType, ocxVersion: null, runtimeKind: "missing", binarySizeBytes: probe.binarySizeBytes }) + : await askYes(`Plant lightweight ocx-child (${sizeLabel}) on ${alias} and connect? (type yes): `); + controller.signal.throwIfAborted(); + if (!approved) throw new CliUsageError("Planting ocx-child was not confirmed"); + } + shouldPlant = true; + } else if (!expected) { + const approved = deps.confirmFingerprint + ? await deps.confirmFingerprint({ alias: probe.alias, fingerprint: probe.fingerprint, keyType: probe.keyType, ...(probe.ocxVersion !== null ? { ocxVersion: probe.ocxVersion } : {}) }) + : await askYes("Confirm " + alias + " " + probe.keyType + " " + probe.fingerprint + " opencodex " + probe.ocxVersion + " (type yes): "); controller.signal.throwIfAborted(); if (!approved) throw new CliUsageError("Host fingerprint was not confirmed"); } - const confirm = await post("/api/link/confirm-host", { alias, fingerprint: probe.fingerprint }); - if (!record(confirm) || Object.keys(confirm).sort().join(",") !== "alias,fingerprint,ocxVersion" || confirm.alias !== alias || confirm.fingerprint !== probe.fingerprint - || typeof confirm.ocxVersion !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/.test(confirm.ocxVersion)) throw new Error("Invalid host confirmation response"); - const result = await post("/api/link/apply", { alias, fingerprint: probe.fingerprint }); + const sendAllowMissing = shouldPlant || probe.runtimeKind === "missing"; + const confirm = await post("/api/link/confirm-host", { alias, fingerprint: probe.fingerprint, ...(sendAllowMissing ? { allowMissing: true } : {}) }); + validateConfirmed(confirm, alias, probe.fingerprint, sendAllowMissing); + const result = await post("/api/link/apply", { alias, fingerprint: probe.fingerprint, ...(shouldPlant ? { plant: true } : {}) }); if (!record(result) || Object.keys(result).join(",") !== "linkId" || typeof result.linkId !== "string" || !/^lnk_[0-9a-f]{16}$/.test(result.linkId)) throw new Error("Invalid link apply response"); - printData({ linkId: result.linkId, alias, state: "connected", childRole: "client" }, wantsJson, ["Connected " + alias + " as a Child (" + result.linkId + ")."]); + printData({ linkId: result.linkId, alias, state: "connected", childRole: "client", ...(shouldPlant ? { planted: true } : {}) }, wantsJson, ["Connected " + alias + " as a Child (" + result.linkId + ")."]); }; try { await Promise.race([work(), expiry]); } catch (error) { if (error instanceof RuntimeApiError && record(error.body)) { const code = record(error.body.error) ? error.body.error.code : undefined; if (code === "remote_platform_unsupported" && error.body.residual !== true) throw new Error("The remote shell cannot run this Remote Link workflow. Update OpenCodex on Home and check PowerShell on the Child."); + if (code === "child_port_busy") throw new Error("The target port on the Child is already in use by another process; no Child files were modified."); + if (code === "child_external_provider_conflict") throw new Error("The Child's ~/.codex/config.toml is already managed by another model provider or custom base URL; no Child files were modified."); const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined; const message = phase ? "Remote Link failed during " + phase : error.message; - if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + RECOVERY : "")); + const recovery = ephemeralFlow ? RECOVERY_EPHEMERAL : RECOVERY_FULL; + if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + recovery : "")); } throw error; } finally { @@ -95,3 +216,4 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] rl?.close(); } } + diff --git a/src/cli/link.ts b/src/cli/link.ts index cdd57b09897..ab50d1e4577 100644 --- a/src/cli/link.ts +++ b/src/cli/link.ts @@ -14,6 +14,7 @@ import { assertSshAlias } from "../link/ssh-argv"; import { linkStorePath } from "../link/paths"; import { isLinkPort } from "../link/ports"; import { readLinkStore, type LinkDirection, type LinkStore } from "../link/store"; +import type { LinkChildKind } from "../link/store"; import { findAvailablePort } from "../server/ports"; import { CliUsageError, @@ -32,11 +33,11 @@ import { export const LINK_USAGE = `Usage: ocx link probe [--json] - ocx link add [--expect-fingerprint SHA256:…] [--json] + ocx link add [--plant] [--expect-fingerprint SHA256:…] [--json] ocx link port [--json] ocx link issue --alias --tunnel-port [--json] ocx link status [--json] - ocx link revoke --link-id [--force --yes] [--json] + ocx link revoke --link-id [--purge] [--force --yes] [--json] ocx link reconnect --link-id [--json] ocx link cleanup [--link-id ] [--json] @@ -63,7 +64,7 @@ export interface LinkCliDeps extends RuntimeApiDeps, Omit LinkStatusConfig; readClientLinkIdentity?: (linkId: string) => ClientLinkIdentity | null; readChildStatus?: () => LinkStatusDto["child"]; - confirmFingerprint?: (host: { alias: string; fingerprint: string; keyType: string }) => Promise; + confirmFingerprint?: (host: { alias: string; fingerprint: string; keyType: string; ocxVersion?: string | null; runtimeKind?: "full" | "ephemeral" | "missing"; binarySizeBytes?: number }) => Promise; readStore?: () => LinkStore; readAdminToken?: () => string | null; } @@ -153,11 +154,12 @@ export function validateStatus(value: unknown): LinkStatusPayload { if (!isRecord(candidate)) throw new Error(`invalid link API response: link ${index}`); // The OCX-6 parity fields are optional on the wire (an older runtime omits them) and always // present in this output, null when absent. - assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer", "tunnel"], `link ${index}`); + assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer", "tunnel", "childKind"], `link ${index}`); const parity = validateParity(candidate, index); if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id) || !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated") || (candidate.state !== "connecting" && candidate.state !== "connected" && candidate.state !== "reconnecting" && candidate.state !== "failed" && candidate.state !== "idle") + || (candidate.childKind !== undefined && candidate.childKind !== "full" && candidate.childKind !== "ephemeral") || !validString(candidate.since) || !validNullableString(candidate.reason) || !isLinkPort(candidate.tunnelPort)) { throw new Error(`invalid link API response: link ${index} fields`); } @@ -169,6 +171,7 @@ export function validateStatus(value: unknown): LinkStatusPayload { since: candidate.since, reason: candidate.reason, tunnelPort: candidate.tunnelPort, + ...(candidate.childKind !== undefined ? { childKind: candidate.childKind as LinkChildKind } : {}), ...parity, ...(candidate.peer !== undefined ? { peer: validatePeer(candidate.peer, index) } : {}), ...(candidate.tunnel !== undefined ? { tunnel: validateTunnel(candidate.tunnel, index) } : {}), @@ -303,13 +306,22 @@ async function runStatus(args: string[], deps: LinkCliDeps): Promise { async function runRevoke(args: string[], deps: LinkCliDeps): Promise { const wantsJson = takeJsonFlag(args); + const purge = takeFlag(args, "--purge"); const force = takeFlag(args, "--force"); const confirmed = takeFlag(args, "--yes"); const linkId = takeOption(args, "--link-id"); if (!linkId || !LINK_ID.test(linkId)) throw new CliUsageError("revoke requires a valid --link-id", LINK_USAGE); rejectArgs(args, LINK_USAGE); + if (purge && force) throw new CliUsageError("--purge cannot be combined with --force", LINK_USAGE); if (force && !confirmed) throw new CliUsageError("forced revoke skips remote cleanup; pass --yes to confirm", LINK_USAGE); if (confirmed && !force) throw new CliUsageError("--yes requires --force", LINK_USAGE); + let wasEphemeral = false; + if (force) { + try { + const store = (deps.readStore ?? (() => readLinkStore(linkStorePath())))(); + wasEphemeral = store.links.find(l => l.id === linkId)?.childKind === "ephemeral"; + } catch { /* fallback to standard recovery message when store is unavailable */ } + } let remoteCleanup: "skipped" | "unverified" = "skipped"; try { const response = await linkRequest(`/api/link/${encodeURIComponent(linkId)}`, force ? { @@ -317,6 +329,11 @@ async function runRevoke(args: string[], deps: LinkCliDeps): Promise { redirect: "error", headers: { "content-type": "application/json" }, body: JSON.stringify({ force: true }), + } : purge ? { + method: "DELETE", + redirect: "error", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ purge: true }), } : { method: "DELETE" }, deps); // Legacy revoke accepts an empty response; a forced receipt needs observed identity. if (force && response === null) throw new Error("invalid link API response: revoke object"); @@ -333,7 +350,9 @@ async function runRevoke(args: string[], deps: LinkCliDeps): Promise { remoteCleanup = "unverified"; } if (force) { - const recovery = "Run ocx disconnect on the remote client."; + const recovery = wasEphemeral + ? "Run ~/.cache/opencodex/bin/ocx-child detach on the remote client." + : "Run ocx disconnect on the remote client."; printData({ linkId, remoteCleanup, recovery }, wantsJson, [ remoteCleanup === "skipped" ? `Revoked ${linkId}; remote cleanup was skipped.` : `Link ${linkId} is already absent; remote cleanup is unverified.`, recovery, diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index 0ee4f389613..49a18a08032 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -258,10 +258,18 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom phase = "remote_plant"; const source = resolveBinary(confirmed.targetTriple ?? null); if (!source.ok) return fail("remote_plant_unavailable", "No verified ocx-child binary is available for this host architecture.", 409, source.reason); - const planted = await runner.run( - buildExecArgv({ alias, argv: remotePlantChildArgv(remotePlatform, source.artifact.sizeBytes, source.artifact.sha256), knownHostsFile: knownHosts }), - { stdin: source.artifact.bytes, timeoutMs: Math.max(budget, 60_000) }, - ); + let planted: SshRunResult; + try { + planted = await runner.run( + buildExecArgv({ alias, argv: remotePlantChildArgv(remotePlatform, source.artifact.sizeBytes, source.artifact.sha256), knownHostsFile: knownHosts }), + { stdin: source.artifact.bytes, timeoutMs: Math.max(budget, 60_000) }, + ); + } catch (plantErr) { + if (plantErr instanceof SshRunnerError && plantErr.code === "timeout") { + return fail("remote_plant_timeout", "Planting ocx-child on the remote host timed out.", 502, sshRunnerErrorHint(plantErr)); + } + return fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshRunnerErrorHint(plantErr)); + } if (planted.code !== 0) return fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshFailureHint(planted.stderr)); plantedFreshBinary = true; ephemeralChild = true; diff --git a/tests/cli/cli-link-enrollment.test.ts b/tests/cli/cli-link-enrollment.test.ts index a8a7f936ed1..e182bf80b75 100644 --- a/tests/cli/cli-link-enrollment.test.ts +++ b/tests/cli/cli-link-enrollment.test.ts @@ -125,4 +125,45 @@ describe("CLI Remote Link enrollment", () => { const value = JSON.parse(h.out[0]!); expect(value.role).toBe("child"); expect(value.links[0]).toMatchObject({ id: linkId, tunnelPort: 19200, state: "failed" }); expect(value.child.state).toBe("failed"); } finally { h.restore(); } }); + + test("bare host refuses non-TTY add without --plant, succeeds with --plant, and prompts once on TTY", async () => { + const bareProbe = { alias: "bare-a", fingerprint, keyType: "ED25519", ocxVersion: null, runtimeKind: "missing", targetTriple: "linux-x64", plantable: true, binarySizeBytes: 943_718 }; + const bareConfirm = { alias: "bare-a", fingerprint, ocxVersion: null, runtimeKind: "missing", targetTriple: "linux-x64", plantable: true, binarySizeBytes: 943_718 }; + const hRefuse = harness([bareProbe]); + try { + expect(await runLinkCommand(["add", "bare-a", "--expect-fingerprint", fingerprint], hRefuse.deps)).toBe(2); + expect(hRefuse.calls).toHaveLength(1); + expect(hRefuse.err.join("\n")).toContain("--plant"); + } finally { hRefuse.restore(); } + + const hPlant = harness([bareProbe, bareConfirm, { linkId }]); + try { + expect(await runLinkCommand(["add", "bare-a", "--plant", "--expect-fingerprint", fingerprint, "--json"], hPlant.deps)).toBe(0); + expect(JSON.parse(String(hPlant.calls[1]?.init?.body))).toEqual({ alias: "bare-a", fingerprint, allowMissing: true }); + expect(JSON.parse(String(hPlant.calls[2]?.init?.body))).toEqual({ alias: "bare-a", fingerprint, plant: true }); + expect(JSON.parse(hPlant.out[0]!)).toEqual({ linkId, alias: "bare-a", state: "connected", childRole: "client", planted: true }); + } finally { hPlant.restore(); } + + const hTty = harness([bareProbe, bareConfirm, { linkId }]); + try { + hTty.deps.stdinImpl = Object.assign(Readable.from(["yes\n"]), { isTTY: true }); + expect(await runLinkCommand(["add", "bare-a", "--json"], hTty.deps)).toBe(0); + expect(JSON.parse(String(hTty.calls[2]?.init?.body))).toEqual({ alias: "bare-a", fingerprint, plant: true }); + } finally { hTty.restore(); } + }); + + test("surfaces actionable messages for child_port_busy and child_external_provider_conflict", async () => { + for (const [code, snippet] of [ + ["child_port_busy", "already in use"], + ["child_external_provider_conflict", "~/.codex/config.toml"], + ] as const) { + let step = 0; + const h = harness(); + try { + h.deps.fetchImpl = async () => ++step < 3 ? Response.json(step === 1 ? probe : confirm) : Response.json({ error: { code } }, { status: 409 }); + expect(await runLinkCommand(["add", "child-a", "--expect-fingerprint", fingerprint], h.deps)).not.toBe(0); + expect(h.err.join("\n")).toContain(snippet); + } finally { h.restore(); } + } + }); }); diff --git a/tests/cli/cli-link-force.test.ts b/tests/cli/cli-link-force.test.ts index bcc785278fd..7fb0b658bbc 100644 --- a/tests/cli/cli-link-force.test.ts +++ b/tests/cli/cli-link-force.test.ts @@ -138,4 +138,20 @@ describe("explicit forced link revoke", () => { expect(first).toBe(1); expect(second).toBe(0); expect(output).not.toHaveBeenCalled(); } finally { await origin.stop(true); await destination.stop(true); } }); + + test("revoke --purge sends { purge: true }, rejects --force + --purge, and forced ephemeral revoke prints ocx-child detach recovery", async () => { + const f = fixture(); + expect(await invoke(["--purge", "--json"], f.deps)).toBe(0); + expect(JSON.parse(String(f.calls[0]?.init?.body))).toEqual({ purge: true }); + + const fBoth = fixture(); + expect(await invoke(["--purge", "--force", "--yes"], fBoth.deps)).toBe(2); + expect(fBoth.calls).toEqual([]); + + output.mockClear(); + const fEph = fixture(); + fEph.deps.readStore = () => ({ version: 1, listenerPort: 18181, links: [{ id: ID, alias: "barebox", direction: "hub-initiated", hostKeyFingerprint: "SHA256:abcdefghijklmnop", tunnelPort: 24500, apiKeyId: "k1", createdAt: "2026-11-07T00:00:00Z", childKind: "ephemeral" }] }); + expect(await invoke(["--force", "--yes", "--json"], fEph.deps)).toBe(0); + expect(json()).toEqual({ linkId: ID, remoteCleanup: "skipped", recovery: "Run ~/.cache/opencodex/bin/ocx-child detach on the remote client." }); + }); }); From c8adfb222414d5dac576d227369443cc6cc123f9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 09:01:38 +0900 Subject: [PATCH 15/50] docs(link): document zero-preinstall SSH plant, --purge, and #remote UX --- .../001_audit_and_status.md | 8 ++++---- .../040_wp4_docs_screenshots_and_pr.md | 9 +++++++++ docs-site/src/content/docs/guides/remote-link.md | 12 ++++++------ docs-site/src/content/docs/ko/guides/remote-link.md | 8 ++++---- structure/remote-link.md | 6 ++++-- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md b/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md index 2b155dc668d..675a92bd818 100644 --- a/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md +++ b/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md @@ -19,8 +19,8 @@ | Work Phase | Status | Summary & Verification Receipt | |---|---|---| | `wp0` | DONE | Docs-first roadmap (`000_roadmap.md`, `001_audit_and_status.md`, `010..040`) completed, audited (`VERDICT: PASS`), and verified with `privacy:scan` + `structure:check` (`exit 0`); zero production files modified | -| `wp1` | PENDING | Rust `native/ocx-child` `--purge` & conflict prefixes + `src/link/` foundations | -| `wp2` | PENDING | Home Management API `confirm-host`, `apply` plant/rollback, `status`, and `DELETE --purge` | -| `wp3` | PENDING | CLI (`--plant`, `--purge`) & `#remote` GUI + 11 locales | -| `wp4` | PENDING | SoT docs, `origin/pr-assets` screenshots, full gates, stacked PR, CI watch & `pr-record` | +| `wp1` | DONE | Rust `native/ocx-child` `--purge` & conflict prefixes + `src/link/` foundations (`9/9` Cargo tests, `51/51` Bun foundation & layout tests) | +| `wp2` | DONE | Home Management API `confirm-host`, `apply` plant/rollback, `status`, and `DELETE --purge` (`90/90` server, projection, and real-binary E2E tests) | +| `wp3` | DONE | CLI (`--plant`, `--purge`) & `#remote` GUI + 11 locales (`82/82` CLI link tests, `3738/3738` GUI tests, `lint:i18n`, `lint`, `build:gui`) | +| `wp4` | IN_PROGRESS | SoT docs (`structure/remote-link.md`, `docs-site/`), `origin/pr-assets` screenshots (`4d57c948b78bc66f2fb06b01cd9eb947766f1a60`), `35729/35729` `test:changed` pass, stacked PR & CI watch | diff --git a/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md b/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md index c73f2f381f2..196978b1c7a 100644 --- a/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md +++ b/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md @@ -37,3 +37,12 @@ - If `MERGED`: rebase cleanly onto `origin/dev`, re-verify gates, push, and open PR with `--base dev`. - Call `goat_pull_request_link`, watch via `goat_pull_request_watch` until required exact-head checks and automated reviews complete, address any valid findings, record `cxc loop pr-record --session 01a1b2d6-b6ca-76c3-8e6c-e00f7449b965 --pr `, run `cxc loop validate`, call `goat_pull_request_unwatch`, and leave PR unmerged. +## 3. `wp4` P-Phase Continuity & Architect Reflection Amendments (`01a1b33c-63c3-7410-9ced-7be53251867e`) + +- **Continuity from `wp3` D**: Resumed from `wp3` D conclusion ("Next cycle wp4 syncs structure/ + docs-site/, captures #remote screenshots to origin/pr-assets, opens the stacked PR, watches CI/reviews, and records pr-record"). +- **Architect `01a1b33c-63c3-7410-9ced-7be53251867e` verdict**: `ALIGNED` on `W4-ARCH-01..W4-ARCH-03`. +- **Folded guards**: + 1. Edit `structure/remote-link.md` `## Ephemeral Rust Child (ocx-child)` in-place (replacing `409 child_restart_unsupported` with `409 child_port_busy`, documenting `409 child_external_provider_conflict`, `src/link/child-binary-source.ts`, `src/link/remote-plant.ts`, `--purge`, and `LinkRecord.childKind`, and keeping total line count comfortably below `manifest.json` cap). + 2. Include `bun run skill:surface:check` and `tests/ci-workflows/docs-link-targets.test.ts` in the `wp4` verification floor. + 3. Update `docs-site/src/content/docs/guides/remote-link.md` and `docs-site/src/content/docs/ko/guides/remote-link.md` (plus check locale pages for non-contradiction). + diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 8fb6e85d98f..9dc03c321fa 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -9,7 +9,7 @@ A machine link connects an OpenCodex **Home** computer to a **Child** computer o - The Home computer can log in to the Child with an OpenSSH key. - For a Child-initiated link, the Child can log in to Home with an OpenSSH key (password login is not supported). -- OpenCodex 2.66.0 or later is installed on the Child computer, and on Home for a Child-initiated link. +- OpenCodex 2.66.0 or later on Home (and on the Child for a Child-initiated link or full Child install). When adding a Child from Home, a bare SSH host needs no pre-installed Node, Bun, or OpenCodex: Home can plant the standalone lightweight `ocx-child` binary (~0.9 MB) over SSH during onboarding. - Both computers run macOS, Linux, or Windows x64. Windows requires OpenSSH Server and Windows PowerShell; Git Bash and curl are not required. - The dashboard that adds a Child from Home is opened on Home itself or through a paired Hub session. Turning the current computer into a Child requires an operator-paired dashboard session; a credentialless local dashboard session cannot commit that routing change. @@ -21,7 +21,7 @@ Password SSH is not supported. Links can be started from Home or Child. Windows 2. Choose **Home**, then **Continue**. The SSH host list opens. 3. Choose a host from the SSH candidates, or enter an SSH config alias. 4. Run the connection test and compare the offered host fingerprint with the fingerprint for the machine you intend to use. Comparing it helps detect a wrong host or a changed host key before SSH trusts the host. -5. Confirm the fingerprint, then connect the Child. +5. Confirm the fingerprint, then choose **Connect child** (or **Plant lightweight agent & connect** when connecting a bare SSH host that does not have OpenCodex installed yet). The dashboard does not ask you to enter a token. It probes the host first, and it cannot apply the link until you explicitly confirm the fingerprint. @@ -89,12 +89,12 @@ These reads need an OpenCodex version with the Remote Link management relay on b ## Remove a Child -Select **Disconnect** for the Child and confirm the alias. The Home stops the tunnel, revokes that Child's link key, and removes the saved link record. +Select **Disconnect** for the Child and confirm the alias. The Home stops the tunnel, revokes that Child's link key, and removes the saved link record. On a lightweight (`ocx-child`) Child, disconnecting restores `~/.codex` and stops the relay with zero background processes while keeping `~/.cache/opencodex/bin/ocx-child` cached by default for fast reconnects; check **Also delete the planted lightweight agent binary from the remote computer** (or pass `--purge` to `ocx link revoke`) to delete the planted binary as well. If the Home cannot reach the Child to run its disconnect command, choose **Remove here only**. This removes the local tunnel, key, and record. Then log in to the Child and run: ```bash -ocx disconnect +ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight Child ``` To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` @@ -189,9 +189,9 @@ 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`. 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. +For disposable SSH devboxes, containers and lightweight cloud VMs where installing the full Bun OpenCodex runtime is unnecessary, Home can stream a SHA-256-verified standalone `ocx-child` binary (~0.9 MB, zero Node/Bun required) directly over SSH `stdin` into `~/.cache/opencodex/bin/ocx-child` (`%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` on Windows) when you choose **Plant lightweight agent & connect** in `#remote` or run `ocx link add --plant --expect-fingerprint SHA256:…` (interactive TTY `ocx link add ` prompts once before planting; air-gapped Children with zero outbound internet work out of the box). You can also pre-place `ocx-child` in `~/.local/bin/ocx-child`, `~/.cache/opencodex/bin/ocx-child`, or `PATH`. Before enrollment, Home probes every full `ocx` candidate first and falls back to `ocx-child` when no runnable full `ocx` is present; once `$OPENCODEX_HOME/child/state.json` exists, remote commands keep targeting `ocx-child` until it detaches. -Unlike a full `ocx` Child, a clean detached `ocx-child` host needs no pre-running background daemon before `ocx link add`: Home refuses any HTTP 200 `/healthz` listener on the target port during preflight, pipes the link credential over SSH stdin (`ocx-child connect` verifies TCP bind availability and Home compensates if the port is occupied), starts `ocx-child serve`, and confirms an authenticated `/v1/catalog` relay. Home `ocx sync` fan-out (`sync --expect-link --json`) refreshes the Child's catalog and `models_cache.json` (retaining the last cached catalog as `stale` if Home is unreachable), ensures `ocx-child serve` is running at or after `lastApplyAt`, and posts OCX-6 parity on a best-effort basis when fresh (while `ocx-child serve` also reports periodically in the background). Running `ocx link revoke` on Home disconnects the Child, revokes its key, and removes the Home record; running offline `ocx-child detach` or `ocx-child disconnect` directly on the Child stops `ocx-child serve` with zero leftover daemon and restores `~/.codex` locally, so also run `ocx link revoke --link-id ` (or `ocx link revoke --link-id --force --yes` if the Child is unreachable) on Home to remove the Home link record and key. +Unlike a full `ocx` Child, a clean detached `ocx-child` host needs no pre-running background daemon before `ocx link add`: Home checks target-port availability and `~/.codex/config.toml` ownership before committing (`409 child_port_busy` if port `10100` is held by another process, or `409 child_external_provider_conflict` if another model provider or custom base URL already owns `config.toml`, leaving Child files untouched and rolling back any newly planted binary), pipes the link credential over SSH stdin, starts `ocx-child serve`, and confirms an authenticated `/v1/catalog` relay. Connected rows in `#remote` display a **Lightweight** / **Full install** badge and a **Home settings synced** / **Sync needed** parity badge. Running `ocx link revoke --link-id ` keeps the cached binary for fast re-attach, whereas `ocx link revoke --link-id --purge` (or `ocx-child detach --purge` on the Child) also deletes the planted binary from the managed cache directory. ### Windows computers diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index 1a0df583ecf..660ac8007e1 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -61,12 +61,12 @@ Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다. ## Child 제거하기 -Child의 **Disconnect**를 선택하고 alias를 확인합니다. Home은 터널을 중지하고 해당 Child의 링크 키를 폐기한 뒤 저장된 링크 기록을 삭제합니다. +Child의 **Disconnect**를 선택하고 alias를 확인합니다. Home은 터널을 중지하고 해당 Child의 링크 키를 폐기한 뒤 저장된 링크 기록을 삭제합니다. 경량 에이전트(`ocx-child`)로 연결된 Child는 기본적으로 빠른 재연결을 위해 `~/.cache/opencodex/bin/ocx-child` 파일을 남겨두며, 확인 대화상자에서 **심어둔 경량 에이전트 파일까지 원격 컴퓨터에서 함께 삭제**를 선택하거나 `ocx link revoke --link-id --purge`를 실행하면 에이전트 파일까지 함께 삭제합니다. Home에서 Child의 연결 해제 명령을 실행할 수 없으면 **Remove here only**를 선택합니다. 그러면 이 컴퓨터의 터널, 키, 기록만 삭제됩니다. 그 다음 Child에 로그인하여 실행합니다. ```bash -ocx disconnect +ocx disconnect # 경량 모듈 Child인 경우 ~/.cache/opencodex/bin/ocx-child detach ``` 자식 연결을 끊으려면 자식에서 `ocx disconnect`를 실행합니다. 이 명령은 클라이언트 터널을 끊고 SSH를 통해 홈에서 링크를 폐기합니다. 홈에서 폐기하지 못하면 다음 문구를 출력합니다: `Home revoke failed; run ocx link revoke --link-id on the home.` @@ -79,11 +79,11 @@ Child는 링크를 통해 Home 컴퓨터의 프로바이더와 프로바이더 ```text ocx link probe [--json] -ocx link add [--expect-fingerprint SHA256:…] [--json] +ocx link add [--plant] [--expect-fingerprint SHA256:…] [--json] ocx link port [--json] ocx link issue --alias --tunnel-port [--json] ocx link status [--json] -ocx link revoke --link-id [--json] +ocx link revoke --link-id [--purge] [--force --yes] [--json] ocx link reconnect --link-id [--json] ``` diff --git a/structure/remote-link.md b/structure/remote-link.md index 4be3ea0af0b..d808a21f8ce 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -188,6 +188,8 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src `native/ocx-child/Cargo.toml` and `native/ocx-child/src/lib.rs` build a standalone lightweight Rust binary (`ocx-child`) for disposable SSH devboxes, containers and VMs that do not run a persistent Bun OpenCodex install. Remote candidate discovery in `src/link/ssh-argv.ts` and `src/link/remote-windows.ts` tries `ocx-child` first when `$OPENCODEX_HOME/child/state.json` is present (preserving an active `ocx-child` enrollment even if full `ocx` is installed later), and otherwise tries every full `ocx` path before falling back to `ocx-child` in `~/.local/bin`, `~/.cache/opencodex/bin` (plus `%LOCALAPPDATA%\opencodex\bin` on Windows) and `PATH`, keeping Windows encoded PowerShell below `POWERSHELL_ENCODED_MAX` (`8000`). -When detached, `ocx-child config show --json` reports `{"port": , "runtimeRole": "standalone", "ephemeralChild": true}`, recognized by `isDetachedEphemeralChildConfig` in `src/link/ephemeral-child.ts`. During `POST /api/link/apply`, `src/server/management/link-enrollment.ts` accepts that clean detached state without a pre-running standalone daemon, refuses `409 child_restart_unsupported` before key issuance if the `/healthz` HTTP probe returns `200` on `` (`502 child_preflight_failed` on an SSH transport drop; non-200 foreign listeners are rejected by `ocx-child connect`'s `TcpListener::bind` port check and compensated by Home), enrolls through `connect --link --key-stdin`, starts `ocx-child serve` via `restart`, and verifies `role: "client"` `/healthz` plus an authenticated `/v1/catalog` callback. +When `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVersion: true`) or `POST /api/link/confirm-host`, `src/link/remote-plant.ts` inspects the remote target triple (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`) and classifies `runtimeKind` as `"full"`, `"ephemeral"`, or `"missing"`, while `src/link/child-binary-source.ts` resolves a non-symlink `ocx-child` artifact (`OPENCODEX_CHILD_BIN_DIR`, packaged sidecars, host-matching local Cargo release builds of `native/ocx-child`, or `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//`). On a bare host (`runtimeKind: "missing"`), `POST /api/link/apply` requires explicit `plant: true`, streams the verified binary over SSH `stdin` into `$HOME/.cache/opencodex/bin/ocx-child` (`0700` atomic write on POSIX) or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` (inside `OcxJob` on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via `remotePurgePlantedChildArgv` if enrollment fails. -`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`. +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, maps foreign listeners and external `~/.codex/config.toml` ownership (`ocx-child-conflict:*`) to `409 child_port_busy` and `409 child_external_provider_conflict` (`502 child_preflight_failed` on an SSH transport drop), persists `childKind: "ephemeral" | "full"` on `LinkRecord` (`src/link/store.ts`, projected on `GET /api/link/status` in `src/link/status-projection.ts` alongside `gui-session` `homeCodexSettingsDigest`), 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), removes the empty `child` state directory, and with `--purge` (`DELETE /api/link/:id` `{ "purge": true }` or `ocx link revoke --purge`) also removes the binary when it resides inside the managed plant directory (`~/.cache/opencodex/bin/ocx-child` or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, refusing symlinks and foreign paths). 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/clients/link-child-plant-foundations.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/link-ocx-child-e2e.test.ts`, `gui/tests/remote-link.test.tsx`. From 50e0c99bf9bea70ac84717e609c60bb55fd6d748 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 09:06:22 +0900 Subject: [PATCH 16/50] fix(link): populate PowerShell best-effort catch blocks for pr-hygiene --- src/link/remote-windows.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index 5b58c9c5bfa..4fb7a8acd41 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -152,7 +152,7 @@ $k=@("$u\.local\bin\ocx-child.exe","$u\.cache\opencodex\bin\ocx-child.exe");if($ $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[] { - const sweep = args.includes("--purge") ? String.raw`foreach($r in @($env:LOCALAPPDATA,$env:USERPROFILE)){if($r){foreach($s in @("$r\opencodex\bin","$r\.cache\opencodex\bin")){if([IO.Directory]::Exists($s)){Get-ChildItem -LiteralPath $s -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{}}}}}};` : ""; + const sweep = args.includes("--purge") ? String.raw`foreach($r in @($env:LOCALAPPDATA,$env:USERPROFILE)){if($r){foreach($s in @("$r\opencodex\bin","$r\.cache\opencodex\bin")){if([IO.Directory]::Exists($s)){Get-ChildItem -LiteralPath $s -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{$null=$_}}}}}};` : ""; const run = sweep ? `try{$c=[OcxJob]::Run($x,((@($x)+$d.args|%{Quote-Native $_}) -join ' '))}catch{Fail};${sweep}exit $c` : `Invoke-Native $x @($d.args)`; return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args }) + OCX_CANDIDATES + String.raw` foreach($x in ($c|select -Unique)){if([IO.File]::Exists($x)-and[OcxJob]::Ok($x)){` + run + String.raw`}} @@ -185,13 +185,13 @@ $sha=([BitConverter]::ToString($h.Hash)).Replace('-','').ToLowerInvariant();$h.D if($tot -ne $lim -or $sha -ne $exp){throw 'integrity'} [IO.File]::Copy($tmp,$dst,$true);[IO.File]::Delete($tmp);$tmp=$null $rc=[OcxJob]::Run($dst,('"'+$dst+'" --version'));if($rc -ne 0){[IO.File]::Delete($dst);exit 1};exit 0 -}catch{if($tmp -and [IO.File]::Exists($tmp)){try{[IO.File]::Delete($tmp)}catch{}};[Console]::Error.WriteLine('Windows ocx-child plant failed.');exit 1} +}catch{if($tmp -and [IO.File]::Exists($tmp)){try{[IO.File]::Delete($tmp)}catch{$null=$_}};[Console]::Error.WriteLine('Windows ocx-child plant failed.');exit 1} `); } export function windowsPurgePlantedChildArgv(): string[] { return packedPowerShellArgv(NATIVE_PROCESS + WINDOWS_MANAGED_BIN + String.raw` -try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){if([OcxJob]::Ok($dst)){[void][OcxJob]::Run($dst,('"'+$dst+'" detach --purge'))};try{[IO.File]::Delete($dst)}catch{}};Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{}}}catch{exit 1};exit 0 +try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){if([OcxJob]::Ok($dst)){[void][OcxJob]::Run($dst,('"'+$dst+'" detach --purge'))};try{[IO.File]::Delete($dst)}catch{$null=$_}};Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{$null=$_}}}catch{exit 1};exit 0 `); } From 71352d39e1f7adaa0e01e155fa5250a8ccb2d344 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 09:09:19 +0900 Subject: [PATCH 17/50] fix(link): preserve recovery binary on failed purge rollback and stabilize child.lock inode --- native/ocx-child/src/cli.rs | 4 +--- native/ocx-child/src/state.rs | 2 +- src/link/remote-plant.ts | 13 +++++++++++-- src/link/remote-windows.ts | 3 ++- tests/clients/link-child-plant-foundations.test.ts | 9 +++++++++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 14bcb7747b0..da093246838 100644 --- a/native/ocx-child/src/cli.rs +++ b/native/ocx-child/src/cli.rs @@ -1020,10 +1020,8 @@ pub fn detach_with_exe(args: &[String], current_exe_override: Option<&Path>) -> }; let _ = fs::remove_file(&paths.key_file); let _ = fs::remove_file(&paths.state_file); - drop(lock); - let _ = fs::remove_file(&paths.lock_file); let _ = fs::remove_dir(&paths.child_dir); - let _ = fs::remove_dir(&paths.opencodex_home); + drop(lock); let mut purged = false; if purge { let exe = match current_exe_override { diff --git a/native/ocx-child/src/state.rs b/native/ocx-child/src/state.rs index e5657bc0c5c..927a2a25553 100644 --- a/native/ocx-child/src/state.rs +++ b/native/ocx-child/src/state.rs @@ -25,7 +25,7 @@ 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"), + lock_file: opencodex_home.join("child.lock"), state_file: child_dir.join("state.json"), key_file: child_dir.join("link-key"), journal_file: child_dir.join("codex-journal.json"), diff --git a/src/link/remote-plant.ts b/src/link/remote-plant.ts index 1dcfee7ea9f..94ff52e44c9 100644 --- a/src/link/remote-plant.ts +++ b/src/link/remote-plant.ts @@ -179,9 +179,18 @@ export const POSIX_PURGE_PLANTED_SCRIPT = [ " if [ -L \"$p\" ]; then exit 1; fi", "done", "dest=\"$HOME/.cache/opencodex/bin/ocx-child\"", + "child_dir=\"${OPENCODEX_HOME:-$HOME/.opencodex}/child\"", "if [ -f \"$dest\" ]; then", - " if [ -x \"$dest\" ]; then \"$dest\" detach --purge >/dev/null 2>&1 || true; fi", - " rm -f \"$dest\" 2>/dev/null || true", + " if [ -x \"$dest\" ]; then", + " \"$dest\" detach --purge >/dev/null || exit $?", + " rm -f \"$dest\" 2>/dev/null || true", + " elif [ -f \"$child_dir/state.json\" ] || [ -f \"$child_dir/codex-journal.json\" ]; then", + " exit 1", + " else", + " rm -f \"$dest\" 2>/dev/null || true", + " fi", + "elif [ -f \"$child_dir/state.json\" ] || [ -f \"$child_dir/codex-journal.json\" ]; then", + " exit 1", "fi", "rmdir \"$HOME/.cache/opencodex/bin\" \"$HOME/.cache/opencodex\" 2>/dev/null || true", "exit 0", diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index 4fb7a8acd41..be51b47d316 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -191,7 +191,8 @@ $rc=[OcxJob]::Run($dst,('"'+$dst+'" --version'));if($rc -ne 0){[IO.File]::Delete export function windowsPurgePlantedChildArgv(): string[] { return packedPowerShellArgv(NATIVE_PROCESS + WINDOWS_MANAGED_BIN + String.raw` -try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){if([OcxJob]::Ok($dst)){[void][OcxJob]::Run($dst,('"'+$dst+'" detach --purge'))};try{[IO.File]::Delete($dst)}catch{$null=$_}};Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{$null=$_}}}catch{exit 1};exit 0 +$r=if($env:HOME){$env:HOME}elseif($env:USERPROFILE){$env:USERPROFILE}else{"."};$h=if($env:OPENCODEX_HOME){$env:OPENCODEX_HOME}else{"$r\.opencodex"};$cd="$h\child" +try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){if([OcxJob]::Ok($dst)){$rc=[OcxJob]::Run($dst,('"'+$dst+'" detach --purge'));if($rc -ne 0){exit $rc};try{[IO.File]::Delete($dst)}catch{$null=$_}}elseif([IO.File]::Exists("$cd\state.json")-or[IO.File]::Exists("$cd\codex-journal.json")){exit 1}else{try{[IO.File]::Delete($dst)}catch{$null=$_}}}elseif([IO.File]::Exists("$cd\state.json")-or[IO.File]::Exists("$cd\codex-journal.json")){exit 1};Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{$null=$_}}}catch{exit 1};exit 0 `); } diff --git a/tests/clients/link-child-plant-foundations.test.ts b/tests/clients/link-child-plant-foundations.test.ts index 98161643038..af0ed1516bb 100644 --- a/tests/clients/link-child-plant-foundations.test.ts +++ b/tests/clients/link-child-plant-foundations.test.ts @@ -196,6 +196,15 @@ describe("remote-plant POSIX & Windows command contracts", () => { expect(purged.exitCode).toBe(0); expect(existsSync(plantedPath)).toBe(false); + // Preserves planted binary and returns nonzero when detach --purge fails (e.g. exit 4 conflict) + const failingScript = Buffer.from("#!/bin/sh\nif [ \"$1\" = \"detach\" ]; then exit 4; fi\necho \"opencodex 2.98.0\"\n", "utf8"); + const failingSha = createHash("sha256").update(failingScript).digest("hex"); + const failPlantArgv = remotePlantChildArgv("posix", failingScript.byteLength, failingSha); + expect(Bun.spawnSync([failPlantArgv[0]!, ...failPlantArgv.slice(1)], { env, stdin: failingScript }).exitCode).toBe(0); + const failedPurge = Bun.spawnSync([purgeArgv[0]!, ...purgeArgv.slice(1)], { env }); + expect(failedPurge.exitCode).toBe(4); + expect(existsSync(plantedPath)).toBe(true); + // Refuses symlinked .cache ancestor const symHome = join(scratch, "sym-remote-home"); const targetCache = join(scratch, "real-cache"); From 75dc0635f2e1be3a8a7c757f7319fa4bef1afa1e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 09:48:30 +0900 Subject: [PATCH 18/50] test(link): stage compiled ocx-child with SHA256SUMS for bare-host E2E on CI --- tests/clients/link-ocx-child-e2e.test.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/clients/link-ocx-child-e2e.test.ts b/tests/clients/link-ocx-child-e2e.test.ts index 07d67e0d189..dfdde80707a 100644 --- a/tests/clients/link-ocx-child-e2e.test.ts +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; 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"; @@ -8,6 +9,7 @@ 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 { childArtifactFilename, hostChildTargetTriple } from "../../src/link/child-binary-source"; import { codexSettingsDigest, projectHomeCodexSettings } from "../../src/link/codex-settings"; import { linkStorePath } from "../../src/link/paths"; import { LINK_RELAY_SESSION_HEADER } from "../../src/link/relay-auth"; @@ -470,7 +472,22 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- }, 30_000); test("streams verified ocx-child binary onto a bare Child host over SSH stdin, relays requests, and removes the planted binary on DELETE --purge", async () => { - findOrBuildOcxChildBinary(); + const builtBinary = findOrBuildOcxChildBinary(); + const hostTriple = hostChildTargetTriple(); + if (!hostTriple) return; + const stagedBinDir = join(scratch, "staged-child-bin"); + mkdirSync(stagedBinDir, { recursive: true, mode: 0o700 }); + chmodSync(stagedBinDir, 0o700); + const artifactName = childArtifactFilename(hostTriple); + const stagedBinPath = join(stagedBinDir, artifactName); + copyFileSync(builtBinary, stagedBinPath); + chmodSync(stagedBinPath, 0o700); + const stagedSha = createHash("sha256").update(readFileSync(stagedBinPath)).digest("hex"); + const sumsPath = join(stagedBinDir, "SHA256SUMS"); + writeFileSync(sumsPath, `${stagedSha} ${artifactName}\n`, { mode: 0o600 }); + chmodSync(sumsPath, 0o600); + const prevBinDir = process.env.OPENCODEX_CHILD_BIN_DIR; + process.env.OPENCODEX_CHILD_BIN_DIR = stagedBinDir; const childUserHome = join(scratch, "bare-child-home"); const childOcxHome = join(childUserHome, ".opencodex"); const childCodexHome = join(childUserHome, ".codex"); @@ -633,6 +650,8 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- expect(existsSync(join(childOcxHome, "child"))).toBe(false); expect(readFileSync(join(childCodexHome, "config.toml"), "utf8")).toBe("approval_policy = \"on-request\"\n"); } finally { + if (prevBinDir === undefined) delete process.env.OPENCODEX_CHILD_BIN_DIR; + else process.env.OPENCODEX_CHILD_BIN_DIR = prevBinDir; if (existsSync(plantedPath)) await runShellAsync([plantedPath, "disconnect", "--purge"]); relaySessions.abortReservations(); await homeHubLinkServer.stop(true); From e40ab37bb94b249247b9fc2741581a000933fbf6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 7 Nov 2026 23:14:28 +0900 Subject: [PATCH 19/50] 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 20/50] 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 21/50] 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 22/50] 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 23/50] 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 24/50] 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 25/50] 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 26/50] 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 27/50] 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 28/50] 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 29/50] 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 30/50] 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 ac4be3185c09c27b50b2040354d26be2bc0e7098 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:16:52 +0900 Subject: [PATCH 31/50] fix(link): refuse to plant ocx-child when the Child cannot verify its SHA-256 The POSIX plant script skipped the digest check when neither sha256sum nor shasum existed and then ran the binary after a size check alone. It now also accepts openssl dgst, and with no hash tool it exits 3 before the binary is installed or executed, printing ocx-plant-error:hash_tool_unavailable. Home maps that marker to 502 remote_plant_failed with hint hash_tool_unavailable. The Windows plant already streams SHA-256 before running the binary. --- src/link/remote-plant.ts | 18 ++++++- src/server/management/link-enrollment.ts | 8 ++- structure/remote-link.md | 2 +- .../link-child-plant-foundations.test.ts | 51 ++++++++++++++++++- .../link-enrollment-ephemeral-child.test.ts | 25 +++++++++ 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/link/remote-plant.ts b/src/link/remote-plant.ts index 94ff52e44c9..55206f51686 100644 --- a/src/link/remote-plant.ts +++ b/src/link/remote-plant.ts @@ -120,6 +120,9 @@ export async function inspectRemoteChildRuntime( return parseRemoteRuntimeInspection(platform, res.stdout); } +/** Printed on stderr by the POSIX plant script when no tool can verify the binary's SHA-256. */ +export const PLANT_HASH_TOOL_UNAVAILABLE_MARKER = "ocx-plant-error:hash_tool_unavailable"; + export const POSIX_PLANT_SCRIPT = [ "case \"$(uname -s 2>/dev/null)\" in Windows*|MSYS*|MINGW*|CYGWIN*) exit 125;; esac", "umask 077", @@ -139,13 +142,19 @@ export const POSIX_PLANT_SCRIPT = [ " printf 'Planted binary size mismatch: expected %s got %s\\n' \"$expected_bytes\" \"$actual_bytes\" >&2", " exit 1", "fi", + // The digest must be checked before the binary is ever executed; with no tool to check it the + // plant fails closed and the temporary file is removed by the EXIT trap. "if command -v sha256sum >/dev/null 2>&1; then", " actual_sha=$(sha256sum \"$tmp\" | awk '{print tolower($1)}')", - " if [ \"$actual_sha\" != \"$expected_sha\" ]; then printf 'Planted binary SHA-256 mismatch\\n' >&2; exit 1; fi", "elif command -v shasum >/dev/null 2>&1; then", " actual_sha=$(shasum -a 256 \"$tmp\" | awk '{print tolower($1)}')", - " if [ \"$actual_sha\" != \"$expected_sha\" ]; then printf 'Planted binary SHA-256 mismatch\\n' >&2; exit 1; fi", + "elif command -v openssl >/dev/null 2>&1; then", + " actual_sha=$(openssl dgst -sha256 \"$tmp\" | awk '{print tolower($NF)}')", + "else", + ` printf '${PLANT_HASH_TOOL_UNAVAILABLE_MARKER}\\nNo sha256sum, shasum, or openssl on this host to verify the ocx-child binary.\\n' >&2`, + " exit 3", "fi", + "if [ \"$actual_sha\" != \"$expected_sha\" ]; then printf 'Planted binary SHA-256 mismatch\\n' >&2; exit 1; fi", "chmod 0700 \"$tmp\" || exit 1", "mv -f \"$tmp\" \"$dest\" || exit 1", "trap - EXIT HUP INT TERM", @@ -202,6 +211,11 @@ export function remotePurgePlantedChildArgv(platform: "posix" | "windows" = "pos : ["sh", "-c", POSIX_PURGE_PLANTED_SCRIPT, "ocx-purge-plant"]; } +/** The distinct reason a plant failed on the Child, when the plant script reported one. */ +export function classifyPlantFailure(stderr: string): "hash_tool_unavailable" | null { + return stderr.includes(PLANT_HASH_TOOL_UNAVAILABLE_MARKER) ? "hash_tool_unavailable" : null; +} + export function classifyChildAttachConflict(stderr: string): ChildAttachConflictCode | null { if (stderr.includes("ocx-child-conflict:child_port_busy:")) return "child_port_busy"; if (stderr.includes("ocx-child-conflict:child_external_provider_conflict:")) return "child_external_provider_conflict"; diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index 49a18a08032..3a6c9964341 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -5,7 +5,7 @@ import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, RE import { resolveChildBinarySource } from "../../link/child-binary-source"; import { parseFingerprintLine } from "../../link/fingerprint"; import { isDetachedEphemeralChildConfig } from "../../link/ephemeral-child"; -import { classifyChildAttachConflict, inspectRemoteChildRuntime, remotePlantChildArgv, remotePurgePlantedChildArgv } from "../../link/remote-plant"; +import { classifyChildAttachConflict, classifyPlantFailure, inspectRemoteChildRuntime, remotePlantChildArgv, remotePurgePlantedChildArgv } from "../../link/remote-plant"; import { requireRemotePlatform } from "../../link/remote-platform"; import { LINK_CATALOG_MAX_BYTES, LINK_RELAY_VERIFY_MS, isChildHealth, linkCommandBudget, linkRestartBudget, parseChildHttp, remoteChildHttpArgv, REMOTE_CURL_PREFLIGHT } from "../../link/remote-probe"; import { newLinkId, type LinkStore } from "../../link/store"; @@ -270,7 +270,11 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom } return fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshRunnerErrorHint(plantErr)); } - if (planted.code !== 0) return fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshFailureHint(planted.stderr)); + if (planted.code !== 0) { + const reason = classifyPlantFailure(planted.stderr); + if (reason) return fail("remote_plant_failed", "The Child has no sha256sum, shasum, or openssl to verify the ocx-child binary, so it was not installed.", 502, reason); + return fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshFailureHint(planted.stderr)); + } plantedFreshBinary = true; ephemeralChild = true; } diff --git a/structure/remote-link.md b/structure/remote-link.md index a6c7df8e168..47a0778075e 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -188,7 +188,7 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src `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 `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVersion: true`) or `POST /api/link/confirm-host`, `src/link/remote-plant.ts` inspects the remote target triple (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`) and classifies `runtimeKind` as `"full"`, `"ephemeral"`, or `"missing"`, while `src/link/child-binary-source.ts` resolves a non-symlink `ocx-child` artifact (`OPENCODEX_CHILD_BIN_DIR`, packaged sidecars, host-matching local Cargo release builds of `native/ocx-child`, or `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//`). On a bare host (`runtimeKind: "missing"`), `POST /api/link/apply` requires explicit `plant: true`, streams the verified binary over SSH `stdin` into `$HOME/.cache/opencodex/bin/ocx-child` (`0700` atomic write on POSIX) or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` (inside `OcxJob` on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via `remotePurgePlantedChildArgv` if enrollment fails. +When `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVersion: true`) or `POST /api/link/confirm-host`, `src/link/remote-plant.ts` inspects the remote target triple (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`) and classifies `runtimeKind` as `"full"`, `"ephemeral"`, or `"missing"`, while `src/link/child-binary-source.ts` resolves a non-symlink `ocx-child` artifact (`OPENCODEX_CHILD_BIN_DIR`, packaged sidecars, host-matching local Cargo release builds of `native/ocx-child`, or `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//`). On a bare host (`runtimeKind: "missing"`), `POST /api/link/apply` requires explicit `plant: true`, streams the verified binary over SSH `stdin` into `$HOME/.cache/opencodex/bin/ocx-child` (`0700` atomic write on POSIX, only after the size and the SHA-256 match; the digest is checked with `sha256sum`, `shasum` or `openssl dgst`, and with none of them the plant exits before installing or running anything and Home answers `502 remote_plant_failed` with hint `hash_tool_unavailable`) or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` (inside `OcxJob` on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via `remotePurgePlantedChildArgv` if enrollment fails. 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, maps foreign listeners and external `~/.codex/config.toml` ownership (`ocx-child-conflict:*`) to `409 child_port_busy` and `409 child_external_provider_conflict` (`502 child_preflight_failed` on an SSH transport drop), persists `childKind: "ephemeral" | "full"` on `LinkRecord` (`src/link/store.ts`, projected on `GET /api/link/status` in `src/link/status-projection.ts` alongside `gui-session` `homeCodexSettingsDigest`), enrolls through `connect --link --key-stdin`, starts `ocx-child serve` via `restart`, and verifies `role: "client"` `/healthz` plus an authenticated `/v1/catalog` callback. diff --git a/tests/clients/link-child-plant-foundations.test.ts b/tests/clients/link-child-plant-foundations.test.ts index af0ed1516bb..658d85c8941 100644 --- a/tests/clients/link-child-plant-foundations.test.ts +++ b/tests/clients/link-child-plant-foundations.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -218,5 +218,52 @@ describe("remote-plant POSIX & Windows command contracts", () => { }); expect(symPlant.exitCode).not.toBe(0); }); -}); + // A bin directory exposing only the named tools, so the plant script sees exactly these. + function restrictedBin(dir: string, tools: readonly string[]): string { + mkdirSync(dir, { recursive: true }); + for (const tool of tools) { + const found = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"].map(d => join(d, tool)).find(p => existsSync(p)); + if (!found) throw new Error("missing host tool " + tool); + symlinkSync(found, join(dir, tool)); + } + return dir; + } + const PLANT_BASE_TOOLS = ["uname", "mkdir", "chmod", "cat", "wc", "awk", "rm", "mv"] as const; + + test("POSIX plant refuses to install or execute a binary when no SHA-256 tool can verify it", () => { + if (process.platform === "win32") return; + const fakeHome = join(scratch, "nohash-home"); + mkdirSync(fakeHome, { recursive: true, mode: 0o700 }); + const marker = join(scratch, "nohash-executed"); + const payload = Buffer.from("#!/bin/sh\necho ran > '" + marker + "'\necho \"opencodex 2.98.0\"\n", "utf8"); + const sha = createHash("sha256").update(payload).digest("hex"); + const argv = remotePlantChildArgv("posix", payload.byteLength, sha); + const env = { HOME: fakeHome, PATH: restrictedBin(join(scratch, "nohash-bin"), PLANT_BASE_TOOLS) }; + const res = Bun.spawnSync(["/bin/sh", ...argv.slice(1)], { env, stdin: payload }); + expect(res.exitCode).not.toBe(0); + expect(res.stderr.toString("utf8")).toContain("ocx-plant-error:hash_tool_unavailable"); + const binDir = join(fakeHome, ".cache", "opencodex", "bin"); + expect(existsSync(join(binDir, "ocx-child"))).toBe(false); + expect(existsSync(binDir) ? readdirSync(binDir) : []).toEqual([]); + expect(existsSync(marker)).toBe(false); + }); + + test("POSIX plant verifies the digest with openssl when sha256sum and shasum are absent", () => { + if (process.platform === "win32" || !["/usr/bin/openssl", "/bin/openssl"].some(p => existsSync(p))) return; + const payload = Buffer.from("#!/bin/sh\necho \"opencodex 2.98.0\"\n", "utf8"); + const sha = createHash("sha256").update(payload).digest("hex"); + const bin = restrictedBin(join(scratch, "openssl-bin"), [...PLANT_BASE_TOOLS, "openssl"]); + const goodHome = join(scratch, "openssl-home"); + mkdirSync(goodHome, { recursive: true, mode: 0o700 }); + const good = Bun.spawnSync(["/bin/sh", ...remotePlantChildArgv("posix", payload.byteLength, sha).slice(1)], { env: { HOME: goodHome, PATH: bin }, stdin: payload }); + expect(good.exitCode).toBe(0); + expect(existsSync(join(goodHome, ".cache", "opencodex", "bin", "ocx-child"))).toBe(true); + const badHome = join(scratch, "openssl-bad-home"); + mkdirSync(badHome, { recursive: true, mode: 0o700 }); + const wrongSha = createHash("sha256").update("other").digest("hex"); + const bad = Bun.spawnSync(["/bin/sh", ...remotePlantChildArgv("posix", payload.byteLength, wrongSha).slice(1)], { env: { HOME: badHome, PATH: bin }, stdin: payload }); + expect(bad.exitCode).not.toBe(0); + expect(existsSync(join(badHome, ".cache", "opencodex", "bin", "ocx-child"))).toBe(false); + }); +}); diff --git a/tests/server/link-enrollment-ephemeral-child.test.ts b/tests/server/link-enrollment-ephemeral-child.test.ts index 665ddb89f0a..1ea24c2150e 100644 --- a/tests/server/link-enrollment-ephemeral-child.test.ts +++ b/tests/server/link-enrollment-ephemeral-child.test.ts @@ -298,6 +298,31 @@ describe("Home Remote Link enrollment, SSH plant, and --purge removal with ephem expect(body2.recovery).toContain("detach --purge"); }); + test("reports hash_tool_unavailable when the Child cannot verify the planted binary's SHA-256", async () => { + const h = buildHarness(); + h.state.confirmedHosts!.set("barebox", { + alias: "barebox", + fingerprint, + keyType: "ED25519", + knownHostLine: "barebox ssh-ed25519 AAAA", + probedAt: 1_800_000_000_000, + remotePlatform: "posix", + ocxVersion: null, + runtimeKind: "missing", + targetTriple: "linux-x64", + plantable: true, + binarySizeBytes: fakeBinary.byteLength, + }); + h.overrides.plant = () => ok("", 3, "ocx-plant-error:hash_tool_unavailable\nNo sha256sum, shasum, or openssl on this host to verify the binary.\n"); + const res = await h.request("POST", "/api/link/apply", { alias: "barebox", plant: true }); + expect(res.status).toBe(502); + const body = await res.json() as { error: { code: string; hint?: string } }; + expect(body.error.code).toBe("remote_plant_failed"); + expect(body.error.hint).toBe("hash_tool_unavailable"); + expect(h.events).not.toContain("connect"); + expect(h.store.links).toHaveLength(0); + }); + test("maps child_port_busy and child_external_provider_conflict to 409 without residual", async () => { const h = buildHarness(); h.overrides.before = () => http200({ service: "other" }); From 00c11d64cfb2a8ac90376765e7348ac838672a04 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:18:40 +0900 Subject: [PATCH 32/50] 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 3dfac1e560073f415429303063c6f2ad3f15d6e9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:20:45 +0900 Subject: [PATCH 33/50] test(link): give the bare-host plant E2E a SHA-256 tool The plant script now refuses to install a binary it cannot hash, and the E2E's bare host exposed only sh and core utilities, so it had been planting without any digest check. Link the first available sha256sum, shasum, or openssl into the host's private PATH. --- tests/clients/link-ocx-child-e2e.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/clients/link-ocx-child-e2e.test.ts b/tests/clients/link-ocx-child-e2e.test.ts index dfdde80707a..981285bfaed 100644 --- a/tests/clients/link-ocx-child-e2e.test.ts +++ b/tests/clients/link-ocx-child-e2e.test.ts @@ -509,6 +509,12 @@ describe.skipIf(process.platform === "win32")("end-to-end Home <-> compiled ocx- if (!resolved) throw new Error(`missing POSIX utility: ${util}`); symlinkSync(resolved, join(privateUtils, util)); } + // The plant script refuses to install a binary it cannot hash, so the bare host needs one tool. + const hashTool = ["sha256sum", "shasum", "openssl"] + .flatMap(tool => ["/bin", "/usr/bin", "/sbin", "/usr/sbin"].map(dir => ({ tool, path: join(dir, tool) }))) + .find(candidate => existsSync(candidate.path)); + if (!hashTool) throw new Error("missing SHA-256 tool (sha256sum, shasum, or openssl) for the plant E2E"); + symlinkSync(hashTool.path, join(privateUtils, hashTool.tool)); const resolvedSh = join(privateUtils, "sh"); const homeConfig: OcxConfig = { port: 10100, From 815d62d862f2c134053788aff20cd6b7ee59ad00 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:21:22 +0900 Subject: [PATCH 34/50] fix(link): purge through the managed planted ocx-child and report a purge that did not happen DELETE /api/link/:id {purge:true} ran `disconnect --purge` through the generic candidate resolver, so a ~/.local/bin/ocx-child could answer first; it is outside the managed directory, so it exited 0 with purged:false and Home reported success. The purge was also dropped silently for links whose record had no childKind. Home now runs the planted binary at the managed path explicitly (remoteManagedPurgeDisconnectArgv, POSIX and Windows) with --json and requires its "purged": true report. A missing managed binary (managed_child_missing) or a report without the removal (purge_incomplete) is 502 remote_disconnect_failed and the link record is kept. A record without childKind is inspected first; a full install answers 409 purge_unsupported instead of a silent plain disconnect. --- .../src/content/docs/guides/remote-link.md | 2 +- .../src/content/docs/ko/guides/remote-link.md | 2 +- src/link/remote-plant.ts | 73 ++++++++++++++++++- src/link/remote-windows.ts | 13 ++++ src/server/management/link-routes.ts | 18 +++-- structure/remote-link.md | 2 +- .../link-child-plant-foundations.test.ts | 31 +++++++- .../link-enrollment-ephemeral-child.test.ts | 44 ++++++++++- 8 files changed, 172 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 7888678b1bc..4c31a9d6670 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -97,7 +97,7 @@ These reads need an OpenCodex version with the Remote Link management relay on b ## Remove a Child -Select **Disconnect** for the Child and confirm the alias. The Home stops the tunnel, revokes that Child's link key, and removes the saved link record. On a lightweight (`ocx-child`) Child, disconnecting restores `~/.codex` and stops the relay with zero background processes while keeping `~/.cache/opencodex/bin/ocx-child` cached by default for fast reconnects; check **Also delete the planted lightweight agent binary from the remote computer** (or pass `--purge` to `ocx link revoke`) to delete the planted binary as well. +Select **Disconnect** for the Child and confirm the alias. The Home stops the tunnel, revokes that Child's link key, and removes the saved link record. On a lightweight (`ocx-child`) Child, disconnecting restores `~/.codex` and stops the relay with zero background processes while keeping `~/.cache/opencodex/bin/ocx-child` cached by default for fast reconnects; check **Also delete the planted lightweight agent binary from the remote computer** (or pass `--purge` to `ocx link revoke`) to delete the planted binary as well. Home runs that purge through the planted binary at `~/.cache/opencodex/bin/ocx-child` (`%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` on Windows), never through another `ocx-child` on `PATH`. If that file is missing, or the Child does not confirm that it deleted the binary, Disconnect reports an error and keeps the link so you can retry, disconnect without the purge option, or remove the file yourself. `--purge` applies only to a lightweight Child: Home refuses it for a full OpenCodex install, and for a link saved before Home recorded the Child type it checks the Child first. If the Home cannot reach the Child to run its disconnect command, choose **Remove here only**. This removes the local tunnel, key, and record. Then log in to the Child and run: diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index 660ac8007e1..21673c7cb45 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -61,7 +61,7 @@ Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다. ## Child 제거하기 -Child의 **Disconnect**를 선택하고 alias를 확인합니다. Home은 터널을 중지하고 해당 Child의 링크 키를 폐기한 뒤 저장된 링크 기록을 삭제합니다. 경량 에이전트(`ocx-child`)로 연결된 Child는 기본적으로 빠른 재연결을 위해 `~/.cache/opencodex/bin/ocx-child` 파일을 남겨두며, 확인 대화상자에서 **심어둔 경량 에이전트 파일까지 원격 컴퓨터에서 함께 삭제**를 선택하거나 `ocx link revoke --link-id --purge`를 실행하면 에이전트 파일까지 함께 삭제합니다. +Child의 **Disconnect**를 선택하고 alias를 확인합니다. Home은 터널을 중지하고 해당 Child의 링크 키를 폐기한 뒤 저장된 링크 기록을 삭제합니다. 경량 에이전트(`ocx-child`)로 연결된 Child는 기본적으로 빠른 재연결을 위해 `~/.cache/opencodex/bin/ocx-child` 파일을 남겨두며, 확인 대화상자에서 **심어둔 경량 에이전트 파일까지 원격 컴퓨터에서 함께 삭제**를 선택하거나 `ocx link revoke --link-id --purge`를 실행하면 에이전트 파일까지 함께 삭제합니다. Home은 이 삭제를 `PATH`에 있는 다른 `ocx-child`가 아니라 `~/.cache/opencodex/bin/ocx-child`(Windows에서는 `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`)에 심어둔 파일로 실행합니다. 그 파일이 없거나 Child가 파일을 삭제했다고 확인하지 않으면 Disconnect는 오류를 보고하고 링크를 그대로 남겨 두므로, 다시 시도하거나 삭제 옵션 없이 연결을 해제하거나 파일을 직접 지울 수 있습니다. `--purge`는 경량 Child에만 적용됩니다. 전체 OpenCodex 설치에서는 Home이 거부하고, Home이 Child 종류를 기록하기 전에 저장된 링크라면 먼저 Child를 확인합니다. Home에서 Child의 연결 해제 명령을 실행할 수 없으면 **Remove here only**를 선택합니다. 그러면 이 컴퓨터의 터널, 키, 기록만 삭제됩니다. 그 다음 Child에 로그인하여 실행합니다. diff --git a/src/link/remote-plant.ts b/src/link/remote-plant.ts index 55206f51686..0670b3c0a67 100644 --- a/src/link/remote-plant.ts +++ b/src/link/remote-plant.ts @@ -1,7 +1,7 @@ import { CHILD_TARGET_TRIPLES, type ChildTargetTriple } from "./child-binary-source"; -import { windowsInspectRuntimeArgv, windowsPlantChildArgv, windowsPurgePlantedChildArgv } from "./remote-windows"; +import { MANAGED_PURGE_CHILD_MISSING_MARKER, windowsInspectRuntimeArgv, windowsManagedPurgeDisconnectArgv, windowsPlantChildArgv, windowsPurgePlantedChildArgv } from "./remote-windows"; import { buildExecArgv } from "./ssh-argv"; -import type { SshRunner } from "./ssh-runner"; +import { sshFailureHint, sshRunnerErrorHint, type SshRunner } from "./ssh-runner"; export type RemoteRuntimeKind = "full" | "ephemeral" | "missing"; export type ChildAttachConflictCode = "child_port_busy" | "child_external_provider_conflict"; @@ -211,6 +211,75 @@ export function remotePurgePlantedChildArgv(platform: "posix" | "windows" = "pos : ["sh", "-c", POSIX_PURGE_PLANTED_SCRIPT, "ocx-purge-plant"]; } +/** + * Runs disconnect through the managed planted binary only. A PATH or ~/.local/bin candidate cannot + * purge itself (it lives outside the managed directory), so the generic resolver is never used here. + */ +export const POSIX_MANAGED_PURGE_DISCONNECT_SCRIPT = [ + "case \"$(uname -s 2>/dev/null)\" in Windows*|MSYS*|MINGW*|CYGWIN*) exit 125;; esac", + "for p in \"$HOME/.cache\" \"$HOME/.cache/opencodex\" \"$HOME/.cache/opencodex/bin\" \"$HOME/.cache/opencodex/bin/ocx-child\"; do", + " if [ -L \"$p\" ]; then printf 'Refusing symlink in managed plant path: %s\\n' \"$p\" >&2; exit 1; fi", + "done", + "dest=\"$HOME/.cache/opencodex/bin/ocx-child\"", + `if [ ! -f "$dest" ] || [ ! -x "$dest" ]; then printf '${MANAGED_PURGE_CHILD_MISSING_MARKER}\\n' >&2; exit 1; fi`, + "exec \"$dest\" disconnect --purge --json", +].join("\n"); + +export function remoteManagedPurgeDisconnectArgv(platform: "posix" | "windows" = "posix"): string[] { + return platform === "windows" + ? windowsManagedPurgeDisconnectArgv() + : ["sh", "-c", POSIX_MANAGED_PURGE_DISCONNECT_SCRIPT, "ocx-managed-purge"]; +} + +/** The `purged` flag from `ocx-child disconnect --json`, or null when no such report was printed. */ +export function parsePurgeDisconnectReport(stdout: string): boolean | null { + for (const raw of stdout.split(/\r?\n/).reverse()) { + const line = raw.trim(); + if (!line.startsWith("{")) continue; + try { + const parsed: unknown = JSON.parse(line); + if (parsed && typeof parsed === "object" && typeof (parsed as { purged?: unknown }).purged === "boolean") return (parsed as { purged: boolean }).purged; + } catch { /* not the report line */ } + } + return null; +} + +export type PurgeDisconnectOutcome = + | { readonly ok: true } + | { readonly ok: false; readonly status: number; readonly code: string; readonly message: string; readonly hint?: string }; + +/** + * Disconnect with --purge. A link enrolled before childKind existed is inspected first; only an + * ocx-child Child can be purged, and success requires the binary's own report that it was removed. + */ +export async function purgeDisconnectRemote( + runner: Pick, + alias: string, + knownHostsFile: string, + platform: "posix" | "windows", + childKind: "ephemeral" | "full" | undefined, +): Promise { + const failed = (message: string, hint?: string): PurgeDisconnectOutcome => ({ ok: false, status: 502, code: "remote_disconnect_failed", message, ...(hint ? { hint } : {}) }); + let kind = childKind; + if (kind === undefined) { + let inspected: ParsedRemoteRuntimeInspection | null; + try { inspected = await inspectRemoteChildRuntime(runner, alias, knownHostsFile, platform); } catch (error) { return failed("The Child runtime could not be inspected, so --purge was not attempted.", sshRunnerErrorHint(error) ?? "child_kind_unknown"); } + if (!inspected || inspected.runtimeKind === "missing") return failed("The Child runtime could not be identified, so --purge was not attempted.", "child_kind_unknown"); + kind = inspected.runtimeKind; + } + if (kind !== "ephemeral") { + return { ok: false, status: 409, code: "purge_unsupported", message: "This Child runs a full OpenCodex install; --purge removes only a planted ocx-child. Disconnect without --purge." }; + } + let result; + try { result = await runner.run(buildExecArgv({ alias, argv: remoteManagedPurgeDisconnectArgv(platform), knownHostsFile }), { timeoutMs: 30_000 }); } catch (error) { return failed("The remote client could not be disconnected.", sshRunnerErrorHint(error)); } + if (result.code !== 0) { + if (result.stderr.includes(MANAGED_PURGE_CHILD_MISSING_MARKER)) return failed("No planted ocx-child was found in the managed directory, so nothing was purged.", "managed_child_missing"); + return failed("The remote client could not be disconnected.", sshFailureHint(result.stderr)); + } + if (parsePurgeDisconnectReport(result.stdout) !== true) return failed("The Child disconnected but did not report removing the planted ocx-child binary.", "purge_incomplete"); + return { ok: true }; +} + /** The distinct reason a plant failed on the Child, when the plant script reported one. */ export function classifyPlantFailure(stderr: string): "hash_tool_unavailable" | null { return stderr.includes(PLANT_HASH_TOOL_UNAVAILABLE_MARKER) ? "hash_tool_unavailable" : null; diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index be51b47d316..257a3dd78d6 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -196,6 +196,19 @@ try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){ `); } +/** Printed on stderr when the managed plant directory holds no runnable ocx-child to purge. */ +export const MANAGED_PURGE_CHILD_MISSING_MARKER = "ocx-purge-error:managed_child_missing"; + +/** Runs `disconnect --purge --json` through the planted %LOCALAPPDATA% binary, never a PATH candidate. */ +export function windowsManagedPurgeDisconnectArgv(): string[] { + return packedPowerShellArgv(NATIVE_PROCESS + WINDOWS_MANAGED_BIN + String.raw` +try{$dir=Resolve-PlantDir}catch{[Console]::Error.WriteLine('Refusing a reparse point in the managed ocx-child directory.');exit 1} +$dst="$dir\ocx-child.exe";if(-not([IO.File]::Exists($dst)-and[OcxJob]::Ok($dst))){[Console]::Error.WriteLine('${MANAGED_PURGE_CHILD_MISSING_MARKER}');exit 1} +try{$rc=[OcxJob]::Run($dst,('"'+$dst+'" disconnect --purge --json'))}catch{exit 1} +Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{$null=$_}};exit $rc +`); +} + export function windowsPinnedSyncArgv(linkId: string): string[] { return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: ["sync", "--expect-link", linkId, "--json"] }) + OCX_CANDIDATES + String.raw` foreach($x in ($c|select -Unique)){ diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 3c56170f6bc..116a498c725 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -2,6 +2,7 @@ import { probe, confirmHost, apply } from "./link-enrollment"; import { fail, pairedSession, auth, port, isRecord, exactBody, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, issueKey, revokeKeyIdempotent, compensateNewLink, type LinkRouteState } from "./link-route-state"; export type { LinkRouteState, PendingLinkHost, ConfirmedLinkHost, LinkRouteListener } from "./link-route-state"; import { requireRemotePlatform } from "../../link/remote-platform"; +import { purgeDisconnectRemote } from "../../link/remote-plant"; import { assertSshAlias, buildExecArgv, remoteOcxArgv } from "../../link/ssh-argv"; import { clearCompensationFailed, compensationPath, readCompensation } from "../../link/compensation"; import { loadHostCandidates } from "../../link/ssh-config"; @@ -229,11 +230,18 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) try { const runner = runnerFor(ctx); const platform = record.remotePlatform ?? await requireRemotePlatform(runner, record.alias, knownHostsFile(ctx)); - const disconnectArgs = purge && record.childKind === "ephemeral" ? ["disconnect", "--purge"] : ["disconnect"]; - const result = await runner.run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(disconnectArgs, platform), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 }); - if (result.code !== 0) { - await restartTunnel(); - return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr)); + if (purge) { + const purged = await purgeDisconnectRemote(runner, record.alias, knownHostsFile(ctx), platform, record.childKind); + if (!purged.ok) { + await restartTunnel(); + return fail(purged.code, purged.message, purged.status, purged.hint); + } + } else { + const result = await runner.run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"], platform), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 }); + if (result.code !== 0) { + await restartTunnel(); + return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr)); + } } } catch (error) { await restartTunnel(); diff --git a/structure/remote-link.md b/structure/remote-link.md index 47a0778075e..32887a94732 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -192,7 +192,7 @@ When `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVers 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, maps foreign listeners and external `~/.codex/config.toml` ownership (`ocx-child-conflict:*`) to `409 child_port_busy` and `409 child_external_provider_conflict` (`502 child_preflight_failed` on an SSH transport drop), persists `childKind: "ephemeral" | "full"` on `LinkRecord` (`src/link/store.ts`, projected on `GET /api/link/status` in `src/link/status-projection.ts` alongside `gui-session` `homeCodexSettingsDigest`), 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), leaves zero background daemon, removes the emptied `child` state directory (including the released daemon start gate), and with `--purge` (`DELETE /api/link/:id` `{ "purge": true }` or `ocx link revoke --purge`) also removes the binary when it resides inside the managed plant directory (`~/.cache/opencodex/bin/ocx-child` or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, refusing symlinks and foreign paths). 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), leaves zero background daemon, removes the emptied `child` state directory (including the released daemon start gate), and with `--purge` (`DELETE /api/link/:id` `{ "purge": true }` or `ocx link revoke --purge`) also removes the binary when it resides inside the managed plant directory (`~/.cache/opencodex/bin/ocx-child` or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, refusing symlinks and foreign paths). Home runs that purge only through the managed planted binary (`purgeDisconnectRemote` and `remoteManagedPurgeDisconnectArgv` in `src/link/remote-plant.ts`, never the generic candidate resolver, which could pick a `~/.local/bin` copy that cannot purge itself) with `--json`, and answers `502 remote_disconnect_failed` while keeping the link record when the managed binary is missing (hint `managed_child_missing`) or the report lacks `"purged": true` (hint `purge_incomplete`). A record without `childKind` (enrolled before it existed) is inspected first; a full install answers `409 purge_unsupported`. 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 is `$OPENCODEX_HOME/child.lock` (outside `child/`, so it keeps one inode while `detach` removes that directory) and the journal lives 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`). diff --git a/tests/clients/link-child-plant-foundations.test.ts b/tests/clients/link-child-plant-foundations.test.ts index 658d85c8941..b47a1d71357 100644 --- a/tests/clients/link-child-plant-foundations.test.ts +++ b/tests/clients/link-child-plant-foundations.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -10,15 +10,18 @@ import { } from "../../src/link/child-binary-source"; import { classifyChildAttachConflict, + parsePurgeDisconnectReport, parseRemoteRuntimeInspection, parseTargetTriple, remoteInspectRuntimeArgv, + remoteManagedPurgeDisconnectArgv, remotePlantChildArgv, remotePurgePlantedChildArgv, } from "../../src/link/remote-plant"; import { POWERSHELL_ENCODED_MAX, windowsInspectRuntimeArgv, + windowsManagedPurgeDisconnectArgv, windowsOcxArgv, windowsPlantChildArgv, windowsPurgePlantedChildArgv, @@ -141,6 +144,7 @@ describe("remote-plant POSIX & Windows command contracts", () => { windowsOcxArgv(["disconnect", "--purge"]), windowsPlantChildArgv(950_000, sha), windowsPurgePlantedChildArgv(), + windowsManagedPurgeDisconnectArgv(), ]) { expect(argv[0]).toBe("powershell.exe"); expect(argv[5]!.length).toBeLessThanOrEqual(POWERSHELL_ENCODED_MAX); @@ -266,4 +270,29 @@ describe("remote-plant POSIX & Windows command contracts", () => { expect(bad.exitCode).not.toBe(0); expect(existsSync(join(badHome, ".cache", "opencodex", "bin", "ocx-child"))).toBe(false); }); + + test("POSIX purge disconnect runs only the managed planted binary and needs its purged report", () => { + if (process.platform === "win32") return; + const fakeHome = join(scratch, "purge-target-home"); + const localBin = join(fakeHome, ".local", "bin"); + mkdirSync(localBin, { recursive: true }); + const recorded = join(scratch, "purge-target-args"); + writeFileSync(join(localBin, "ocx-child"), "#!/bin/sh\necho local >> '" + recorded + "'\necho '{\"purged\":false}'\n", { mode: 0o755 }); + const argv = remoteManagedPurgeDisconnectArgv("posix"); + const env = { HOME: fakeHome, PATH: localBin + ":/usr/bin:/bin" }; + const missing = Bun.spawnSync(["/bin/sh", ...argv.slice(1)], { env }); + expect(missing.exitCode).toBe(1); + expect(missing.stderr.toString("utf8")).toContain("ocx-purge-error:managed_child_missing"); + expect(existsSync(recorded)).toBe(false); + + const managedBin = join(fakeHome, ".cache", "opencodex", "bin"); + mkdirSync(managedBin, { recursive: true }); + writeFileSync(join(managedBin, "ocx-child"), "#!/bin/sh\necho \"$@\" >> '" + recorded + "'\necho '{\"restored\":true,\"outcome\":\"NotAttached\",\"daemonRunning\":false,\"purged\":true}'\n", { mode: 0o755 }); + const ran = Bun.spawnSync(["/bin/sh", ...argv.slice(1)], { env }); + expect(ran.exitCode).toBe(0); + expect(readFileSync(recorded, "utf8")).toBe("disconnect --purge --json\n"); + expect(parsePurgeDisconnectReport(ran.stdout.toString("utf8"))).toBe(true); + expect(parsePurgeDisconnectReport('{"restored":true,"purged":false}\n')).toBe(false); + expect(parsePurgeDisconnectReport("Disconnected (purged=true)\n")).toBeNull(); + }); }); diff --git a/tests/server/link-enrollment-ephemeral-child.test.ts b/tests/server/link-enrollment-ephemeral-child.test.ts index 1ea24c2150e..33d28690cf0 100644 --- a/tests/server/link-enrollment-ephemeral-child.test.ts +++ b/tests/server/link-enrollment-ephemeral-child.test.ts @@ -30,7 +30,7 @@ const fakeBinary = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 1, 2, 3, 4]); const fakeSha = createHash("sha256").update(fakeBinary).digest("hex"); const ok = (stdout = "", code = 0, stderr = ""): SshRunResult => ({ code, stdout, stderr }); const http200 = (body: unknown) => ok(`${JSON.stringify(body)}\n200`); -type Step = "inspect" | "plant" | "purgePlant" | "port" | "config" | "curl" | "before" | "connect" | "restart" | "health" | "catalog" | "disconnect" | "disconnectPurge"; +type Step = "inspect" | "plant" | "purgePlant" | "port" | "config" | "curl" | "before" | "connect" | "restart" | "health" | "catalog" | "disconnect" | "disconnectPurge" | "managedPurge"; function buildHarness() { const remoteOcx = (args: string[]) => quoteRemote(remoteOcxArgv(args, "posix")); @@ -87,6 +87,7 @@ function buildHarness() { const step: Step = command === inspectCmd ? "inspect" : command === plantCmd ? "plant" : command === purgePlantCmd ? "purgePlant" + : command.includes("ocx-managed-purge") ? "managedPurge" : command === remoteOcx(["link", "port"]) ? "port" : command === remoteOcx(["config", "show", "--json"]) ? "config" : command.includes("ocx-curl-preflight") ? "curl" @@ -122,6 +123,7 @@ function buildHarness() { } case "disconnect": return ok(); case "disconnectPurge": return ok(); + case "managedPurge": return ok('{"restored":true,"outcome":"Restored","daemonRunning":false,"purged":true}\n'); } }, spawnTunnel() { throw new Error("unused"); }, @@ -209,7 +211,45 @@ describe("Home Remote Link enrollment, SSH plant, and --purge removal with ephem const del = await h.request("DELETE", `/api/link/${body.linkId}`, { purge: true }); expect(del.status).toBe(200); - expect(h.events).toContain("disconnectPurge"); + expect(h.events).toContain("managedPurge"); + expect(h.events).not.toContain("disconnectPurge"); + expect(h.store.links).toHaveLength(0); + }); + + test("DELETE --purge targets the managed planted binary and reports a binary it did not remove as a failure", async () => { + const h = buildHarness(); + const { linkId } = await (await h.request("POST", "/api/link/apply", { alias: "devbox" })).json() as { linkId: string }; + h.overrides.managedPurge = () => ok('{"restored":true,"outcome":"NotAttached","daemonRunning":false,"purged":false}\n'); + const kept = await h.request("DELETE", "/api/link/" + linkId, { purge: true }); + expect(kept.status).toBe(502); + expect((await kept.json() as { error: { code: string; hint?: string } }).error).toMatchObject({ code: "remote_disconnect_failed", hint: "purge_incomplete" }); + expect(h.store.links).toHaveLength(1); + expect(h.events).not.toContain("disconnectPurge"); + + h.overrides.managedPurge = () => ok("", 1, "ocx-purge-error:managed_child_missing\n"); + const missing = await h.request("DELETE", "/api/link/" + linkId, { purge: true }); + expect(missing.status).toBe(502); + expect((await missing.json() as { error: { hint?: string } }).error.hint).toBe("managed_child_missing"); + expect(h.store.links).toHaveLength(1); + }); + + test("DELETE --purge on a link enrolled without childKind inspects the Child instead of silently skipping the purge", async () => { + const h = buildHarness(); + const { linkId } = await (await h.request("POST", "/api/link/apply", { alias: "devbox" })).json() as { linkId: string }; + delete (h.store.links[0] as { childKind?: string }).childKind; + const start = h.events.length; + h.overrides.inspect = () => ok("ocx-inspect:uname:Linux:x86_64\nocx-inspect:kind:full\nocx-inspect:version:opencodex 2.98.0\n"); + const full = await h.request("DELETE", "/api/link/" + linkId, { purge: true }); + expect(full.status).toBe(409); + expect((await full.json() as { error: { code: string } }).error.code).toBe("purge_unsupported"); + expect(h.events.slice(start)).toContain("inspect"); + expect(h.events.slice(start).filter(e => e === "disconnect" || e === "disconnectPurge" || e === "managedPurge")).toEqual([]); + expect(h.store.links).toHaveLength(1); + + h.overrides.inspect = () => ok("ocx-inspect:uname:Linux:x86_64\nocx-inspect:kind:ephemeral\nocx-inspect:version:ocx-child 2.98.0\n"); + const eph = await h.request("DELETE", "/api/link/" + linkId, { purge: true }); + expect(eph.status).toBe(200); + expect(h.events.slice(start)).toContain("managedPurge"); expect(h.store.links).toHaveLength(0); }); From 44220133385ebcbf16261721a56d8dbc4b0de0ee Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:21:45 +0900 Subject: [PATCH 35/50] 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 36/50] 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 37/50] 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 7d3e24c67a54961510f09b3bf9961bd3ee2824ef Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:24:53 +0900 Subject: [PATCH 38/50] fix(ocx-child): stop re-permissioning existing OPENCODEX_HOME when taking the lifecycle lock Moving the lifecycle lock to $OPENCODEX_HOME/child.lock made every FileLock acquisition call ensure_private_dir on OPENCODEX_HOME: attach, sync, restart, and detach chmodded a shared home to 0700 on POSIX and, on Windows, applied a protected owner-only DACL with inheritable ACEs that propagated into a co-installed full OpenCodex tree, all before the full-ocx conflict check. FileLock now decides through lock_parent_action: a missing parent is created private by create_private_dir_if_missing (0700, or a hardened DACL only on a directory this call created), an existing parent is used as it is, and a symlink or non-directory is refused. The lock file itself is still created 0600 / owner-only on the file alone. CODEX_HOME keeps using #8237's acquire_in_existing_dir, which never touched its mode. --- native/ocx-child/src/platform.rs | 153 +++++++++++++++++- native/ocx-child/tests/relay_and_lifecycle.rs | 30 ++++ structure/remote-link.md | 2 +- 3 files changed, 179 insertions(+), 6 deletions(-) diff --git a/native/ocx-child/src/platform.rs b/native/ocx-child/src/platform.rs index 6e370730c70..f57f3466f8f 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -919,7 +919,78 @@ pub enum LockAcquireError { Io, } +/// What a lock acquisition does with the lock file's parent directory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockParentAction { + /// The parent is missing and ocx-child creates it, private, as its own directory. + CreatePrivate, + /// The parent already exists; its permissions are never changed. It may be an + /// `OPENCODEX_HOME` shared with a full OpenCodex install, or `CODEX_HOME`. + UseExisting, + Refuse, +} + +/// `parent` is the parent's metadata, or `None` when it does not exist. +pub fn lock_parent_action(parent: Option<&fs::Metadata>, create_missing: bool) -> LockParentAction { + match parent { + None if create_missing => LockParentAction::CreatePrivate, + None => LockParentAction::Refuse, + Some(meta) if meta.is_dir() => LockParentAction::UseExisting, + Some(_) => LockParentAction::Refuse, + } +} + +/// Create `dir` private (`0700`, or a protected owner-only DACL on Windows) if it is missing. +/// Returns whether this call created it. A directory that already exists, including one another +/// process creates concurrently, is left exactly as it is. +pub fn create_private_dir_if_missing(dir: &Path) -> Result { + if let Some(ancestor) = dir.parent().filter(|p| !p.as_os_str().is_empty()) { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(ancestor) + .map_err(|e| format!("failed to create {}: {e}", ancestor.display()))?; + } + #[cfg(not(unix))] + fs::create_dir_all(ancestor) + .map_err(|e| format!("failed to create {}: {e}", ancestor.display()))?; + } + #[cfg(unix)] + let created = { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new().mode(0o700).create(dir) + }; + #[cfg(not(unix))] + let created = fs::DirBuilder::new().create(dir); + match created { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return if fs::symlink_metadata(dir).is_ok_and(|m| m.is_dir()) { + Ok(false) + } else { + Err(format!("{} exists and is not a directory", dir.display())) + }; + } + Err(e) => return Err(format!("failed to create {}: {e}", dir.display())), + } + reject_symlink(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("failed to chmod 0700 {}: {e}", dir.display()))?; + } + #[cfg(windows)] + windows_private_acl::harden_and_verify(dir, true)?; + Ok(true) +} + impl FileLock { + /// Lock `path`, creating its parent private only if it is missing. An existing parent keeps + /// its permissions; the lock file itself is created owner-only. pub fn try_acquire_nonblocking(path: &Path) -> Result { Self::acquire_inner(path, false, true) } @@ -937,13 +1008,17 @@ impl FileLock { fn acquire_inner( path: &Path, blocking: bool, - harden_parent: bool, + create_missing_parent: bool, ) -> Result { let parent = path.parent().ok_or(LockAcquireError::Io)?; - if harden_parent { - ensure_private_dir(parent).map_err(|_| LockAcquireError::Io)?; - } else if !fs::symlink_metadata(parent).is_ok_and(|m| m.is_dir()) { - return Err(LockAcquireError::Io); + // A symlinked parent is refused rather than followed, as ensure_private_dir did. + let parent_meta = fs::symlink_metadata(parent).ok(); + match lock_parent_action(parent_meta.as_ref(), create_missing_parent) { + LockParentAction::CreatePrivate => { + create_private_dir_if_missing(parent).map_err(|_| LockAcquireError::Io)?; + } + LockParentAction::UseExisting => {} + LockParentAction::Refuse => return Err(LockAcquireError::Io), } reject_symlink(path).map_err(|_| LockAcquireError::Io)?; #[cfg(windows)] @@ -1238,3 +1313,71 @@ pub fn purge_managed_executable( Ok(PurgeExecutableOutcome::Removed) } } + +#[cfg(test)] +mod lock_parent_tests { + use super::*; + + struct Scratch(PathBuf); + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn scratch() -> Scratch { + let dir = std::env::temp_dir().join(format!( + "ocx-child-lock-parent-{}-{}", + std::process::id(), + random_hex(6).unwrap() + )); + fs::create_dir(&dir).unwrap(); + Scratch(dir) + } + + #[test] + fn only_a_missing_parent_is_created_and_an_existing_one_is_never_touched() { + let root = scratch(); + let file = root.0.join("plain-file"); + fs::write(&file, b"x").unwrap(); + let dir_meta = fs::symlink_metadata(&root.0).unwrap(); + let file_meta = fs::symlink_metadata(&file).unwrap(); + assert_eq!( + lock_parent_action(None, true), + LockParentAction::CreatePrivate + ); + assert_eq!(lock_parent_action(None, false), LockParentAction::Refuse); + for create in [true, false] { + assert_eq!( + lock_parent_action(Some(&dir_meta), create), + LockParentAction::UseExisting + ); + assert_eq!( + lock_parent_action(Some(&file_meta), create), + LockParentAction::Refuse + ); + } + } + + #[test] + fn create_private_dir_if_missing_reports_creation_and_keeps_existing_modes() { + let root = scratch(); + let created = root.0.join("new").join("home"); + assert!(create_private_dir_if_missing(&created).unwrap()); + assert!(!create_private_dir_if_missing(&created).unwrap()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&created), 0o700); + let shared = root.0.join("shared"); + fs::create_dir(&shared).unwrap(); + fs::set_permissions(&shared, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!create_private_dir_if_missing(&shared).unwrap()); + let lock = FileLock::acquire_blocking(&shared.join("child.lock")).unwrap(); + drop(lock); + assert_eq!(mode(&shared), 0o755); + assert_eq!(mode(&shared.join("child.lock")), 0o600); + } + } +} diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 38d9db1e281..b6c9ea23000 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -676,6 +676,9 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac let codex_home = scratch.join("codex"); fs::create_dir_all(&ocx_home).unwrap(); fs::create_dir_all(&codex_home).unwrap(); + // Both homes pre-exist with a shared mode; no lifecycle command may change it. + set_dir_mode_755(&ocx_home); + set_dir_mode_755(&codex_home); fs::write( codex_home.join("config.toml"), b"# keep my comment\napproval_policy = \"on-request\"\n", @@ -734,6 +737,7 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac Some(cred_json.as_bytes()), ); assert_eq!(rc_conn, 0, "connect stderr: {err_conn}"); + assert_home_modes_kept(&ocx_home, &codex_home, "attach"); assert!(codex_home.join("models_cache.json").exists()); let rollback_path = ocx_home.join("child").join("attach-rollback.json"); @@ -981,6 +985,7 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac None, ); assert_eq!(rc_sync, 0, "sync stderr: {err_sync}"); + assert_home_modes_kept(&ocx_home, &codex_home, "sync"); assert_eq!( out_sync.trim(), r#"{"schema":"ocx.sync.fanout/1","home":{"state":"synced","exit":0},"fanout":{"state":"not-applicable","reason":null},"children":[]}"# @@ -1069,6 +1074,7 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac 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_home_modes_kept(&ocx_home, &codex_home, "detach"); assert!( !process_is_alive(active_pid), "zero leftover daemon after disconnect" @@ -1092,6 +1098,30 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac let _ = fs::remove_dir_all(&scratch); } +#[cfg(unix)] +fn set_dir_mode_755(dir: &Path) { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(not(unix))] +fn set_dir_mode_755(_dir: &Path) {} + +#[cfg(unix)] +fn assert_home_modes_kept(ocx_home: &Path, codex_home: &Path, step: &str) { + use std::os::unix::fs::PermissionsExt; + for (name, dir) in [("OPENCODEX_HOME", ocx_home), ("CODEX_HOME", codex_home)] { + let mode = fs::metadata(dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o755, + "{step} changed the mode of the pre-existing {name}" + ); + } +} + +#[cfg(not(unix))] +fn assert_home_modes_kept(_ocx_home: &Path, _codex_home: &Path, _step: &str) {} + #[test] fn detach_purge_removes_only_managed_binary_refuses_symlinks_and_emits_conflict_codes() { let bin = Path::new(env!("CARGO_BIN_EXE_ocx-child")); diff --git a/structure/remote-link.md b/structure/remote-link.md index 32887a94732..97efb93e8f0 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 `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), leaves zero background daemon, removes the emptied `child` state directory (including the released daemon start gate), and with `--purge` (`DELETE /api/link/:id` `{ "purge": true }` or `ocx link revoke --purge`) also removes the binary when it resides inside the managed plant directory (`~/.cache/opencodex/bin/ocx-child` or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, refusing symlinks and foreign paths). Home runs that purge only through the managed planted binary (`purgeDisconnectRemote` and `remoteManagedPurgeDisconnectArgv` in `src/link/remote-plant.ts`, never the generic candidate resolver, which could pick a `~/.local/bin` copy that cannot purge itself) with `--json`, and answers `502 remote_disconnect_failed` while keeping the link record when the managed binary is missing (hint `managed_child_missing`) or the report lacks `"purged": true` (hint `purge_incomplete`). A record without `childKind` (enrolled before it existed) is inspected first; a full install answers `409 purge_unsupported`. 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 is `$OPENCODEX_HOME/child.lock` (outside `child/`, so it keeps one inode while `detach` removes that directory) and the journal lives 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 is `$OPENCODEX_HOME/child.lock` (outside `child/`, so it keeps one inode while `detach` removes that directory) and the journal lives 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. Taking a lock never changes the permissions of a directory ocx-child did not create: `FileLock` (`native/ocx-child/src/platform.rs`) creates a missing parent private (`0700`, or a protected owner-only DACL on Windows) and leaves an existing `OPENCODEX_HOME`, which a full OpenCodex install may share, or `CODEX_HOME` exactly as it is; the lock files themselves are created owner-only (`0600`, or an owner-only DACL on the file alone). Coverage: `native/ocx-child/tests/relay_and_lifecycle.rs` (pre-existing `0755` homes across attach, sync, and detach) and the `lock_parent_tests` unit tests in `platform.rs`. `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 2cb3218aee189dcbc56b37df894e68fa21680291 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:25:32 +0900 Subject: [PATCH 39/50] fix(link): treat an empty OPENCODEX_HOME as unset when looking up cached ocx-child artifacts OPENCODEX_HOME= made the cache lookup join(, cache, ...), a path relative to the server's working directory. An empty or blank value now falls back to ~/.opencodex, as ocx-child does. structure/remote-link.md also records that childKind joined the strict link record keys, so a downgraded Home rejects a links.json that carries it (the same trade-off as remotePlatform). --- src/link/child-binary-source.ts | 3 +- structure/remote-link.md | 2 +- .../link-child-plant-foundations.test.ts | 28 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/link/child-binary-source.ts b/src/link/child-binary-source.ts index 56446e6db14..9f6dfd2bbf8 100644 --- a/src/link/child-binary-source.ts +++ b/src/link/child-binary-source.ts @@ -204,7 +204,8 @@ export function resolveChildBinarySource( } } - const ocxHome = options.opencodexHome ?? env.OPENCODEX_HOME?.trim() ?? join(env.HOME || homedir(), ".opencodex"); + // An empty OPENCODEX_HOME counts as unset, as in ocx-child; joining "" would search the cwd. + const ocxHome = options.opencodexHome || env.OPENCODEX_HOME?.trim() || join(env.HOME || homedir(), ".opencodex"); const version = options.version ?? packageVersion("0.0.0"); const cachedCandidate = join(ocxHome, "cache", "ocx-child", version, artifactFile); const cacheRes = verifyManifestCandidate(cachedCandidate, targetTriple, "cache", hostPlatform); diff --git a/structure/remote-link.md b/structure/remote-link.md index 97efb93e8f0..30c5745f183 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -14,7 +14,7 @@ Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` Every remote `ocx` call goes through `remoteOcxArgv` with the link's platform. On POSIX it is a quoted `sh -c` script that tries executable Desktop and system candidates before generic PATH shims: `$HOME/.opencodex-desktop/bin/ocx`, `/Applications/OpenCodex.app/Contents/MacOS/ocx`, `/usr/bin/ocx`, then inherited PATH plus Bun/local/Homebrew directories. Each candidate must successfully report `--version`; an unrunnable stale shim is skipped. Arguments remain quoted once. The script still exits 125 on a Windows/MSYS/Cygwin `uname`, a guard against POSIX dispatch to a Windows host; exhaustion exits 127 (`remote_ocx_missing`). On Windows, `src/link/remote-windows.ts` builds one `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand` command whose arguments travel as base64 JSON data, never as shell text, and `quoteRemote` admits only that exact shape. The script tries the Desktop `ocx.exe`, the GOAT-managed `.local/share/goat/opencodex/current/ocx.exe` junction under the user profile, `.local/bin/ocx.exe`, the per-user and Program Files OpenCodex directories, then PATH. Each candidate must answer `--version` within ten seconds, and only the selected one runs the real command with CRT-quoted arguments and inherited standard handles, so the link key still arrives on stdin and the exit status passes through. The whole script travels deflated so the command fits, and the Child inflates it before anything in it runs. A helper compiled with `Add-Type` (`OCX_JOB_SOURCE`, a quoted literal inside that script, never evaluated as PowerShell) creates that process with `PROC_THREAD_ATTRIBUTE_JOB_LIST`, so it is a member of a Win32 Job with kill-on-close and silent breakaway from the moment it exists: terminating the PowerShell host, which is what the SSH server does with a dropped channel, kills that `ocx.exe`, while a runtime it starts on its own breaks away and survives. One finally block releases the attribute list and the process, thread and Job handles on every path and terminates the child through its own handle if anything fails after creation. If the inflation or `Add-Type` fails (Constrained Language Mode blocks both the stream types and `Add-Type`, for example), the command exits 126 before any `ocx.exe` is probed or started. A failure while the helper creates, waits on or reads the selected process also exits 126, after the finally block above has terminated that process. There is no uncontained fallback. The `--version` probes that choose a candidate run outside the Job, bounded to ten seconds each; only the selected command runs inside it. The inflation guard covers the inflation alone, so the exit status and errors of the inflated script itself pass through unchanged. The encoded command stays under 8,000 characters for cmd.exe's 8,191-character command line. A dropped real OpenSSH channel is a required native release gate: the opt-in case in `tests/clients/link-ssh-argv-discovery.test.ts` runs against a disposable Windows OpenSSH host named by `OCX_WINDOWS_OPENSSH_FIXTURE`. Exhaustion exits 127. Coverage: `tests/clients/link-ssh-argv-discovery.test.ts`. -`src/link/remote-platform.ts` discovers the platform through the pinned host key: `requireRemotePlatform` runs the fixed, locale-independent `cmd /c echo %OS%` (`WINDOWS_OS_PROBE`), treats `Windows_NT` as Windows, and otherwise classifies `uname -s`. An ssh transport failure or an unclassifiable answer throws, so enrollment answers `probe_failed` before it trusts the host or issues a key. The first-contact probe runs the cross-shell `echo ocx-link-probe` and requires that exact output. The probe stores the platform on the pending host, confirmation and apply reuse it, and the link record keeps it as the optional `remotePlatform` in `src/link/store.ts`; a record written before the field existed is discovered again under its pinned key. On Windows, enrollment skips the curl preflight and reads the Child's loopback `/healthz` and `/v1/catalog` with a bounded .NET request that ignores proxies, never follows a redirect and caps the body at 4 MiB (`windowsChildHttpArgv`). Ordinary revoke, the Child-initiated issue and revoke in `src/client/link-join.ts` and `src/client/link-teardown.ts`, and the read-only `ocx link cleanup` in `src/cli/link-cleanup.ts` use the same adapter. Coverage: `tests/server/link-enrollment.test.ts`, `tests/server/link-management-routes.test.ts`, `tests/cli/cli-link-cleanup.test.ts`, `tests/clients/link-store.test.ts`. +`src/link/remote-platform.ts` discovers the platform through the pinned host key: `requireRemotePlatform` runs the fixed, locale-independent `cmd /c echo %OS%` (`WINDOWS_OS_PROBE`), treats `Windows_NT` as Windows, and otherwise classifies `uname -s`. An ssh transport failure or an unclassifiable answer throws, so enrollment answers `probe_failed` before it trusts the host or issues a key. The first-contact probe runs the cross-shell `echo ocx-link-probe` and requires that exact output. The probe stores the platform on the pending host, confirmation and apply reuse it, and the link record keeps it as the optional `remotePlatform` in `src/link/store.ts`; a record written before the field existed is discovered again under its pinned key. The optional `childKind` (`"ephemeral" | "full"`) joined the same strict `RECORD_KEYS` set with the ocx-child plant flow, with the same trade-off as `remotePlatform`: a Home downgraded to a release without the field rejects a `links.json` that contains it, so downgrading needs those links disconnected (or the field removed) first. On Windows, enrollment skips the curl preflight and reads the Child's loopback `/healthz` and `/v1/catalog` with a bounded .NET request that ignores proxies, never follows a redirect and caps the body at 4 MiB (`windowsChildHttpArgv`). Ordinary revoke, the Child-initiated issue and revoke in `src/client/link-join.ts` and `src/client/link-teardown.ts`, and the read-only `ocx link cleanup` in `src/cli/link-cleanup.ts` use the same adapter. Coverage: `tests/server/link-enrollment.test.ts`, `tests/server/link-management-routes.test.ts`, `tests/cli/cli-link-cleanup.test.ts`, `tests/clients/link-store.test.ts`. `src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. Include discovery shields literal parentheses and braces from Bun's extended-glob and brace expansion, preserving existing escapes and character classes; wildcard matching, traversal options and lexical ordering remain with `Bun.Glob`. Coverage: `tests/clients/link-ssh-config.test.ts`. A candidate is an offer, not trust. diff --git a/tests/clients/link-child-plant-foundations.test.ts b/tests/clients/link-child-plant-foundations.test.ts index b47a1d71357..d6ac17b6911 100644 --- a/tests/clients/link-child-plant-foundations.test.ts +++ b/tests/clients/link-child-plant-foundations.test.ts @@ -134,6 +134,34 @@ describe("child-binary-source SHA256SUMS and boundary guards", () => { expect(resolveChildBinarySource(null)).toEqual({ ok: false, reason: "unsupported_target" }); }); + + test("an empty OPENCODEX_HOME falls back to ~/.opencodex for the artifact cache instead of the working directory", () => { + const fakeHome = join(scratch, "empty-ocx-home-user"); + const cacheDir = join(fakeHome, ".opencodex", "cache", "ocx-child", "2.98.0"); + mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); + const bytes = Buffer.from("cached-child-binary", "utf8"); + writeFileSync(join(cacheDir, childArtifactFilename("linux-arm64")), bytes, { mode: 0o700 }); + writeFileSync(join(cacheDir, "SHA256SUMS"), createHash("sha256").update(bytes).digest("hex") + " " + childArtifactFilename("linux-arm64") + "\n", { mode: 0o600 }); + const empty = join(scratch, "empty-elsewhere"); + mkdirSync(empty, { recursive: true }); + const resolved = resolveChildBinarySource("linux-arm64", { + env: { OPENCODEX_HOME: "", HOME: fakeHome }, + version: "2.98.0", + repoRoot: empty, + execPath: join(empty, "bun"), + allowLocalRelease: false, + }); + expect(resolved.ok).toBe(true); + if (resolved.ok) expect(resolved.artifact.source).toBe("cache"); + const blank = resolveChildBinarySource("linux-arm64", { + env: { OPENCODEX_HOME: " ", HOME: fakeHome }, + version: "2.98.0", + repoRoot: empty, + execPath: join(empty, "bun"), + allowLocalRelease: false, + }); + expect(blank.ok).toBe(true); + }); }); describe("remote-plant POSIX & Windows command contracts", () => { From f9d1b5462d4bec96e8a8a1b3676aeba78c8d9c8a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:25:53 +0900 Subject: [PATCH 40/50] 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 41/50] 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 42/50] 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 daa997358943e150fed955709cbf35dcbebb91db Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:28:24 +0900 Subject: [PATCH 43/50] docs(link): say where a plantable ocx-child build must come from today The guide claimed a bare SSH host works out of the box, but resolveChildBinarySource only finds a build in OPENCODEX_CHILD_BIN_DIR, native-bin/ sidecars (no package ships them), a source checkout's own cargo release build, or a SHA256SUMS-verified $OPENCODEX_HOME/cache/ocx-child// (nothing fills it). A packaged npm or desktop Home therefore reports plantable:false with artifact_not_found. The English guide and structure now list those sources in order, state that packaged installs need one of them, and describe the Child's hash tool requirement. When the probe reports artifact_not_found, the CLI, the remote_plant_unavailable message (plantUnavailableGuidance), and the #remote sheet (link.plant.artifactHelp in all 11 locales) name the file and the directories Home reads. --- .../src/content/docs/guides/remote-link.md | 13 ++++++++-- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/RemoteLinkSheet.tsx | 16 ++++++++++++- gui/tests/remote-link.test.tsx | 24 +++++++++++++++++++ src/cli/link-enrollment.ts | 3 ++- src/link/child-binary-source.ts | 12 ++++++++++ src/server/management/link-enrollment.ts | 4 ++-- structure/remote-link.md | 2 +- tests/cli/cli-link-enrollment.test.ts | 14 +++++++++++ 19 files changed, 92 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 4c31a9d6670..98cbb42ee90 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -9,7 +9,7 @@ A machine link connects an OpenCodex **Home** computer to a **Child** computer o - The Home computer can log in to the Child with an OpenSSH key. - For a Child-initiated link, the Child can log in to Home with an OpenSSH key (password login is not supported). -- OpenCodex 2.66.0 or later on Home (and on the Child for a Child-initiated link or full Child install). When adding a Child from Home, a bare SSH host needs no pre-installed Node, Bun, or OpenCodex: Home can plant the standalone lightweight `ocx-child` binary (~0.9 MB) over SSH during onboarding. +- OpenCodex 2.66.0 or later on Home (and on the Child for a Child-initiated link or full Child install). When adding a Child from Home, a bare SSH host needs no pre-installed Node, Bun, or OpenCodex if Home has a verified `ocx-child` build for the Child's platform: Home can then plant that standalone binary over SSH during onboarding. The npm package and the desktop app do not include `ocx-child` builds; see [Ephemeral Child machines](#ephemeral-child-machines-ocx-child) for where Home looks for one. - Both computers run macOS, Linux, or Windows x64. Windows requires OpenSSH Server and Windows PowerShell; Git Bash and curl are not required. - The dashboard that adds a Child from Home is opened on Home itself or through a paired Hub session. Turning the current computer into a Child requires an operator-paired dashboard session; a credentialless local dashboard session cannot commit that routing change. @@ -197,7 +197,16 @@ 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, Home can stream a SHA-256-verified standalone `ocx-child` binary (~0.9 MB, zero Node/Bun required) directly over SSH `stdin` into `~/.cache/opencodex/bin/ocx-child` (`%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` on Windows) when you choose **Plant lightweight agent & connect** in `#remote` or run `ocx link add --plant --expect-fingerprint SHA256:…` (interactive TTY `ocx link add ` prompts once before planting; air-gapped Children with zero outbound internet work out of the box). You can also pre-place `ocx-child` in `~/.local/bin/ocx-child`, `~/.cache/opencodex/bin/ocx-child`, or `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. +For disposable SSH devboxes, containers and lightweight cloud VMs where installing the full Bun OpenCodex runtime is unnecessary, Home can stream a SHA-256-verified standalone `ocx-child` binary (no Node or Bun required on the Child) over SSH `stdin` into `~/.cache/opencodex/bin/ocx-child` (`%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` on Windows) when you choose **Plant lightweight agent & connect** in `#remote` or run `ocx link add --plant --expect-fingerprint SHA256:…` (an interactive `ocx link add ` asks once before planting). The Child downloads nothing, so it needs no outbound internet access, but a POSIX Child must have `sha256sum`, `shasum`, or `openssl`: the binary is checked against its SHA-256 before it is installed or run, and without one of those tools the plant is refused (`hash_tool_unavailable`). You can also pre-place `ocx-child` in `~/.local/bin/ocx-child`, `~/.cache/opencodex/bin/ocx-child`, or `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. + +Home plants only a build it can verify, and it does not download or build one. The npm package and the desktop app ship no `ocx-child` builds, so on a packaged install the probe reports the bare host as not plantable (`plantable: false`, `plantUnavailableReason: "artifact_not_found"`) until you provide one. Home looks in these places, in order, for the Child's target (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, or `windows-x64`): + +1. The directory named by `OPENCODEX_CHILD_BIN_DIR` on Home: `ocx-child-` (`ocx-child-windows-x64.exe` for Windows) or `/ocx-child`, plus plain `ocx-child` when the Child has the Home's own target. The file must be listed in a `SHA256SUMS` file in the same directory. +2. A `native-bin/` directory next to the OpenCodex sources or the running executable, with the same `SHA256SUMS` rule. No OpenCodex package provides this directory today. +3. In a source checkout only, `native/ocx-child/target/release/ocx-child` when the Child has the Home's own target. +4. `$OPENCODEX_HOME/cache/ocx-child//ocx-child-` with a `SHA256SUMS` file beside it. Nothing fills this cache automatically; copy builds there yourself. + +To prepare a build, run `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` in a source checkout (cross-compiling for other targets), copy the result to one of those directories under the `ocx-child-` name, and write its `SHA256SUMS` line with `sha256sum ocx-child- >> SHA256SUMS` (or `shasum -a 256`). The files must not be symlinks or group- or world-writable. Unlike a full `ocx` Child, a clean detached `ocx-child` host needs no pre-running background daemon before `ocx link add`: Home checks target-port availability and `~/.codex/config.toml` ownership before committing (`409 child_port_busy` if port `10100` is held by another process, or `409 child_external_provider_conflict` if another model provider or custom base URL already owns `config.toml`, leaving Child files untouched and rolling back any newly planted binary), pipes the link credential over SSH stdin, starts `ocx-child serve`, and confirms an authenticated `/v1/catalog` relay. Connected rows in `#remote` display a **Lightweight** / **Full install** badge and a **Home settings synced** / **Sync needed** parity badge. Running `ocx link revoke --link-id ` keeps the cached binary for fast re-attach, whereas `ocx link revoke --link-id --purge` (or `ocx-child detach --purge` on the Child) also deletes the planted binary from the managed cache directory. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 53ea2ebc596..23cd65d0139 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4309,6 +4309,7 @@ export const de: Record = { "link.plant.apply": "Leichtgewicht-Agent bereitstellen & verbinden", "link.plant.applying": "Wird bereitgestellt & verbunden…", "link.plant.unavailable": "Auf diesem Host ({arch}) wurde kein OpenCodex gefunden und lokal ist keine verifizierte ocx-child-Binärdatei verfügbar.", + "link.plant.artifactHelp": "Zum Platzieren braucht dieser Home-Rechner einen verifizierten Build: Legen Sie {file} und eine SHA256SUMS-Datei, die ihn auflistet, in den Ordner aus OPENCODEX_CHILD_BIN_DIR oder nach $OPENCODEX_HOME/cache/ocx-child// und bestätigen Sie den Host dann erneut.", "link.plant.unknownArch": "unbekannte Architektur", "link.ephemeralReady": "Leichtgewicht-Agent bereit (ocx-child {version})", "remoteLink.badge.ephemeral": "Leichtgewicht", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6d121472b3f..24d16ebd787 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4343,6 +4343,7 @@ export const en = { "link.plant.apply": "Plant lightweight agent & connect", "link.plant.applying": "Planting & connecting…", "link.plant.unavailable": "OpenCodex was not found on this host ({arch}), and no verified ocx-child binary is available locally to plant.", + "link.plant.artifactHelp": "To plant it, this Home needs a verified build: put {file} and a SHA256SUMS file that lists it in the folder named by OPENCODEX_CHILD_BIN_DIR, or in $OPENCODEX_HOME/cache/ocx-child//, then confirm the host again.", "link.plant.unknownArch": "unknown architecture", "link.ephemeralReady": "Lightweight agent ready (ocx-child {version})", "remoteLink.badge.ephemeral": "Lightweight", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 9d89c1d5c08..0d6a1494a2e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4298,6 +4298,7 @@ export const fr: Record = { "link.plant.apply": "Déployer l’agent léger et connecter", "link.plant.applying": "Déploiement et connexion…", "link.plant.unavailable": "OpenCodex est introuvable sur cet hôte ({arch}) et aucun binaire ocx-child vérifié n’est disponible localement.", + "link.plant.artifactHelp": "Pour le déployer, ce Home a besoin d’une version vérifiée : placez {file} et un fichier SHA256SUMS qui le liste dans le dossier indiqué par OPENCODEX_CHILD_BIN_DIR, ou dans $OPENCODEX_HOME/cache/ocx-child//, puis confirmez de nouveau l’hôte.", "link.plant.unknownArch": "architecture inconnue", "link.ephemeralReady": "Agent léger prêt (ocx-child {version})", "remoteLink.badge.ephemeral": "Léger", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 23d91bc5eab..3d29cd036e1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4331,6 +4331,7 @@ export const ja: Record = { "link.plant.apply": "軽量エージェントを配置して接続", "link.plant.applying": "配置して接続中…", "link.plant.unavailable": "リモートホスト({arch})に OpenCodex が見つからず、配置可能な検証済み ocx-child バイナリもローカルにありません。", + "link.plant.artifactHelp": "配置するには、この Home に検証済みビルドが必要です。{file} とそれを記載した SHA256SUMS ファイルを OPENCODEX_CHILD_BIN_DIR で指定したフォルダ、または $OPENCODEX_HOME/cache/ocx-child// に置き、ホストをもう一度確認してください。", "link.plant.unknownArch": "不明なアーキテクチャ", "link.ephemeralReady": "軽量エージェント準備完了(ocx-child {version})", "remoteLink.badge.ephemeral": "軽量モジュール", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 577dd1b3f9a..8153f0bc5d1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4331,6 +4331,7 @@ export const ko: Record = { "link.plant.apply": "경량 에이전트 심고 연결", "link.plant.applying": "경량 에이전트 심고 연결 중…", "link.plant.unavailable": "원격 컴퓨터({arch})에 OpenCodex가 없으며, 이 컴퓨터에서 심을 수 있는 검증된 경량 에이전트 파일을 찾지 못했습니다.", + "link.plant.artifactHelp": "심으려면 이 Home에 검증된 빌드가 있어야 합니다. {file} 파일과 이를 기록한 SHA256SUMS 파일을 OPENCODEX_CHILD_BIN_DIR이 가리키는 폴더나 $OPENCODEX_HOME/cache/ocx-child//에 넣은 다음 호스트를 다시 확인하세요.", "link.plant.unknownArch": "알 수 없는 아키텍처", "link.ephemeralReady": "경량 에이전트 준비됨 (ocx-child {version})", "remoteLink.badge.ephemeral": "경량 모듈", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index bac5dcacbba..176cfa47496 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -4304,6 +4304,7 @@ export const pt: Record = { "link.plant.apply": "Instalar agente leve e conectar", "link.plant.applying": "Instalando e conectando…", "link.plant.unavailable": "O OpenCodex não foi encontrado neste host ({arch}) e não há binário ocx-child verificado disponível localmente.", + "link.plant.artifactHelp": "Para implantá-lo, este Home precisa de uma versão verificada: coloque {file} e um arquivo SHA256SUMS que o liste na pasta indicada por OPENCODEX_CHILD_BIN_DIR ou em $OPENCODEX_HOME/cache/ocx-child// e confirme o host novamente.", "link.plant.unknownArch": "arquitetura desconhecida", "link.ephemeralReady": "Agente leve pronto (ocx-child {version})", "remoteLink.badge.ephemeral": "Leve", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 27cb3528da2..31098c9b182 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4332,6 +4332,7 @@ export const ru: Record = { "link.plant.apply": "Установить лёгкий агент и подключить", "link.plant.applying": "Установка и подключение…", "link.plant.unavailable": "OpenCodex не найден на этом хосте ({arch}), а проверенный бинарный файл ocx-child локально отсутствует.", + "link.plant.artifactHelp": "Чтобы установить его, этому Home нужна проверенная сборка: поместите {file} и файл SHA256SUMS с его записью в папку из OPENCODEX_CHILD_BIN_DIR или в $OPENCODEX_HOME/cache/ocx-child/<версия Home>/, затем снова подтвердите хост.", "link.plant.unknownArch": "неизвестная архитектура", "link.ephemeralReady": "Лёгкий агент готов (ocx-child {version})", "remoteLink.badge.ephemeral": "Лёгкий модуль", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ef01eb73825..9080c9e63df 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -4332,6 +4332,7 @@ export const tr: Record = { "link.plant.apply": "Hafif aracıyı yerleştir ve bağlan", "link.plant.applying": "Yerleştiriliyor ve bağlanıyor…", "link.plant.unavailable": "Bu ana bilgisayarda ({arch}) OpenCodex bulunamadı ve yerel olarak doğrulanmış ocx-child ikili dosyası mevcut değil.", + "link.plant.artifactHelp": "Yerleştirmek için bu Home'un doğrulanmış bir derlemeye ihtiyacı var: {file} dosyasını ve onu listeleyen bir SHA256SUMS dosyasını OPENCODEX_CHILD_BIN_DIR ile belirtilen klasöre veya $OPENCODEX_HOME/cache/ocx-child// içine koyun, ardından ana bilgisayarı yeniden onaylayın.", "link.plant.unknownArch": "bilinmeyen mimari", "link.ephemeralReady": "Hafif aracı hazır (ocx-child {version})", "remoteLink.badge.ephemeral": "Hafif", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 59d91dd30a9..d9b313eef18 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -4301,6 +4301,7 @@ export const vi: Record = { "link.plant.apply": "Cài tác nhân gọn nhẹ & kết nối", "link.plant.applying": "Đang cài & kết nối…", "link.plant.unavailable": "Không tìm thấy OpenCodex trên máy chủ này ({arch}) và không có tệp ocx-child đã xác minh trên máy cục bộ.", + "link.plant.artifactHelp": "Để cài đặt, Home này cần một bản dựng đã xác minh: đặt {file} và tệp SHA256SUMS liệt kê nó vào thư mục do OPENCODEX_CHILD_BIN_DIR chỉ định, hoặc vào $OPENCODEX_HOME/cache/ocx-child//, rồi xác nhận lại máy chủ.", "link.plant.unknownArch": "kiến trúc không xác định", "link.ephemeralReady": "Tác nhân gọn nhẹ sẵn sàng (ocx-child {version})", "remoteLink.badge.ephemeral": "Gọn nhẹ", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 77ddc81a7e6..e053cf68fec 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -4295,6 +4295,7 @@ export const zhTW: Record = { "link.plant.apply": "植入輕量代理並連線", "link.plant.applying": "正在植入並連線…", "link.plant.unavailable": "遠端主機({arch})未安裝 OpenCodex,且本機沒有可植入的已驗證 ocx-child 執行檔。", + "link.plant.artifactHelp": "若要植入,此 Home 需要經過驗證的建置:將 {file} 與列出它的 SHA256SUMS 檔案放到 OPENCODEX_CHILD_BIN_DIR 指定的資料夾,或 $OPENCODEX_HOME/cache/ocx-child//,然後再次確認主機。", "link.plant.unknownArch": "未知架構", "link.ephemeralReady": "輕量代理已就緒(ocx-child {version})", "remoteLink.badge.ephemeral": "輕量模組", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e62dbd2aa68..4c50e32add4 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4330,6 +4330,7 @@ export const zh: Record = { "link.plant.apply": "植入轻量代理并连接", "link.plant.applying": "正在植入并连接…", "link.plant.unavailable": "远程主机({arch})未安装 OpenCodex,且本地没有可植入的已验证 ocx-child 二进制文件。", + "link.plant.artifactHelp": "若要植入,此 Home 需要经过验证的构建:将 {file} 和列出它的 SHA256SUMS 文件放到 OPENCODEX_CHILD_BIN_DIR 指定的目录,或 $OPENCODEX_HOME/cache/ocx-child//,然后再次确认主机。", "link.plant.unknownArch": "未知架构", "link.ephemeralReady": "轻量代理已就绪(ocx-child {version})", "remoteLink.badge.ephemeral": "轻量模块", diff --git a/gui/src/pages/RemoteLinkSheet.tsx b/gui/src/pages/RemoteLinkSheet.tsx index f09fc68ae7b..e8db2d9f1d9 100644 --- a/gui/src/pages/RemoteLinkSheet.tsx +++ b/gui/src/pages/RemoteLinkSheet.tsx @@ -48,6 +48,11 @@ export interface RemoteLinkSheetProps { onRetryFailedAction: () => void; } +/** The file name Home looks for in OPENCODEX_CHILD_BIN_DIR and its cache (child-binary-source.ts). */ +function childArtifactName(targetTriple: string): string { + return targetTriple.startsWith("windows-") ? "ocx-child-" + targetTriple + ".exe" : "ocx-child-" + targetTriple; +} + const CARD = "flex flex-col gap-3 rounded-[10px] bg-surface p-4 ring-1 ring-line"; function ConfirmationCard({ confirmation, isChildFlow, childSelectable, busy, onApplyLink, onJoinLink }: { @@ -71,7 +76,16 @@ function ConfirmationCard({ confirmation, isChildFlow, childSelectable, busy, on ); } if (confirmation.runtimeKind === "missing") { - if (!confirmation.plantable) return {t("link.plant.unavailable", { arch: confirmation.targetTriple ?? t("link.plant.unknownArch") })}; + if (!confirmation.plantable) { + return ( + + {t("link.plant.unavailable", { arch: confirmation.targetTriple ?? t("link.plant.unknownArch") })} + {confirmation.plantUnavailableReason === "artifact_not_found" && confirmation.targetTriple && ( + {t("link.plant.artifactHelp", { file: childArtifactName(confirmation.targetTriple) })} + )} + + ); + } return (

{t("link.plant.title")}

diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index a3e7d7108ef..8b689dc4f72 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -3,6 +3,7 @@ import { Window } from "happy-dom"; import type { Root } from "react-dom/client"; import { act } from "react"; import RemoteLink from "../src/pages/RemoteLink"; +import { RemoteLinkSheet } from "../src/pages/RemoteLinkSheet"; import { boundLinkHint, CHILD_RESTART_NOTICE_MS, CHILD_RESTART_POLL_MS, CHILD_RESTART_SLOW_POLL_MS, LINK_ERROR_CODES, LINK_JOIN_DENIALS, LinkApiError, parseRemoteLinkStatus, readLinkJson, waitForChildRuntime, type RemoteLinkStatusWire } from "../src/remote-link-api"; import { LanguageProvider } from "../src/i18n/provider"; import { LOCALES } from "../src/i18n/shared"; @@ -1236,3 +1237,26 @@ test("Ready-to-Plant, cached ephemeral, connected badges, conflict notices, and await flush(); expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/api/link/lnk_eph", body: { force: true } }); }); + +test("a bare host Home cannot plant names the verified build Home needs and where it looks", async () => { + const host = win.document.createElement("div"); + win.document.body.append(host); + root = (await import("react-dom/client")).createRoot(host); + const noop = () => {}; + const sheet = (reason: string | null) => ( + + + + ); + await act(async () => { root?.render(sheet("artifact_not_found")); }); + const text = host.textContent ?? ""; + expect(text).toContain("no verified ocx-child binary"); + expect(text).toContain("OPENCODEX_CHILD_BIN_DIR"); + expect(text).toContain("ocx-child-linux-arm64"); + expect(text).toContain("SHA256SUMS"); + await act(async () => { root?.render(sheet("unsupported_target")); }); + expect(host.textContent ?? "").not.toContain("OPENCODEX_CHILD_BIN_DIR"); +}); diff --git a/src/cli/link-enrollment.ts b/src/cli/link-enrollment.ts index 3842fa0d200..09dd12898bd 100644 --- a/src/cli/link-enrollment.ts +++ b/src/cli/link-enrollment.ts @@ -1,4 +1,5 @@ import { createInterface } from "node:readline/promises"; +import { plantUnavailableGuidance } from "../link/child-binary-source"; import { assertSshAlias } from "../link/ssh-argv"; import { CliUsageError, RuntimeApiError, runtimeBaseUrl, rejectArgs, takeFlag, takeJsonFlag, takeOption, printData } from "./runtime-api"; import type { LinkCliDeps } from "./link"; @@ -163,7 +164,7 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] ephemeralFlow = probe.runtimeKind === "ephemeral" || probe.runtimeKind === "missing"; if (probe.runtimeKind === "missing") { if (probe.plantable !== true) { - throw new Error(`ocx was not found on ${alias} (${probe.targetTriple ?? "unknown target"}) and no verified ocx-child binary is available locally (${probe.plantUnavailableReason ?? "unavailable"}).`); + throw new Error("ocx was not found on " + alias + ". " + plantUnavailableGuidance(probe.plantUnavailableReason, probe.targetTriple ?? null)); } if (!isTTY && !plantFlag) { throw new CliUsageError("Remote host has no OpenCodex runtime; pass --plant alongside --expect-fingerprint to plant lightweight ocx-child.", usage); diff --git a/src/link/child-binary-source.ts b/src/link/child-binary-source.ts index 9f6dfd2bbf8..5165bbb7eb6 100644 --- a/src/link/child-binary-source.ts +++ b/src/link/child-binary-source.ts @@ -143,6 +143,18 @@ function verifyManifestCandidate( }; } +/** What an operator must do when Home cannot plant ocx-child, for the CLI and the API message. */ +export function plantUnavailableGuidance(reason: string | undefined, targetTriple: string | null, version: string = packageVersion("0.0.0")): string { + if (reason === "artifact_not_found" && targetTriple && isChildTargetTriple(targetTriple)) { + const file = childArtifactFilename(targetTriple); + return "No verified ocx-child build for " + targetTriple + " is available on this Home. Put " + file + " and a SHA256SUMS file that lists it in the directory named by OPENCODEX_CHILD_BIN_DIR, or in $OPENCODEX_HOME/cache/ocx-child/" + version + "/, then try again."; + } + if (reason === "checksum_mismatch") { + return "An ocx-child build for " + (targetTriple ?? "this target") + " was found on this Home but failed verification: it needs a matching SHA256SUMS entry, must not be a symlink, and must not be group- or world-writable."; + } + return "ocx-child has no build for this Child's platform (" + (targetTriple ?? "unknown target") + ")."; +} + export function resolveChildBinarySource( targetTriple: ChildTargetTriple | null, options: ResolveChildBinaryOptions = {}, diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index 3a6c9964341..e6397ea4b94 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; 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 { resolveChildBinarySource } from "../../link/child-binary-source"; +import { plantUnavailableGuidance, resolveChildBinarySource } from "../../link/child-binary-source"; import { parseFingerprintLine } from "../../link/fingerprint"; import { isDetachedEphemeralChildConfig } from "../../link/ephemeral-child"; import { classifyChildAttachConflict, classifyPlantFailure, inspectRemoteChildRuntime, remotePlantChildArgv, remotePurgePlantedChildArgv } from "../../link/remote-plant"; @@ -257,7 +257,7 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom if (body.plant === true && confirmed.runtimeKind === "missing") { phase = "remote_plant"; const source = resolveBinary(confirmed.targetTriple ?? null); - if (!source.ok) return fail("remote_plant_unavailable", "No verified ocx-child binary is available for this host architecture.", 409, source.reason); + if (!source.ok) return fail("remote_plant_unavailable", plantUnavailableGuidance(source.reason, confirmed.targetTriple ?? null), 409, source.reason); let planted: SshRunResult; try { planted = await runner.run( diff --git a/structure/remote-link.md b/structure/remote-link.md index 30c5745f183..d8867dcfb7f 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -188,7 +188,7 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src `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 `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVersion: true`) or `POST /api/link/confirm-host`, `src/link/remote-plant.ts` inspects the remote target triple (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`) and classifies `runtimeKind` as `"full"`, `"ephemeral"`, or `"missing"`, while `src/link/child-binary-source.ts` resolves a non-symlink `ocx-child` artifact (`OPENCODEX_CHILD_BIN_DIR`, packaged sidecars, host-matching local Cargo release builds of `native/ocx-child`, or `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//`). On a bare host (`runtimeKind: "missing"`), `POST /api/link/apply` requires explicit `plant: true`, streams the verified binary over SSH `stdin` into `$HOME/.cache/opencodex/bin/ocx-child` (`0700` atomic write on POSIX, only after the size and the SHA-256 match; the digest is checked with `sha256sum`, `shasum` or `openssl dgst`, and with none of them the plant exits before installing or running anything and Home answers `502 remote_plant_failed` with hint `hash_tool_unavailable`) or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` (inside `OcxJob` on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via `remotePurgePlantedChildArgv` if enrollment fails. +When `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVersion: true`) or `POST /api/link/confirm-host`, `src/link/remote-plant.ts` inspects the remote target triple (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`) and classifies `runtimeKind` as `"full"`, `"ephemeral"`, or `"missing"`, while `src/link/child-binary-source.ts` resolves a non-symlink `ocx-child` artifact (`OPENCODEX_CHILD_BIN_DIR`, `native-bin/` sidecars next to the sources or the executable, host-matching local Cargo release builds of `native/ocx-child` in a source checkout, or `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//`; an empty `OPENCODEX_HOME` counts as unset). No package ships `native-bin/` sidecars and nothing populates the cache, so a packaged npm or desktop Home answers `plantable: false` with `plantUnavailableReason: "artifact_not_found"` until an operator provides a build; the CLI, the `remote_plant_unavailable` message (`plantUnavailableGuidance`) and the `#remote` sheet name those locations. On a bare host (`runtimeKind: "missing"`), `POST /api/link/apply` requires explicit `plant: true`, streams the verified binary over SSH `stdin` into `$HOME/.cache/opencodex/bin/ocx-child` (`0700` atomic write on POSIX, only after the size and the SHA-256 match; the digest is checked with `sha256sum`, `shasum` or `openssl dgst`, and with none of them the plant exits before installing or running anything and Home answers `502 remote_plant_failed` with hint `hash_tool_unavailable`) or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` (inside `OcxJob` on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via `remotePurgePlantedChildArgv` if enrollment fails. 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, maps foreign listeners and external `~/.codex/config.toml` ownership (`ocx-child-conflict:*`) to `409 child_port_busy` and `409 child_external_provider_conflict` (`502 child_preflight_failed` on an SSH transport drop), persists `childKind: "ephemeral" | "full"` on `LinkRecord` (`src/link/store.ts`, projected on `GET /api/link/status` in `src/link/status-projection.ts` alongside `gui-session` `homeCodexSettingsDigest`), enrolls through `connect --link --key-stdin`, starts `ocx-child serve` via `restart`, and verifies `role: "client"` `/healthz` plus an authenticated `/v1/catalog` callback. diff --git a/tests/cli/cli-link-enrollment.test.ts b/tests/cli/cli-link-enrollment.test.ts index e182bf80b75..5cc2cf21dfd 100644 --- a/tests/cli/cli-link-enrollment.test.ts +++ b/tests/cli/cli-link-enrollment.test.ts @@ -166,4 +166,18 @@ describe("CLI Remote Link enrollment", () => { } finally { h.restore(); } } }); + + test("a bare host without a local ocx-child build names where Home looks for one", async () => { + const missing = { alias: "bare-b", fingerprint, keyType: "ED25519", ocxVersion: null, runtimeKind: "missing", targetTriple: "linux-arm64", plantable: false, plantUnavailableReason: "artifact_not_found" }; + const h = harness([missing]); + try { + expect(await runLinkCommand(["add", "bare-b", "--plant", "--expect-fingerprint", fingerprint], h.deps)).not.toBe(0); + const message = h.err.join("\n"); + expect(message).toContain("OPENCODEX_CHILD_BIN_DIR"); + expect(message).toContain("ocx-child-linux-arm64"); + expect(message).toContain("SHA256SUMS"); + expect(message).toContain("cache/ocx-child/"); + expect(h.calls).toHaveLength(1); + } finally { h.restore(); } + }); }); From 0d0f17da0465bc31b7ae68daa3ad8ab78b1996b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:29:22 +0900 Subject: [PATCH 44/50] 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 45/50] 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 46/50] 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 47/50] 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 48/50] 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!( From 916aac68607aca752afb2030990cfe874059a492 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:45:01 +0900 Subject: [PATCH 49/50] fix(link): keep an unrunnable planted ocx-child while any recovery record exists The enrollment-rollback purge deletes a planted ocx-child that cannot run unless recovery evidence remains, but it only looked for child/state.json and codex-journal.json. An attach interrupted before state.json exists leaves only attach-rollback.json and the CODEX_HOME ownership record (.ocx-child-owner.json); the purge then deleted the one binary that could roll that attach back and reported success. Both the POSIX and the Windows purge now treat the attach rollback snapshot and the ownership record (under CODEX_HOME, defaulting to ~/.codex like ocx-child) as recovery evidence, keep the binary, name the record on stderr, and exit non-zero so Home reports a residual. --- src/link/remote-plant.ts | 13 ++++- src/link/remote-windows.ts | 5 +- structure/remote-link.md | 2 +- .../link-child-plant-foundations.test.ts | 54 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/link/remote-plant.ts b/src/link/remote-plant.ts index 0670b3c0a67..dba052e010b 100644 --- a/src/link/remote-plant.ts +++ b/src/link/remote-plant.ts @@ -189,16 +189,25 @@ export const POSIX_PURGE_PLANTED_SCRIPT = [ "done", "dest=\"$HOME/.cache/opencodex/bin/ocx-child\"", "child_dir=\"${OPENCODEX_HOME:-$HOME/.opencodex}/child\"", + "codex_home=\"${CODEX_HOME:-$HOME/.codex}\"", + // Any record ocx-child keeps until an attach, sync, or detach finishes means the binary is still + // the only way to recover, so it stays and the rollback reports a residual. + "recovery_pending() {", + " for f in \"$child_dir/state.json\" \"$child_dir/codex-journal.json\" \"$child_dir/attach-rollback.json\" \"$codex_home/.ocx-child-owner.json\"; do", + " if [ -e \"$f\" ] || [ -L \"$f\" ]; then printf 'ocx-child recovery record remains at %s; keeping the planted binary for detach.\\n' \"$f\" >&2; return 0; fi", + " done", + " return 1", + "}", "if [ -f \"$dest\" ]; then", " if [ -x \"$dest\" ]; then", " \"$dest\" detach --purge >/dev/null || exit $?", " rm -f \"$dest\" 2>/dev/null || true", - " elif [ -f \"$child_dir/state.json\" ] || [ -f \"$child_dir/codex-journal.json\" ]; then", + " elif recovery_pending; then", " exit 1", " else", " rm -f \"$dest\" 2>/dev/null || true", " fi", - "elif [ -f \"$child_dir/state.json\" ] || [ -f \"$child_dir/codex-journal.json\" ]; then", + "elif recovery_pending; then", " exit 1", "fi", "rmdir \"$HOME/.cache/opencodex/bin\" \"$HOME/.cache/opencodex\" 2>/dev/null || true", diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index 257a3dd78d6..ff6d84e6ed8 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -191,8 +191,9 @@ $rc=[OcxJob]::Run($dst,('"'+$dst+'" --version'));if($rc -ne 0){[IO.File]::Delete export function windowsPurgePlantedChildArgv(): string[] { return packedPowerShellArgv(NATIVE_PROCESS + WINDOWS_MANAGED_BIN + String.raw` -$r=if($env:HOME){$env:HOME}elseif($env:USERPROFILE){$env:USERPROFILE}else{"."};$h=if($env:OPENCODEX_HOME){$env:OPENCODEX_HOME}else{"$r\.opencodex"};$cd="$h\child" -try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){if([OcxJob]::Ok($dst)){$rc=[OcxJob]::Run($dst,('"'+$dst+'" detach --purge'));if($rc -ne 0){exit $rc};try{[IO.File]::Delete($dst)}catch{$null=$_}}elseif([IO.File]::Exists("$cd\state.json")-or[IO.File]::Exists("$cd\codex-journal.json")){exit 1}else{try{[IO.File]::Delete($dst)}catch{$null=$_}}}elseif([IO.File]::Exists("$cd\state.json")-or[IO.File]::Exists("$cd\codex-journal.json")){exit 1};Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{$null=$_}}}catch{exit 1};exit 0 +$r=if($env:HOME){$env:HOME}elseif($env:USERPROFILE){$env:USERPROFILE}else{"."};$h=if($env:OPENCODEX_HOME){$env:OPENCODEX_HOME}else{"$r\.opencodex"};$cd="$h\child";$cx=if($env:CODEX_HOME){$env:CODEX_HOME}else{"$r\.codex"} +$rec=@(@("$cd\state.json","$cd\codex-journal.json","$cd\attach-rollback.json","$cx\.ocx-child-owner.json")|?{[IO.File]::Exists($_)-or[IO.Directory]::Exists($_)});$pend=$rec.Count -gt 0;if($pend){$m='ocx-child recovery record remains at '+$rec[0]+'; keeping the planted binary for detach.'} +try{$dir=Resolve-PlantDir;$dst="$dir\ocx-child.exe";if([IO.File]::Exists($dst)){if([OcxJob]::Ok($dst)){$rc=[OcxJob]::Run($dst,('"'+$dst+'" detach --purge'));if($rc -ne 0){exit $rc};try{[IO.File]::Delete($dst)}catch{$null=$_}}elseif($pend){[Console]::Error.WriteLine($m);exit 1}else{try{[IO.File]::Delete($dst)}catch{$null=$_}}}elseif($pend){[Console]::Error.WriteLine($m);exit 1};Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{try{[IO.File]::Delete($_.FullName)}catch{$null=$_}}}catch{exit 1};exit 0 `); } diff --git a/structure/remote-link.md b/structure/remote-link.md index ca52250bcbd..19ffddc9e5b 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -188,7 +188,7 @@ The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src `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 `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVersion: true`) or `POST /api/link/confirm-host`, `src/link/remote-plant.ts` inspects the remote target triple (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`) and classifies `runtimeKind` as `"full"`, `"ephemeral"`, or `"missing"`, while `src/link/child-binary-source.ts` resolves a non-symlink `ocx-child` artifact (`OPENCODEX_CHILD_BIN_DIR`, `native-bin/` sidecars next to the sources or the executable, host-matching local Cargo release builds of `native/ocx-child` in a source checkout, or `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//`; an empty `OPENCODEX_HOME` counts as unset). No package ships `native-bin/` sidecars and nothing populates the cache, so a packaged npm or desktop Home answers `plantable: false` with `plantUnavailableReason: "artifact_not_found"` until an operator provides a build; the CLI, the `remote_plant_unavailable` message (`plantUnavailableGuidance`) and the `#remote` sheet name those locations. On a bare host (`runtimeKind: "missing"`), `POST /api/link/apply` requires explicit `plant: true`, streams the verified binary over SSH `stdin` into `$HOME/.cache/opencodex/bin/ocx-child` (`0700` atomic write on POSIX, only after the size and the SHA-256 match; the digest is checked with `sha256sum`, `shasum` or `openssl dgst`, and with none of them the plant exits before installing or running anything and Home answers `502 remote_plant_failed` with hint `hash_tool_unavailable`) or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` (inside `OcxJob` on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via `remotePurgePlantedChildArgv` if enrollment fails. +When `allowMissing: true` is passed to `POST /api/link/probe` (with `includeVersion: true`) or `POST /api/link/confirm-host`, `src/link/remote-plant.ts` inspects the remote target triple (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `windows-x64`) and classifies `runtimeKind` as `"full"`, `"ephemeral"`, or `"missing"`, while `src/link/child-binary-source.ts` resolves a non-symlink `ocx-child` artifact (`OPENCODEX_CHILD_BIN_DIR`, `native-bin/` sidecars next to the sources or the executable, host-matching local Cargo release builds of `native/ocx-child` in a source checkout, or `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//`; an empty `OPENCODEX_HOME` counts as unset). No package ships `native-bin/` sidecars and nothing populates the cache, so a packaged npm or desktop Home answers `plantable: false` with `plantUnavailableReason: "artifact_not_found"` until an operator provides a build; the CLI, the `remote_plant_unavailable` message (`plantUnavailableGuidance`) and the `#remote` sheet name those locations. On a bare host (`runtimeKind: "missing"`), `POST /api/link/apply` requires explicit `plant: true`, streams the verified binary over SSH `stdin` into `$HOME/.cache/opencodex/bin/ocx-child` (`0700` atomic write on POSIX, only after the size and the SHA-256 match; the digest is checked with `sha256sum`, `shasum` or `openssl dgst`, and with none of them the plant exits before installing or running anything and Home answers `502 remote_plant_failed` with hint `hash_tool_unavailable`) or `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` (inside `OcxJob` on Windows, refusing symlinks and reparse points), and purges any freshly planted binary via `remotePurgePlantedChildArgv` if enrollment fails. When the planted binary cannot run, that purge deletes it only if no recovery record remains (`$OPENCODEX_HOME/child/state.json`, `codex-journal.json`, `attach-rollback.json`, or `$CODEX_HOME/.ocx-child-owner.json`); otherwise it keeps the binary so `detach` can finish, names the record on stderr, and exits non-zero, and Home answers with `residual: true` and the `detach --purge` recovery step. 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, maps foreign listeners and external `~/.codex/config.toml` ownership (`ocx-child-conflict:*`) to `409 child_port_busy` and `409 child_external_provider_conflict` (`502 child_preflight_failed` on an SSH transport drop), persists `childKind: "ephemeral" | "full"` on `LinkRecord` (`src/link/store.ts`, projected on `GET /api/link/status` in `src/link/status-projection.ts` alongside `gui-session` `homeCodexSettingsDigest`), enrolls through `connect --link --key-stdin`, starts `ocx-child serve` via `restart`, and verifies `role: "client"` `/healthz` plus an authenticated `/v1/catalog` callback. diff --git a/tests/clients/link-child-plant-foundations.test.ts b/tests/clients/link-child-plant-foundations.test.ts index d6ac17b6911..6fda3e08303 100644 --- a/tests/clients/link-child-plant-foundations.test.ts +++ b/tests/clients/link-child-plant-foundations.test.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { inflateRawSync } from "node:zlib"; import { childArtifactFilename, hostChildTargetTriple, @@ -281,6 +282,59 @@ describe("remote-plant POSIX & Windows command contracts", () => { expect(existsSync(marker)).toBe(false); }); + // Every record ocx-child keeps while an attach, sync, or detach still needs the binary to finish. + const RECOVERY_RECORDS: ReadonlyArray<{ readonly name: string; readonly path: (homes: { readonly ocx: string; readonly codex: string }) => string }> = [ + { name: "child state", path: h => join(h.ocx, "child", "state.json") }, + { name: "undo journal", path: h => join(h.ocx, "child", "codex-journal.json") }, + { name: "attach rollback snapshot", path: h => join(h.ocx, "child", "attach-rollback.json") }, + { name: "CODEX_HOME ownership record", path: h => join(h.codex, ".ocx-child-owner.json") }, + ]; + + test("POSIX rollback purge keeps an unrunnable planted binary and fails while any recovery record exists", () => { + if (process.platform === "win32") return; + const argv = remotePurgePlantedChildArgv("posix"); + let n = 0; + const runPurge = (binary: "unrunnable" | "absent", record: (typeof RECOVERY_RECORDS)[number] | null, explicitHomes: boolean) => { + const home = join(scratch, "recovery-home-" + n++); + const homes = explicitHomes + ? { ocx: join(scratch, "custom-ocx-" + n), codex: join(scratch, "custom-codex-" + n) } + : { ocx: join(home, ".opencodex"), codex: join(home, ".codex") }; + const dest = join(home, ".cache", "opencodex", "bin", "ocx-child"); + mkdirSync(join(home, ".cache", "opencodex", "bin"), { recursive: true, mode: 0o700 }); + if (binary === "unrunnable") writeFileSync(dest, "not an executable", { mode: 0o600 }); + if (record) { + mkdirSync(join(record.path(homes), ".."), { recursive: true }); + writeFileSync(record.path(homes), "{}"); + } + const env: Record = { HOME: home, PATH: "/usr/bin:/bin" }; + if (explicitHomes) Object.assign(env, { OPENCODEX_HOME: homes.ocx, CODEX_HOME: homes.codex }); + const res = Bun.spawnSync(["/bin/sh", ...argv.slice(1)], { env }); + return { code: res.exitCode, kept: existsSync(dest), stderr: res.stderr.toString("utf8") }; + }; + const observed: unknown[] = [], expected: unknown[] = []; + for (const explicitHomes of [false, true]) { + expect(runPurge("unrunnable", null, explicitHomes)).toEqual({ code: 0, kept: false, stderr: "" }); + for (const record of RECOVERY_RECORDS) { + for (const binary of ["unrunnable", "absent"] as const) { + const res = runPurge(binary, record, explicitHomes); + observed.push({ record: record.name, binary, explicitHomes, failed: res.code !== 0, kept: res.kept, reported: res.stderr.includes("recovery") }); + expected.push({ record: record.name, binary, explicitHomes, failed: true, kept: binary === "unrunnable", reported: true }); + } + } + } + expect(observed).toEqual(expected); + }); + + test("Windows rollback purge checks every recovery record in both homes before deleting the binary", () => { + const outer = Buffer.from(windowsPurgePlantedChildArgv()[5]!, "base64").toString("utf16le"); + const packed = /\$ocxPacked='([^']+)'/.exec(outer); + expect(packed).not.toBeNull(); + const script = inflateRawSync(Buffer.from(Array.from(packed![1]!, ch => ch.charCodeAt(0) - 256))).toString("utf8"); + for (const name of ["state.json", "codex-journal.json", "attach-rollback.json", ".ocx-child-owner.json"]) expect(script).toContain(name); + expect(script).toContain("$env:CODEX_HOME"); + expect(script).toContain("\\.codex"); + }); + test("POSIX plant verifies the digest with openssl when sha256sum and shasum are absent", () => { if (process.platform === "win32" || !["/usr/bin/openssl", "/bin/openssl"].some(p => existsSync(p))) return; const payload = Buffer.from("#!/bin/sh\necho \"opencodex 2.98.0\"\n", "utf8"); From 88424a8ba74436ac837668b2c4cd290d69a0cb3f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 21:46:45 +0900 Subject: [PATCH 50/50] fix(gui): explain a refused --purge on a full install in the #remote sheet DELETE /api/link/:id with { "purge": true } answers 409 purge_unsupported when the Child turns out to run a full OpenCodex install, but the GUI had no mapping for that code and showed the generic "could not be completed" text. Map it to a message in all eleven locales that says there is no planted binary to delete and to disconnect without deleting it. The route-owner scrape now also reads the codes purgeDisconnectRemote returns, because the DELETE route forwards them as a variable. --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/RemoteLink.tsx | 1 + gui/src/remote-link-api.ts | 1 + gui/tests/remote-link.test.tsx | 23 ++++++++++++++++++++++- 14 files changed, 35 insertions(+), 1 deletion(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 23cd65d0139..bbf162a96cc 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4141,6 +4141,7 @@ export const de: Record = { "remoteLink.error.probe_failed": "Keine Verbindung zum SSH-Host möglich. Prüfen Sie, ob er Ihren SSH-Schlüssel akzeptiert und ob ein benötigter ProxyCommand-Helfer installiert ist.", "remoteLink.error.remote_connect_failed": "Der entfernte Computer konnte sich nicht mit diesem Home verbinden. Prüfen Sie dort den OpenCodex-Dienst und versuchen Sie es erneut.", "remoteLink.error.remote_disconnect_failed": "Die Remote-Link-Anfrage konnte nicht abgeschlossen werden.", + "remoteLink.error.purge_unsupported": "Auf diesem Remote-Computer läuft eine vollständige OpenCodex-Installation, daher gibt es keine bereitgestellte Leichtgewicht-Agent-Datei zum Löschen. Trennen Sie die Verbindung, ohne die Datei zu löschen.", "remoteLink.error.remote_ocx_missing": "ocx wurde auf dem entfernten Computer nicht gefunden. Installieren Sie dort OpenCodex oder stellen Sie sicher, dass ocx im PATH nicht interaktiver SSH-Sitzungen liegt.", "remoteLink.error.remote_ocx_outdated": "OpenCodex auf dem entfernten Computer ist für Remote Link zu alt. Führen Sie dort ocx update aus (2.66.0 oder neuer) und versuchen Sie es erneut.", "remoteLink.error.remote_ocx_unrecognized": "Der entfernte Computer hat keine OpenCodex-Version gemeldet. Remote Link benötigt OpenCodex 2.66.0 oder neuer unter macOS oder Linux.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 24d16ebd787..8b2f5ae6e5e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4175,6 +4175,7 @@ export const en = { "remoteLink.error.probe_failed": "Could not connect to the SSH host. Check that it accepts your SSH key and that any ProxyCommand helper is installed.", "remoteLink.error.remote_connect_failed": "The remote computer could not connect to this Home. Check its OpenCodex service and retry.", "remoteLink.error.remote_disconnect_failed": "Remote link request could not be completed.", + "remoteLink.error.purge_unsupported": "This remote computer runs a full OpenCodex install, so there is no planted lightweight agent binary to delete. Disconnect without deleting the binary.", "remoteLink.error.remote_ocx_missing": "ocx was not found on the remote computer. Install OpenCodex there, or make sure ocx is on the PATH of non-interactive SSH sessions.", "remoteLink.error.remote_ocx_outdated": "OpenCodex on the remote computer is too old for Remote Link. Run ocx update there (2.66.0 or later), then retry.", "remoteLink.error.remote_ocx_unrecognized": "The remote computer did not report an OpenCodex version. Remote Link needs OpenCodex 2.66.0 or later on macOS or Linux.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 0d6a1494a2e..87d5a25c97f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4130,6 +4130,7 @@ export const fr: Record = { "remoteLink.error.probe_failed": "Impossible de se connecter à l’hôte SSH. Vérifiez qu’il accepte votre clé SSH et que l’assistant ProxyCommand éventuellement requis est installé.", "remoteLink.error.remote_connect_failed": "L’ordinateur distant n’a pas pu se connecter à ce Home. Vérifiez son service OpenCodex, puis réessayez.", "remoteLink.error.remote_disconnect_failed": "La demande de lien distant n’a pas pu aboutir.", + "remoteLink.error.purge_unsupported": "Cet ordinateur distant exécute une installation complète d’OpenCodex : aucun binaire d’agent léger déployé n’est à supprimer. Déconnectez-le sans supprimer le binaire.", "remoteLink.error.remote_ocx_missing": "ocx est introuvable sur l’ordinateur distant. Installez-y OpenCodex, ou vérifiez que ocx figure dans le PATH des sessions SSH non interactives.", "remoteLink.error.remote_ocx_outdated": "OpenCodex est trop ancien sur l’ordinateur distant pour Remote Link. Exécutez-y ocx update (2.66.0 ou ultérieur), puis réessayez.", "remoteLink.error.remote_ocx_unrecognized": "L’ordinateur distant n’a pas indiqué de version d’OpenCodex. Remote Link nécessite OpenCodex 2.66.0 ou ultérieur sous macOS ou Linux.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3d29cd036e1..a4c60cff6b7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4163,6 +4163,7 @@ export const ja: Record = { "remoteLink.error.probe_failed": "SSH ホストに接続できませんでした。SSH キーで認証できるか、必要な ProxyCommand ヘルパーがインストールされているかを確認してください。", "remoteLink.error.remote_connect_failed": "リモートのコンピューターがこの Home に接続できませんでした。そのコンピューターの OpenCodex サービスを確認してから再試行してください。", "remoteLink.error.remote_disconnect_failed": "リモートリンクのリクエストを完了できませんでした。", + "remoteLink.error.purge_unsupported": "このリモートコンピュータは OpenCodex のフルインストールで動作しているため、削除する配置済み軽量エージェントファイルはありません。ファイルを削除せずに接続を解除してください。", "remoteLink.error.remote_ocx_missing": "リモートのコンピューターで ocx が見つかりませんでした。そこに OpenCodex をインストールするか、非対話 SSH セッションの PATH に ocx があることを確認してください。", "remoteLink.error.remote_ocx_outdated": "リモートのコンピューターの OpenCodex が古く、リモートリンクに対応していません。そのコンピューターで ocx update を実行して 2.66.0 以降にしてから再試行してください。", "remoteLink.error.remote_ocx_unrecognized": "リモートのコンピューターが OpenCodex のバージョンを返しませんでした。リモートリンクには macOS または Linux 上の OpenCodex 2.66.0 以降が必要です。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8153f0bc5d1..acd5ffb8064 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4163,6 +4163,7 @@ export const ko: Record = { "remoteLink.error.probe_failed": "SSH 호스트에 접속하지 못했습니다. SSH 키로 로그인할 수 있는지, ProxyCommand 도우미가 설치되어 있는지 확인하세요.", "remoteLink.error.remote_connect_failed": "원격 컴퓨터를 이 홈에 연결하지 못했습니다. 그 컴퓨터의 OpenCodex 서비스를 확인한 뒤 다시 시도하세요.", "remoteLink.error.remote_disconnect_failed": "원격 연결 요청을 완료하지 못했습니다.", + "remoteLink.error.purge_unsupported": "이 원격 컴퓨터는 OpenCodex 전체 설치로 실행 중이라 삭제할 경량 에이전트 파일이 없습니다. 파일 삭제 없이 연결을 해제하세요.", "remoteLink.error.remote_ocx_missing": "원격 컴퓨터에서 ocx를 찾지 못했습니다. 그 컴퓨터에 OpenCodex를 설치하거나, 비대화형 SSH 세션의 PATH에 ocx가 있는지 확인하세요.", "remoteLink.error.remote_ocx_outdated": "원격 컴퓨터의 OpenCodex가 원격 연결을 쓰기에는 오래되었습니다. 그 컴퓨터에서 ocx update를 실행해 2.66.0 이상으로 올린 뒤 다시 시도하세요.", "remoteLink.error.remote_ocx_unrecognized": "원격 컴퓨터가 OpenCodex 버전을 알려 주지 않았습니다. 원격 연결에는 macOS 또는 Linux의 OpenCodex 2.66.0 이상이 필요합니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 176cfa47496..191d243a22d 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -4136,6 +4136,7 @@ export const pt: Record = { "remoteLink.error.probe_failed": "Não foi possível conectar ao host SSH. Verifique se ele aceita sua chave SSH e se o auxiliar de ProxyCommand está instalado.", "remoteLink.error.remote_connect_failed": "O computador remoto não conseguiu se conectar a este Home. Verifique o serviço OpenCodex dele e tente novamente.", "remoteLink.error.remote_disconnect_failed": "Não foi possível concluir a solicitação de vínculo remoto.", + "remoteLink.error.purge_unsupported": "Este computador remoto executa uma instalação completa do OpenCodex, então não há binário de agente leve instalado para excluir. Desconecte sem excluir o binário.", "remoteLink.error.remote_ocx_missing": "O ocx não foi encontrado no computador remoto. Instale o OpenCodex lá ou garanta que o ocx esteja no PATH das sessões SSH não interativas.", "remoteLink.error.remote_ocx_outdated": "O OpenCodex no computador remoto é antigo demais para o Remote Link. Execute ocx update lá (2.66.0 ou posterior) e tente novamente.", "remoteLink.error.remote_ocx_unrecognized": "O computador remoto não informou uma versão do OpenCodex. O Remote Link exige o OpenCodex 2.66.0 ou posterior no macOS ou Linux.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 31098c9b182..16cc7a8f665 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4164,6 +4164,7 @@ export const ru: Record = { "remoteLink.error.probe_failed": "Не удалось подключиться к SSH-хосту. Убедитесь, что он принимает ваш ключ SSH и что нужная вспомогательная программа ProxyCommand установлена.", "remoteLink.error.remote_connect_failed": "Удалённый компьютер не смог подключиться к этому Home. Проверьте службу OpenCodex на нём и повторите попытку.", "remoteLink.error.remote_disconnect_failed": "Не удалось завершить запрос удалённой связи.", + "remoteLink.error.purge_unsupported": "На этом удалённом компьютере работает полная установка OpenCodex, поэтому удалять установленный файл лёгкого агента нечего. Отключитесь, не удаляя файл.", "remoteLink.error.remote_ocx_missing": "На удалённом компьютере не найден ocx. Установите там OpenCodex или убедитесь, что ocx есть в PATH неинтерактивных SSH-сеансов.", "remoteLink.error.remote_ocx_outdated": "OpenCodex на удалённом компьютере слишком старый для удалённой связи. Выполните там ocx update (версия 2.66.0 или новее) и повторите попытку.", "remoteLink.error.remote_ocx_unrecognized": "Удалённый компьютер не сообщил версию OpenCodex. Для удалённой связи нужен OpenCodex 2.66.0 или новее на macOS или Linux.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9080c9e63df..9c9f8f938b8 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -4164,6 +4164,7 @@ export const tr: Record = { "remoteLink.error.probe_failed": "SSH ana bilgisayarına bağlanılamadı. SSH anahtarınızı kabul ettiğini ve gerekiyorsa ProxyCommand yardımcısının kurulu olduğunu kontrol edin.", "remoteLink.error.remote_connect_failed": "Uzak bilgisayar bu Home'a bağlanamadı. Oradaki OpenCodex hizmetini kontrol edip tekrar deneyin.", "remoteLink.error.remote_disconnect_failed": "Uzak bağlantı isteği tamamlanamadı.", + "remoteLink.error.purge_unsupported": "Bu uzak bilgisayar tam bir OpenCodex kurulumu çalıştırıyor; silinecek yerleştirilmiş hafif aracı dosyası yok. Dosyayı silmeden bağlantıyı kesin.", "remoteLink.error.remote_ocx_missing": "Uzak bilgisayarda ocx bulunamadı. Orada OpenCodex'i kurun veya ocx'in etkileşimsiz SSH oturumlarının PATH'inde olduğundan emin olun.", "remoteLink.error.remote_ocx_outdated": "Uzak bilgisayardaki OpenCodex, uzak bağlantı için çok eski. Orada ocx update komutunu çalıştırıp 2.66.0 veya sonrasına güncelleyin, ardından yeniden deneyin.", "remoteLink.error.remote_ocx_unrecognized": "Uzak bilgisayar bir OpenCodex sürümü bildirmedi. Uzak bağlantı için macOS veya Linux üzerinde OpenCodex 2.66.0 veya sonrası gerekir.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index d9b313eef18..87530280e12 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -4092,6 +4092,7 @@ export const vi: Record = { "remoteLink.error.probe_failed": "Không kết nối được tới máy chủ SSH. Hãy kiểm tra máy đó chấp nhận khóa SSH của bạn và trình trợ giúp ProxyCommand cần thiết đã được cài đặt.", "remoteLink.error.remote_connect_failed": "Máy từ xa không kết nối được tới Home này. Hãy kiểm tra dịch vụ OpenCodex trên máy đó rồi thử lại.", "remoteLink.error.remote_disconnect_failed": "Không thể hoàn tất yêu cầu liên kết từ xa.", + "remoteLink.error.purge_unsupported": "Máy tính từ xa này chạy bản cài đặt OpenCodex đầy đủ nên không có tệp tác nhân gọn nhẹ nào để xóa. Hãy ngắt kết nối mà không xóa tệp.", "remoteLink.error.remote_ocx_missing": "Không tìm thấy ocx trên máy từ xa. Hãy cài OpenCodex trên máy đó hoặc kiểm tra ocx có trong PATH của phiên SSH không tương tác.", "remoteLink.error.remote_ocx_outdated": "OpenCodex trên máy từ xa quá cũ để dùng liên kết từ xa. Hãy chạy ocx update trên máy đó (2.66.0 trở lên) rồi thử lại.", "remoteLink.error.remote_ocx_unrecognized": "Máy từ xa không báo phiên bản OpenCodex. Liên kết từ xa cần OpenCodex 2.66.0 trở lên trên macOS hoặc Linux.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e053cf68fec..1e9e42176be 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -4127,6 +4127,7 @@ export const zhTW: Record = { "remoteLink.error.probe_failed": "無法連線到 SSH 主機。請確認該主機接受你的 SSH 金鑰,並已安裝所需的 ProxyCommand 輔助程式。", "remoteLink.error.remote_connect_failed": "遠端電腦無法連線到此 Home。請檢查該電腦上的 OpenCodex 服務後重試。", "remoteLink.error.remote_disconnect_failed": "無法完成遠端連線要求。", + "remoteLink.error.purge_unsupported": "此遠端電腦執行的是完整的 OpenCodex 安裝,沒有可刪除的已植入輕量代理檔案。請在不刪除檔案的情況下中斷連線。", "remoteLink.error.remote_ocx_missing": "在遠端電腦上找不到 ocx。請在該電腦上安裝 OpenCodex,或確認 ocx 位於非互動式 SSH 工作階段的 PATH 中。", "remoteLink.error.remote_ocx_outdated": "遠端電腦上的 OpenCodex 版本過舊,不支援遠端連線。請在該電腦上執行 ocx update 升級到 2.66.0 或更新版本後重試。", "remoteLink.error.remote_ocx_unrecognized": "遠端電腦沒有回報 OpenCodex 版本。遠端連線需要 macOS 或 Linux 上的 OpenCodex 2.66.0 或更新版本。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4c50e32add4..b988a709ecb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4162,6 +4162,7 @@ export const zh: Record = { "remoteLink.error.probe_failed": "无法连接到 SSH 主机。请确认该主机接受你的 SSH 密钥,并已安装所需的 ProxyCommand 辅助程序。", "remoteLink.error.remote_connect_failed": "远程电脑无法连接到此 Home。请检查该电脑上的 OpenCodex 服务后重试。", "remoteLink.error.remote_disconnect_failed": "无法完成远程连接请求。", + "remoteLink.error.purge_unsupported": "此远程计算机运行的是完整的 OpenCodex 安装,没有可删除的已植入轻量代理文件。请在不删除文件的情况下断开连接。", "remoteLink.error.remote_ocx_missing": "在远程电脑上找不到 ocx。请在该电脑上安装 OpenCodex,或确认 ocx 位于非交互式 SSH 会话的 PATH 中。", "remoteLink.error.remote_ocx_outdated": "远程电脑上的 OpenCodex 版本过旧,不支持远程连接。请在该电脑上运行 ocx update 升级到 2.66.0 或更高版本后重试。", "remoteLink.error.remote_ocx_unrecognized": "远程电脑没有报告 OpenCodex 版本。远程连接需要 macOS 或 Linux 上的 OpenCodex 2.66.0 或更高版本。", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 8998a5e7296..59baa83eaa6 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -75,6 +75,7 @@ const ERROR_TKEY: Record = { link_unavailable: "remoteLink.error.link_unavailable", listener_unavailable: "remoteLink.error.listener_unavailable", probe_failed: "remoteLink.error.probe_failed", + purge_unsupported: "remoteLink.error.purge_unsupported", remote_connect_failed: "remoteLink.error.remote_connect_failed", remote_disconnect_failed: "remoteLink.error.remote_disconnect_failed", remote_ocx_missing: "remoteLink.error.remote_ocx_missing", diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index 8e28b6b056a..a9a1d82e0ed 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -30,6 +30,7 @@ export const LINK_ERROR_CODES = [ "link_unavailable", "listener_unavailable", "probe_failed", + "purge_unsupported", "remote_connect_failed", "remote_disconnect_failed", "remote_ocx_missing", diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 8b689dc4f72..f71ec8abefb 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -60,7 +60,12 @@ async function mount(props: Partial> = { test("current translated link errors remain implemented across route and enrollment owners", async () => { const source = (await Promise.all(["link-routes", "link-enrollment", "link-route-state"].map(name => Bun.file(`../src/server/management/${name}.ts`).text()))).join("\n"); - const fromRoutes = [...source.matchAll(/(?:fail\(|remoteFailure\([^,\n]+,\s*)"([a-z_]+)"/g)].map(match => match[1]).filter((value, index, all) => all.indexOf(value) === index).sort(); + // DELETE --purge forwards the code purgeDisconnectRemote chose, so its literals count as route codes. + const purgeSource = await Bun.file("../src/link/remote-plant.ts").text(); + const fromRoutes = [ + ...[...source.matchAll(/(?:fail\(|remoteFailure\([^,\n]+,\s*)"([a-z_]+)"/g)].map(match => match[1]), + ...[...purgeSource.matchAll(/\bcode: "([a-z_]+)"/g)].map(match => match[1]), + ].filter((value, index, all) => all.indexOf(value) === index).sort(); // New phase errors use generic text. These two old-server translations remain compatible // although enrollment now reports child_relay_timeout and phase-specific failures instead. const legacyOnly = new Set(["admission_timeout", "link_apply_failed"]); @@ -1238,6 +1243,22 @@ test("Ready-to-Plant, cached ephemeral, connected badges, conflict notices, and expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/api/link/lnk_eph", body: { force: true } }); }); +test("a refused --purge on a full install explains why instead of a generic failure", async () => { + globalThis.fetch = (async (_input, init) => { + if (init?.method === "DELETE") return response({ error: { code: "purge_unsupported", message: "full install" } }, 409); + return response({ ...baseStatus, links: [{ id: "lnk_stale", alias: "devbox-stale", direction: "hub-initiated", state: "connected", since: "2026-11-07T00:00:00Z", reason: null, tunnelPort: 24502, childKind: "ephemeral" }] }); + }) as typeof fetch; + const host = await mount(); + await act(async () => { ([...host.querySelectorAll(".remote-link-row button")][0] as HTMLButtonElement).click(); }); + await flush(); + const dialog = host.querySelector(".remote-link-confirm-dialog") as HTMLDialogElement; + await act(async () => { (dialog.querySelector('input[type="checkbox"]') as HTMLInputElement).click(); }); + await act(async () => { ([...dialog.querySelectorAll("button")].find(b => b.textContent?.includes("Delete & disconnect")) as HTMLButtonElement).click(); }); + await flush(); + expect(dialog.textContent).toContain("full OpenCodex install"); + expect(dialog.textContent).toContain("without deleting"); +}); + test("a bare host Home cannot plant names the verified build Home needs and where it looks", async () => { const host = win.document.createElement("div"); win.document.body.append(host);