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..675a92bd818 --- /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` | 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/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..338b39a1f9b --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md @@ -0,0 +1,197 @@ +# 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`. + +## 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/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..2ac68ff1277 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md @@ -0,0 +1,117 @@ +# 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. + +## 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/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..d8c42f9edb1 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md @@ -0,0 +1,124 @@ +# 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`. + +## 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/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..196978b1c7a --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md @@ -0,0 +1,48 @@ +# 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. + +## 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 6715ea81b1d..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 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 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. @@ -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. @@ -97,12 +97,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. 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: ```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.` @@ -197,9 +197,18 @@ 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 (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. -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. +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. 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. 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..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,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은 이 삭제를 `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에 로그인하여 실행합니다. ```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/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 38ba9f69723..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.", @@ -4304,4 +4305,24 @@ 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.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", + "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 caf0376524b..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.", @@ -4338,6 +4339,26 @@ 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.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", + "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 3a63df42930..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.", @@ -4293,4 +4294,24 @@ 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.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", + "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 fe424e97713..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 以降が必要です。", @@ -4326,4 +4327,24 @@ 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.artifactHelp": "配置するには、この Home に検証済みビルドが必要です。{file} とそれを記載した SHA256SUMS ファイルを OPENCODEX_CHILD_BIN_DIR で指定したフォルダ、または $OPENCODEX_HOME/cache/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 c7f3b0695d6..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 이상이 필요합니다.", @@ -4326,4 +4327,24 @@ 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.artifactHelp": "심으려면 이 Home에 검증된 빌드가 있어야 합니다. {file} 파일과 이를 기록한 SHA256SUMS 파일을 OPENCODEX_CHILD_BIN_DIR이 가리키는 폴더나 $OPENCODEX_HOME/cache/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개 상태로 즉시 종료됩니다. 기본적으로 빠른 재연결을 위해 경량 에이전트 파일은 캐시에 남겨둡니다.", + "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 b36665ed9e8..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.", @@ -4299,4 +4300,24 @@ 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.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", + "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 9f64b2040b1..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.", @@ -4327,4 +4328,24 @@ 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.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": "Лёгкий модуль", + "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 1ba001e0fad..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.", @@ -4327,4 +4328,24 @@ 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.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", + "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 16b3f084d4f..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.", @@ -4296,4 +4297,24 @@ 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.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ẹ", + "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 60db01ed946..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 或更新版本。", @@ -4290,4 +4291,24 @@ 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.artifactHelp": "若要植入,此 Home 需要經過驗證的建置:將 {file} 與列出它的 SHA256SUMS 檔案放到 OPENCODEX_CHILD_BIN_DIR 指定的資料夾,或 $OPENCODEX_HOME/cache/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 6c2ef994ba8..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 或更高版本。", @@ -4325,4 +4326,24 @@ 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.artifactHelp": "若要植入,此 Home 需要经过验证的构建:将 {file} 和列出它的 SHA256SUMS 文件放到 OPENCODEX_CHILD_BIN_DIR 指定的目录,或 $OPENCODEX_HOME/cache/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 b3429536aff..59baa83eaa6 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -1,18 +1,18 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState, type KeyboardEvent, 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 { isStandaloneRuntime, standaloneApiTargets } from "../api-targets"; import { ConnectPairingForm } from "../connect-pairing"; import FallbackPanel from "../components/FallbackPanel"; import { Button } from "@/components/ui-v2/button"; -import { cn } from "@/lib/utils"; -import { Banner, IconButton, PageHeader } from "../components/v2-startup/primitives"; -import { LinkIntro, LinkStatus, LinkSwitchRow, Monogram, RoleCard, SheetSteps } from "../components/v2-startup/remote-link-parts"; +import { Banner, PageHeader, StateBadge } from "../components/v2-startup/primitives"; +import { LinkIntro, LinkStatus, LinkSwitchRow, Monogram, RoleCard } from "../components/v2-startup/remote-link-parts"; +import { RemoteLinkDisconnectDialog } from "./RemoteLinkDialogs"; +import { LinkErrorNotice, RemoteLinkSheet, type LinkActionErrorView } from "./RemoteLinkSheet"; import "../styles-remote-link.css"; type RemoteLinkRole = "home" | "child"; @@ -45,6 +45,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", @@ -73,11 +75,15 @@ 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", 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", @@ -118,23 +124,18 @@ 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}}; +/** Whether a Child applied the Home's current Codex settings; null when either digest is unknown. */ +function paritySynced(homeDigest: string | null, childDigest: string | null): boolean | null { + return homeDigest !== null && childDigest !== null ? homeDigest === childDigest : null; } +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(); const [uiState, setUiState] = useState("off"); @@ -150,9 +151,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); @@ -322,10 +324,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)); @@ -337,8 +342,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(); @@ -397,6 +403,7 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail const closeConfirmation = () => { setConfirming(null); + setPurgeOnDisconnect(false); setForceError(null); window.setTimeout(() => disconnectTriggerRef.current?.focus(), 0); }; @@ -412,7 +419,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)); @@ -509,22 +521,29 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail ) : 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}}} + {statusRows.map(row => { + const synced = paritySynced(status?.homeCodexSettingsDigest ?? null, row.appliedCodexSettingsDigest ?? null); + return ( +
  • + +
    +
    + {row.alias} + {row.childKind && {t(row.childKind === "ephemeral" ? "remoteLink.badge.ephemeral" : "remoteLink.badge.full")}} + {synced !== null && {t(synced ? "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(childHome ? "remoteLink.findHome.empty" : "link.noChildren")}

@@ -546,101 +565,42 @@ export default function RemoteLink({ apiBase, sessionReady, pairingRecoveryAvail )} {(status?.role === "child" || statusRows.length > 0) && } - { event.preventDefault(); closeSheet(); }} - className="remote-link-sheet m-auto max-h-[min(800px,calc(100dvh-24px))] w-[min(620px,calc(100vw-24px))] flex-col overflow-hidden rounded-[14px] border-0 bg-surface p-1 text-fg shadow-lg ring-1 ring-line backdrop:bg-black/40 open:flex"> -
-
-
- - -
- -
-
- {role === "home" &&

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

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

} -
-

{childHome ? 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.keyType} -

{probe.fingerprint}

-
- - -
- )} - {confirmation && ( -
-

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

- {childHome && {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(); }} - className="remote-link-confirm-dialog m-auto w-[min(440px,calc(100vw-24px))] rounded-[14px] border-0 bg-surface p-1 text-fg shadow-lg ring-1 ring-line backdrop:bg-black/40"> - {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..150798346e3 --- /dev/null +++ b/gui/src/pages/RemoteLinkDialogs.tsx @@ -0,0 +1,66 @@ +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"; +import { Button } from "@/components/ui-v2/button"; + +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(); }} + className="remote-link-confirm-dialog m-auto w-[min(440px,calc(100vw-24px))] rounded-[14px] border-0 bg-surface p-1 text-fg shadow-lg ring-1 ring-line backdrop:bg-black/40"> + {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..e8db2d9f1d9 --- /dev/null +++ b/gui/src/pages/RemoteLinkSheet.tsx @@ -0,0 +1,200 @@ +import type { ReactElement, RefObject } from "react"; +import { + formatBinaryMegabytes, + type LinkCandidateView, + type LinkConfirmHostView, + type LinkProbeView, +} from "../remote-link-api"; +import { IconLink, IconRefresh, IconX } from "../icons"; +import { type TKey, useT } from "../i18n/shared"; +import { Button } from "@/components/ui-v2/button"; +import { cn } from "@/lib/utils"; +import { Banner, IconButton } from "../components/v2-startup/primitives"; +import { SheetSteps } from "../components/v2-startup/remote-link-parts"; + +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; +} + +/** 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 }: { + confirmation: LinkConfirmHostView; + isChildFlow: boolean; + childSelectable: boolean; + busy: RemoteLinkSheetProps["busy"]; + onApplyLink: () => void; + onJoinLink: () => void; +}): ReactElement { + const t = useT(); + if (isChildFlow) { + return ( +
+

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

+ {t("remoteLink.findHome.notice")} + +
+ ); + } + if (confirmation.runtimeKind === "missing") { + 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")}

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

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

+ +
+ ); + } + return ( +
+

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

+ +
+ ); +} + +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(); }} + className="remote-link-sheet m-auto max-h-[min(800px,calc(100dvh-24px))] w-[min(620px,calc(100vw-24px))] flex-col overflow-hidden rounded-[14px] border-0 bg-surface p-1 text-fg shadow-lg ring-1 ring-line backdrop:bg-black/40 open:flex"> +
+
+
+ + +
+ +
+
+ {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.keyType} +

{probe.fingerprint}

+
+ + +
+ )} + {confirmation && } + {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..a9a1d82e0ed 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", @@ -28,11 +30,15 @@ export const LINK_ERROR_CODES = [ "link_unavailable", "listener_unavailable", "probe_failed", + "purge_unsupported", "remote_connect_failed", "remote_disconnect_failed", "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 +50,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 +90,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 +145,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 +155,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 78cd850b5a1..3537ce774f6 100644 --- a/gui/src/styles-remote-link.css +++ b/gui/src/styles-remote-link.css @@ -7,7 +7,7 @@ /* the knob: bridge.css sizes and colours it, the drawing itself lives here */ .remote-link-switch { cursor: pointer; } .remote-link-switch::after { display: block; content: ""; border-radius: 50%; transition: transform 0.15s ease; } -.remote-link-sheet input[type="checkbox"] { accent-color: var(--ocx-fg); width: 15px; height: 15px; margin: 0; } +.remote-link-sheet input[type="checkbox"], .remote-link-confirm-dialog input[type="checkbox"] { accent-color: var(--ocx-fg); width: 15px; height: 15px; margin: 0; } @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; } } diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 7cdadc94ca9..f71ec8abefb 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"; @@ -59,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"]); @@ -1024,7 +1030,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 +1049,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 +1114,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 +1135,149 @@ 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 } }); +}); + +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); + 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/native/ocx-child/src/cli.rs b/native/ocx-child/src/cli.rs index 9a0a3e3f5af..da9889428cd 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, BufRead, Read}; +use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -21,10 +22,11 @@ use crate::journal::{ }; use crate::parity::LinkChildParityReport; use crate::platform::{ - DirectoryIdentity, FileLock, LockAcquireError, SecretBytes, constant_time_eq, - directory_identity, ensure_private_dir, format_iso8601_utc, now_epoch_ms, random_hex, - reject_symlink, resolve_codex_home, resolve_opencodex_home, volatile_zero_bytes, - write_private_atomic, write_regular_atomic, + DirectoryIdentity, FileLock, LockAcquireError, PurgeExecutableOutcome, SecretBytes, + constant_time_eq, directory_identity, ensure_private_dir, format_iso8601_utc, now_epoch_ms, + purge_managed_executable, random_hex, reject_symlink, resolve_codex_home, + resolve_opencodex_home, resolve_user_home, volatile_zero_bytes, write_private_atomic, + write_regular_atomic, }; use crate::relay::{ RelayCredentials, UpstreamError, fetch_home_authenticated, post_child_parity_report, @@ -1530,10 +1532,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 local_paths = current_paths(); - let _lock = match FileLock::acquire_blocking(&local_paths.lock_file) { + let lock = match FileLock::acquire_blocking(&local_paths.lock_file) { Ok(l) => l, Err(_) => { eprintln!("detach failed: child lifecycle lock busy"); @@ -1544,7 +1562,20 @@ fn cmd_detach(args: &[String]) -> u8 { match CodexHomeGuard::acquire(&local_paths, true, false, ClaimPolicy::Detach) { Ok(guard) => guard, Err(CodexHomeError::Missing) => { - return detach_without_codex_home(&local_paths, json_mode); + if let Err(code) = detach_without_codex_home(&local_paths) { + return code; + } + drop(lock); + let purged = match purge_after_detach(purge, current_exe_override) { + Ok(purged) => purged, + Err(code) => return code, + }; + print_detached( + json_mode, + crate::journal::DetachOutcome::NotAttached, + purged, + ); + return 0; } Err(e) => { eprintln!("detach refused: {}", e.message()); @@ -1637,22 +1668,66 @@ fn cmd_detach(args: &[String]) -> u8 { eprintln!("detach cleanup failed releasing Codex home ownership: {e}"); return 1; } - print_detached(json_mode, outcome); + drop(codex_guard); + // The lifecycle lock lives outside the child directory, so the emptied directory can go. + // The start gate is removed only after it is released; a relay still waiting on it finds + // no state and exits without serving. + drop(_startup_gate); + let _ = remove_optional_file(&paths.child_dir.join("daemon-start.lock")); + let _ = fs::remove_dir(&paths.child_dir); + drop(lock); + let purged = match purge_after_detach(purge, current_exe_override) { + Ok(purged) => purged, + Err(code) => return code, + }; + print_detached(json_mode, outcome, purged); 0 } -fn print_detached(json_mode: bool, outcome: crate::journal::DetachOutcome) { +/// Removes the running binary when `--purge` was requested and it sits in the managed plant +/// directory. Runs only after every recovery record is gone and the lifecycle lock is released. +fn purge_after_detach(purge: bool, current_exe_override: Option<&Path>) -> Result { + if !purge { + return Ok(false); + } + 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 Err(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) => Ok(true), + Ok(PurgeExecutableOutcome::SkippedUnmanagedPath) => Ok(false), + Err(e) => { + eprintln!("detach --purge failed: {e}"); + Err(1) + } + } +} + +fn print_detached(json_mode: bool, outcome: crate::journal::DetachOutcome, purged: bool) { 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." ); } } /// Detach when `CODEX_HOME` does not exist: succeed only if nothing needs restoring. -fn detach_without_codex_home(paths: &ChildPaths, json_mode: bool) -> u8 { +fn detach_without_codex_home(paths: &ChildPaths) -> Result<(), u8> { for path in [ &paths.rollback_file, &paths.journal_file, @@ -1665,17 +1740,16 @@ fn detach_without_codex_home(paths: &ChildPaths, json_mode: bool) -> u8 { eprintln!( "detach refused: Codex home does not exist; child recovery records were kept" ); - return 1; + return Err(1); } Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) => { eprintln!("detach refused: cannot inspect child recovery material: {e}"); - return 1; + return Err(1); } } } - print_detached(json_mode, crate::journal::DetachOutcome::NotAttached); - 0 + Ok(()) } fn cmd_status(args: &[String]) -> u8 { diff --git a/native/ocx-child/src/daemon.rs b/native/ocx-child/src/daemon.rs index 2f29f2b2853..1994191117b 100644 --- a/native/ocx-child/src/daemon.rs +++ b/native/ocx-child/src/daemon.rs @@ -82,13 +82,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 7e7e1d1f888..4bff6b078ef 100644 --- a/native/ocx-child/src/platform.rs +++ b/native/ocx-child/src/platform.rs @@ -1005,7 +1005,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) } @@ -1023,13 +1094,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)?; crate::test_hooks::barrier("lock-before-open"); @@ -1243,3 +1318,170 @@ 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) + } +} + +#[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/src/state.rs b/native/ocx-child/src/state.rs index 90d7e49989d..6cfd10598a3 100644 --- a/native/ocx-child/src/state.rs +++ b/native/ocx-child/src/state.rs @@ -30,7 +30,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/native/ocx-child/src/toml_surgeon.rs b/native/ocx-child/src/toml_surgeon.rs index 06548341b11..a7c5d5e3f98 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/lifecycle_guards.rs b/native/ocx-child/tests/lifecycle_guards.rs index c23c78bde8a..6b8eead4a99 100644 --- a/native/ocx-child/tests/lifecycle_guards.rs +++ b/native/ocx-child/tests/lifecycle_guards.rs @@ -907,7 +907,7 @@ fn lock_open_refuses_a_symlink_swapped_in_after_the_symlink_check() { 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 lock = ocx.join("child.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"]); diff --git a/native/ocx-child/tests/relay_and_lifecycle.rs b/native/ocx-child/tests/relay_and_lifecycle.rs index 8e85f13b13e..9026fd99abf 100644 --- a/native/ocx-child/tests/relay_and_lifecycle.rs +++ b/native/ocx-child/tests/relay_and_lifecycle.rs @@ -6,6 +6,7 @@ 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, Ordering}; use std::sync::{Arc, Mutex}; @@ -675,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", @@ -733,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"); @@ -980,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":[]}"# @@ -1068,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" @@ -1080,6 +1087,172 @@ fn full_lifecycle_sse_relay_spoof_rejection_sync_rollover_and_offline_1798_detac assert!(restored_cfg.contains("user_extra = \"kept\"")); assert!(!codex_home.join("opencodex-catalog.json").exists()); assert!(!codex_home.join("models_cache.json").exists()); + assert!( + !ocx_home.join("child").exists(), + "detach removes the emptied child directory, including the released start gate" + ); + assert!( + ocx_home.join("child.lock").exists(), + "the lifecycle lock lives outside child/ and survives detach" + ); + 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")); + 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 691d1eada3b..6add5da82b2 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -177,5 +177,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/cli/link-enrollment.ts b/src/cli/link-enrollment.ts index b7c29d21058..09dd12898bd 100644 --- a/src/cli/link-enrollment.ts +++ b/src/cli/link-enrollment.ts @@ -1,13 +1,19 @@ import { createInterface } from "node:readline/promises"; +import { plantUnavailableGuidance } from "../link/child-binary-source"; 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 +22,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 +136,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 +144,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 + ". " + 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); } + 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 +217,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/link/child-binary-source.ts b/src/link/child-binary-source.ts new file mode 100644 index 00000000000..5165bbb7eb6 --- /dev/null +++ b/src/link/child-binary-source.ts @@ -0,0 +1,228 @@ +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 }, + }; +} + +/** 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 = {}, +): 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", + }, + }; + } + } + + // 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); + 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..dba052e010b --- /dev/null +++ b/src/link/remote-plant.ts @@ -0,0 +1,302 @@ +import { CHILD_TARGET_TRIPLES, type ChildTargetTriple } from "./child-binary-source"; +import { MANAGED_PURGE_CHILD_MISSING_MARKER, windowsInspectRuntimeArgv, windowsManagedPurgeDisconnectArgv, windowsPlantChildArgv, windowsPurgePlantedChildArgv } from "./remote-windows"; +import { buildExecArgv } from "./ssh-argv"; +import { sshFailureHint, sshRunnerErrorHint, 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\" | awk 'NR==1{print; exit}')", + " 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); +} + +/** 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", + "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\" | 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", + "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)}')", + "elif command -v shasum >/dev/null 2>&1; then", + " actual_sha=$(shasum -a 256 \"$tmp\" | awk '{print tolower($1)}')", + "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", + "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\"", + "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 recovery_pending; then", + " exit 1", + " else", + " rm -f \"$dest\" 2>/dev/null || true", + " fi", + "elif recovery_pending; then", + " exit 1", + "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"]; +} + +/** + * 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; +} + +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..ff6d84e6ed8 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -152,12 +152,64 @@ $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{$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)){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{$null=$_}};[Console]::Error.WriteLine('Windows ocx-child plant failed.');exit 1} +`); +} + +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";$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 +`); +} + +/** 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/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/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/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..e6397ea4b94 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 { 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"; 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,83 @@ 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", plantUnavailableGuidance(source.reason, confirmed.targetTriple ?? null), 409, source.reason); + 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) { + 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; + } + 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 +318,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 +354,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..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"; @@ -9,6 +10,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 +163,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 +204,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,10 +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 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)); + 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(); @@ -297,8 +315,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/structure/remote-link.md b/structure/remote-link.md index d9043c1aa0c..19ffddc9e5b 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. @@ -188,16 +188,18 @@ 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`, `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. -`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. +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. -`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`). +`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`. 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 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, 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. 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: anyone who can replace the home or a parent directory can still swap the home in that instant. Doing so gives them nothing they lack already, because the same access lets them replace those files directly; the rechecks exist to stop restoration from silently continuing into a directory that was swapped or recreated, not to defend against a writer who controls that directory tree. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (a test barrier between verification and the write while the home is replaced by a directory of sentinel files). Daemon start commit (`native/ocx-child/src/daemon.rs`, `relay.rs`): `attach`, `restart`, `sync` rollover, and rollback's restart of the prior relay all start `serve --foreground --lifecycle-start` through `start_or_rollover_daemon`. It records a fresh random `daemonGeneration` in `state.json` before spawning and passes `ocx-child-start/1 ` on the child's stdin, which stays open. Inside the daemon start gate the child requires `state.daemonGeneration` to equal its generation, and until it reads `commit ` on stdin it answers only `/healthz` (reporting `generationCommitted: false`), returning `503 child_starting` for every other route and posting no parity reports. EOF, a read error, or any other line before the commit makes it exit without serving, so a lifecycle parent that dies after handing over the secret leaves no relay behind and no recovery record is touched. The parent writes the commit line only after the state naming the generation and PID is durable and, for `attach`, after the ownership phase is `attached` and the rollback snapshot is removed, and for rollback's restart of the prior relay, after the rollback snapshot is removed (a removal that fails leaves that relay uncommitted, and it exits); it then waits for `/healthz` to report `generationCommitted: true`. An operator-run `ocx-child serve --foreground` (no `--lifecycle-start`) serves immediately and is refused while an attach rollback is pending. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (parent killed at a barrier before the commit; stale and superseded generations). -Authenticated shutdown is bounded by `stop_owned_daemon`'s 3-second wait. One-shot Home calls carry a whole-request deadline (60 s for lifecycle fetches, 10 s for parity reports) enforced per read on the upstream socket, because per-read timeouts alone never end a trickling response. The relay's parity thread registers a clone of its in-flight socket in a `CancelSlot`; shutdown takes the same mutex, marks the slot cancelled, and shuts that socket down, so the request either sees the cancellation before it registers or is aborted after. A socket can be registered only once connected, so that connect is capped at 1 second. `serve` exits only after the parity thread has ended. When all 32 workers are busy, a new connection gets a short-lived control thread (at most 256) that answers only `/healthz` and the authenticated shutdown, with 1-second read and write timeouts and a 2-second deadline covering the request heads of the whole exchange. A full worker pool therefore cannot keep the shutdown connection from being handled, and a sender that trickles bytes holds a control slot for at most 2 seconds; while 256 control connections are open, a new one is closed unanswered. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (a Home that authenticates and then stalls or trickles the parity response; a relay whose workers are all blocked; control slots held by senders that trickle their request heads). Coverage: `tests/clients/link-ocx-child-conformance.test.ts`, `tests/clients/link-ephemeral-child-discovery.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/link-ocx-child-e2e.test.ts`. +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/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`. Foreground startup requires an attached state, matching recovery journal, verified link key and matching listener port. Sync, restart and detach refuse a journal bound to different canonical homes; detach checks this before changing state or stopping the relay. Shutdown authenticates both ends on one socket without sending the private secret and never signals a process based only on a reported PID. Attach keeps a private rollback snapshot until success or completed recovery; detach can retry an incomplete rollback. Failed restore or cleanup keeps recovery records for retry. On Windows, temporary replacements are created with a protected owner-only DACL before bytes are written. diff --git a/tests/cli/cli-link-enrollment.test.ts b/tests/cli/cli-link-enrollment.test.ts index a8a7f936ed1..5cc2cf21dfd 100644 --- a/tests/cli/cli-link-enrollment.test.ts +++ b/tests/cli/cli-link-enrollment.test.ts @@ -125,4 +125,59 @@ 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(); } + } + }); + + 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(); } + }); }); 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." }); + }); }); 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..6fda3e08303 --- /dev/null +++ b/tests/clients/link-child-plant-foundations.test.ts @@ -0,0 +1,380 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +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, + resolveChildBinarySource, +} 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, +} 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" }); + }); + + 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", () => { + 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(), + windowsManagedPurgeDisconnectArgv(), + ]) { + 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); + + // 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"); + 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); + }); + + // 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); + }); + + // 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"); + 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); + }); + + 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/clients/link-ocx-child-e2e.test.ts b/tests/clients/link-ocx-child-e2e.test.ts index b05f480f90c..c886363bd72 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"; @@ -471,4 +473,197 @@ 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 () => { + 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"); + 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)); + } + // 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, + 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 (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); + } + }, 30_000); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a66036c31b1..4236689fd44 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1630,5 +1630,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/tests/server/link-enrollment-ephemeral-child.test.ts b/tests/server/link-enrollment-ephemeral-child.test.ts index 61101c19e32..33d28690cf0 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" | "managedPurge"; 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,32 @@ 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.includes("ocx-managed-purge") ? "managedPurge" + : 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 +122,8 @@ function buildHarness() { return http200({ models: [{ slug: "home-m1" }] }); } 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"); }, @@ -117,6 +136,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 +163,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 +175,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 +187,198 @@ 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 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 del = await h.request("DELETE", `/api/link/${body.linkId}`); + 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("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); + }); + + 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("refuses enrollment with 409 before issuing a key when a foreign listener occupies childPort", async () => { + 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" }); - 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 +411,4 @@ describe("Home Remote Link enrollment and removal with ephemeral ocx-child", () expect(body.recovery).not.toContain("ocx restart"); }); }); +