From c838ce65b8c012c4c5435b24723ef8afd2b99f0c Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:01:49 +0900 Subject: [PATCH 01/18] docs(devlog): re-verify wp3 plan and fold its audit --- .../261111_link_child_updates/000_plan.md | 6 +- .../030_child_operations.md | 1169 ++++++++++------- 2 files changed, 708 insertions(+), 467 deletions(-) diff --git a/devlog/_plan/261111_link_child_updates/000_plan.md b/devlog/_plan/261111_link_child_updates/000_plan.md index bafccd54154..077d7091b48 100644 --- a/devlog/_plan/261111_link_child_updates/000_plan.md +++ b/devlog/_plan/261111_link_child_updates/000_plan.md @@ -261,13 +261,13 @@ file or symbol; later phases MODIFY it. | file | `child-catalog-freshness.ts`: `createCatalogPullRecorder`, `createCatalogRevisionMonitor`, `catalogVerdict`; the shared monitor is `sharedCatalogRevisionMonitor()` | `src/link/child-catalog-freshness.ts` | 020 (050 adds the shared monitor) | | function | `observeChild(record, deps: ObserveDeps): Promise`; `ObserveOutcome.probe` carries the plan classification and the Child clock | `src/link/child-observe.ts` | 020 | | file | `child-ops-types.ts`: `ChildJob`, `ChildJobItem`, `ChildItemState`, `ChildOpsEnv`, `ItemRunner`, `ItemOutcome` | `src/link/child-ops-types.ts` | 030 (moves the job types out of 020's engine) | -| file | `child-ops-engine.ts`: `createChildOpsEngine`, `sharedChildOpsEngine`; methods `submit`, `job` (`ChildJob \| undefined`), `activeJob`, `whenDone` (050), `settled` | `src/link/child-ops-engine.ts` | 020 (030, 050 extend) | +| file | `child-ops-engine.ts`: `createChildOpsEngine`, `sharedChildOpsEngine`, `resetSharedChildOpsEngineForTests` (030); methods `submit(request, env)` (030; 020 shipped `submit(request)`), `job` (`ChildJob \| undefined`), `activeJob`, `whenDone` (050), `settled` | `src/link/child-ops-engine.ts` | 020 (030, 050 extend) | | file | `child-ops-runners.ts`: `runSyncItem`, `runRestartCodexItem`, `runUpdateItem`, `pinnedSync` | `src/link/child-ops-runners.ts` | 030 | | file | `child-ops-scheduler.ts`: `createChildOpsScheduler`, `createLinkBackoff` (shared by 050 and 070) | `src/link/child-ops-scheduler.ts` | 050 | | file | `child-policy.ts`, `child-ops-host.ts` (`startChildOpsHost`) | `src/link/` | 050 | | file | `child-update-sweep.ts` | `src/link/child-update-sweep.ts` | 070 | -| file | `link-child-ops-routes.ts`: `handleLinkChildOpsRoutes`, `childOpsEngineFor`, `childOpsEnvFor`, `parseOperation`, `attachLinkChildOpsForState` | `src/server/management/link-child-ops-routes.ts` | 020 (030, 050 extend) | -| file | `link-child-ops.ts`: `runLinkCheck` (020), `validateChildJob` (020, tolerant from 030), `runLinkChildOp` (030), `ocx link policy` (050) | `src/cli/link-child-ops.ts` | 020 | +| file | `link-child-ops-routes.ts`: `handleLinkChildOpsRoutes`, `childOpsEngineFor` (`(ctx, state)` in 020, `(state)` from 030), `childOpsEnvFor`, `parseOperation`, `attachLinkChildOpsForState`, `catalogRevisionForRoutes` | `src/server/management/link-child-ops-routes.ts` | 020 (030, 050 extend) | +| file | `link-child-ops.ts`: `runLinkCheck` (020), `validateChildJob` (020, tolerant since 020; 030 keeps `restart.requested`), `linkCheckExitCode` (020; 030 adds partial successes), `runLinkChildOp` (030), `ocx link policy` (050) | `src/cli/link-child-ops.ts` | 020 | | file | `link-status-child.ts`: `validateChildOps` | `src/cli/link-status-child.ts` | 020 | | route | `POST /api/link/children/operations`, `GET /api/link/children/jobs/{jobId}`, `GET/PUT /api/link/children/policy`; the registry, capabilities and docs spell the parameter `{jobId}` | route-registry.ts | 020, 050 | | error code | `invalid_body`, `link_not_found`, `link_not_home_initiated`, `link_operation_in_progress`, `no_eligible_links`, `invalid_job_id`, `job_not_found`, `policy_write_failed` | management routes | 020, 030, 050 | diff --git a/devlog/_plan/261111_link_child_updates/030_child_operations.md b/devlog/_plan/261111_link_child_updates/030_child_operations.md index 53783ec3522..8ca2d5f106f 100644 --- a/devlog/_plan/261111_link_child_updates/030_child_operations.md +++ b/devlog/_plan/261111_link_child_updates/030_child_operations.md @@ -18,29 +18,38 @@ > automatic sync rechecks eligibility when its item starts) and 4529522112 (`pendingUpdateRecovery`). > Also 4529521950: on a non-loopback Home the CLI verbs name the loopback requirement instead of a bare 403 (Section 10). +> **Revision 5 (wp3 P re-verification on dev 87e005f93c, wp1 #8505 and wp2 #8508 merged).** Every before-code snippet, +> anchor and name was rechecked against the merged code; Section 2 now edits what wp2 shipped instead of what 020 +> planned. The engine keeps wp2's single engine-wide queue (three items across all jobs, plus at most one update item), +> busy items still write no `lastOperation`, and `"all"` still means every link (so `no_eligible_links` stays "this +> Home has no links"). The CLI keeps wp2's `validateChildJob`, `linkCheckExitCode`, JSON output and conflict messages, +> and its wait deadline no longer runs while items are queued. `childSkipHint` moves (wp1 left it in src/cli), the +> restarting sync passes `expectSelectedKind`, a Windows gated `update` without `--plan` is refused, and the legacy +> activity limit is documented. Full list: "wp3 P re-verification (base dev 87e005f93c)" at the end. + - **Work-phase:** wp3. **Depends on:** wp1 ([010](010_foundations.md)), wp2 ([020](020_signals_visibility.md)). - **Class:** C4. New mutating management routes, remote update execution over the pinned SSH channel, a new CLI contract (`link.child-ops/1`). Explicit security review is required before merge (`src/AGENTS.md`, `MAINTAINERS.md`; 000 "Work class"). -- **Base:** `dev` (61151e3457 at revision 2) plus the wp1 and wp2 merges. +- **Base:** `dev` 87e005f93c (revision 5), which contains wp1 (#8505) and wp2 (#8508). **Outcome.** The Home can run three operations on any Home-initiated Child from the management API and the CLI: `sync` (the pinned `sync --expect-link` with an optional restart scope), `restart-codex` (the same pinned sync carrying `--restart-app-server-only` or `--restart-codex`, which works on every full Child at or above 2.93.0, D13/F2) and `update` (the Child's own `update.orchestrated/1` command, version-gated inside the remote script, target always the Home's version, followed by a pinned sync, -D12). Every operation is a job in one process-wide engine that serializes work per link, runs at most one -update item and at most three other items at a time across all jobs (engine-wide slots), keeps 32 finished jobs or one -hour, and records each item's summary as `lastOperation` in the observation cache. +D12). Every operation is a job in one process-wide engine that serializes work per link, runs at most three +items at a time across all jobs (wp2's single engine-wide queue) of which at most one is an update item, keeps 32 +finished jobs or one hour, and records each item that ran as `lastOperation` in the observation cache. `DELETE /api/link/{id}` answers `409 link_operation_in_progress` while a job holds the link. Operators get `ocx link update|sync|restart-codex`; agents see contract `link.child-ops/1` in `ocx capabilities`. **IN:** `src/link/child-ops-types.ts`, `src/link/child-ops-engine.ts`, `src/link/child-ops-runners.ts`, `src/server/management/link-child-ops-routes.ts`, one delegating branch and the DELETE 409 in -`link-routes.ts`, a `childOps` seam on `LinkRouteState`, registry entries, `src/cli/link-child-ops.ts`, +`link-routes.ts`, the `childOps` comment on `LinkRouteState`, `src/cli/link-child-ops.ts`, `link.ts` switch arms and usage, three capabilities, the contract string, `registry.ts` help lines, `skills/ocx` hand pages plus `bun run skill:surface`, docs-site guide and CLI reference (8 languages), -`structure/remote-link.md` and its manifest entry. +`structure/remote-link.md` (its manifest entry already owns both non-`src/link` files since wp2). **OUT:** automatic triggers (policy, scheduler, fan-out: [050](050_auto_catalog_fanout.md); automatic updates and re-observe: [070](070_auto_updates.md)); GUI buttons, the GUI `LinkErrorCode` entry and its @@ -58,29 +67,33 @@ the wp3 PR writes. (Item map: 1 → 2.2, 2.4, 2.5, 2.7; 2 → 2.1; 3 and 7 → 2 | Producer | Symbol | Behaviour wp3 relies on | |---|---|---| -| wp1 `src/link/ssh-argv.ts` | `remotePinnedSyncArgv(linkId, platform, opts?: { restart?: "app-server" \| "codex" })`, `remoteGatedOcxArgv(cmd: GatedOcxCommand, platform)` | Gate ≥ 2.93.0 for sync (exit 121 + marker); with `restart` exactly one of `--restart-app-server-only` / `--restart-codex` after `--json` (D2). wp3 adds the `launcher` field (A4 below). | -| wp1 `src/link/child-gated-ocx.ts` | `remoteUpdateApplyArgv`, `classifyUpdateApply(result): UpdateApplyVerdict` | `UpdateApplyVerdict = { state: ApplyState; reason; fromVersion?; toVersion?; suppressTarget; reobserve }`; ssh 255 ⇒ `uncertain/ssh-failed`, exit 11 ⇒ `failed` with `suppressTarget: true` (010 mapping table). wp3 adds `viaLauncher` and the `child-node-unavailable` refusal (A4). | -| wp1 `src/link/child-observations.ts` | `ChildOp`, `ChildOperationSummary`, `ChildObservation`, `updateChildObservations`, `patchChildObservation(file, linkId, patch, now: Date)` | Tolerant cache (D4); a write prunes ids absent from `links.json`. | -| wp1 `src/link/sync-fanout-classify.ts` | `classifySyncResult` | 010 moved it; `childSkipHint` was not moved, so wp3 moves it (Section 1, conditional rows). | -| wp2 `src/link/child-observe.ts` | `observeChild(record, deps: ObserveDeps): Promise` | `ObserveOutcome = { state, reason, observation, probe }`; `probe = { classification: PlanClassification; childNowMs; startedAt; platform } \| null` (020). | -| wp2 `src/link/child-activity.ts` | `LinkActivitySnapshot`, `isLinkIdle(snapshot, now, idleAfterMs)`, `DEFAULT_IDLE_AFTER_MS` | Listener `linkActivity(linkId, now)`; absent ⇒ never idle. | -| wp2 engine, routes, CLI | `createChildOpsEngine` (`submit`, `job`, `activeJob`), `handleLinkChildOpsRoutes`, `attachLinkChildOpsForState`, `runLinkCheck`, `validateChildJob` | wp3 MODIFIES these files (2.2, 2.4, 2.8); `LinkRouteState.childOps` and both registry entries exist already (2.5, 2.7). | +| wp1 `src/link/ssh-argv.ts` | `remotePinnedSyncArgv(linkId, platform = "posix", opts: PinnedSyncOptions = {})` (:440), `remoteGatedOcxArgv(cmd: GatedOcxCommand, platform = "posix")` (:421), `GatedOcxCommand` (:328-341: `minVersion`, `lacksMarker`, `acceptEphemeral`, `reportEntry`, `reportClock`, `reportSelectedKind?`, `args`), `assertGated` (:348-353) | Gate ≥ 2.93.0 for sync (exit 121 + marker); with `restart` exactly one of `--restart-app-server-only` / `--restart-codex` after `--json` and `reportSelectedKind: true` (D2); a Windows restart throws `unsupported:windows-restart` (:443). The Windows branch refuses `update` only when it carries `--yes` (:424-425); wp3 tightens it (A4). wp3 adds the `launcher` field (A4 below). | +| wp1 `src/link/child-gated-ocx.ts` | `SshResult` (:6), `remoteUpdatePlanArgv(homeVersion, platform)` (:26), `remoteUpdateApplyArgv({ homeVersion, planToken, notAfter }, platform)` (:37), `readGatedFraming` (:55), `classifyUpdatePlanProbe` (:88), `classifyUpdateApply(result): UpdateApplyVerdict` (:105) | `UpdateApplyVerdict = { state: ApplyState; reason; fromVersion?; toVersion?; suppressTarget; reobserve }` (child-update-contract.ts:32); ssh 255 ⇒ `uncertain/ssh-failed`, exit 11 ⇒ `failed` with `suppressTarget: true`. A token starting with `-` drops `--expect-plan` (:44). wp3 adds `viaLauncher` and the `child-node-unavailable` refusal (A4). | +| wp1 `src/link/child-observations.ts` | `ChildOp` (:20), `ChildOperationSummary` (:22), `ChildObservation` (:26), `withoutCarriedFields` (:123), `readChildObservations(path = childObservationsPath())` (:146), `updateChildObservations` (:167), `patchChildObservation(file, linkId, patch, now: Date)` (:195) | Tolerant cache (D4); a write prunes ids absent from `links.json`; `CARRIED_OBSERVATION_FIELDS` (:46) survive an observe. | +| wp1 `src/link/sync-fanout-classify.ts` | `classifySyncResult(result, options: { expectSelectedKind?: boolean } = {})` (:51), `ChildVerdict` (:14, carries `executable?: "ocx-child"`) | The `selected-kind` marker is read only with `expectSelectedKind: true`, so the restarting sync must pass it (2.3). `childSkipHint` was NOT moved: it is a private function at src/cli/sync-fanout.ts:13-23, so wp3 moves it (Section 1). | +| wp2 `src/link/child-observe.ts` | `observeChild(record, deps: ObserveDeps): Promise` (:63), `ObserveDeps` (:15-19: `runner`, `knownHostsFile`, `homeVersion`, `peer`, `platformOf`, `inspect`, `now: () => Date`) | `ObserveOutcome = { state: "succeeded" \| "owner-action" \| "unsupported" \| "offline" \| "failed"; reason; observation; probe }`; `probe = { classification; childNowMs; startedAt; platform } \| null` (:25). A client-initiated record returns `unsupported/child-initiated` with a peer-only observation and no SSH (:64-68); an `ocx-child` returns `succeeded` with `probe: null`. | +| wp2 `src/link/child-activity.ts` | `LinkActivitySnapshot` (:6), `isLinkIdle(snapshot, now, idleAfterMs)` (:22), `DEFAULT_IDLE_AFTER_MS` (:4) | Listener `linkActivity(linkId, now)` (link-route-state.ts:39); absent ⇒ never idle. A legacy key admission counts only once its response status is known and below 400 (src/server/index/link-relay-sessions.ts:136-153); see Section 10. | +| wp2 `src/link/child-ops-engine.ts` | `createChildOpsEngine(deps: { readStore; observe; writeObservation; now?: () => Date; jobId?; concurrency? })` (:43), `submit({ op: "observe", linkIds, trigger })`, `job`, `activeJob`, `settled`; constants `CHILD_JOB_SCHEMA`, `CHILD_OPS_CONCURRENCY` (3), `CHILD_JOB_RETENTION_MS`, `CHILD_JOB_RETENTION_COUNT` (:28-31); job types declared inline (:8-17) | One engine-wide `pending` queue pumped while `active < concurrency` (:58-60, :108-119); a held link makes a single-link submit 409 and a multi-link item `busy` with `ran = false`, which writes no `lastOperation` (:75-84); `"all"` is every link in the store and `no_eligible_links` is returned only when the store is empty (:126-128); retention is applied on submit and on `job()` (:66-72). wp3 rewrites this file (2.2). | +| wp2 `src/server/management/link-child-ops-routes.ts` | `catalogRevisionForRoutes` (:27), `parseOperation` (:41), `childOpsEngineFor(ctx, state)` (:51-66, `state.childOps ??= createChildOpsEngine(...)`), `attachLinkChildOpsForState` (:69-86, flushes deferred catalog pulls first), `handleLinkChildOpsRoutes` (:88-116: `auth(ctx, "either")` first, `409 runtime_role_client` on a Child, `503 link_unavailable` when the store read throws) | wp3 rewrites the engine accessor, `parseOperation` and the POST branch, and adds `childOpsEnvFor` (2.4). | +| wp2 `src/cli/link-child-ops.ts` | `validateChildJob` (:33, already tolerant: unknown keys dropped, enum fields kept as any string ≤ 256), `linkCheckExitCode` (:73: 0 success, 6 `busy`/`offline`/`refused`/`owner-action`/`unsupported`, 1 anything else including an unknown state), `runLinkCheck` (:90), `OLD_HOME_MESSAGE`, `NO_ELIGIBLE_LINKS_MESSAGE`, `RUNTIME_ROLE_CLIENT_MESSAGE` (:14-16), deadline `ceil(items/3) × 120 s + 30 s` from submission (:125) | The validator keeps only `sync.state` / `restart.outcome` plus `reason` (:51-58), so it drops `restart.requested`; wp3 extends it (2.8). | +| wp2 `src/lib/catalog-etag.ts` | `catalogEtag(body)` | Pure; `createCatalogRevisionMonitor` hashes with it, so `current()?.etag` is what `/v1/catalog` serves. | ## A4 — Remote apply of npm and pnpm Children through the package launcher **Problem (from 010, verified here).** The gated script prefers the served pair recorded in `serving-runtimes.json`, -`[bun, /src/cli/index.ts]` for a package install (src/link/ssh-argv.ts:203-221). The Node launcher is where -npm/pnpm orchestrated apply lives: `bin/ocx.mjs:1362-1390` runs `applyNodeOrchestratedUpdate` only for +`[bun, /src/cli/index.ts]` for a package install (`REMOTE_SERVED_RUNTIME` and `remoteServedRuntimeAttempt`, +src/link/ssh-argv.ts:152-200; the pair is what `currentServingCommand` records, src/config/serving-runtimes.ts:55-58). +The Node launcher is where npm/pnpm orchestrated apply lives: `bin/ocx.mjs:1362-1393` runs `applyNodeOrchestratedUpdate` only for `mode === "apply"` on an npm/pnpm `node_modules` install. The Bun entry never gets there: for a plan that `canApply` -with `installer` npm or pnpm it falls through to `src/update/orchestrated-entry.ts:225-233` and prints a result with +with `installer` npm or pnpm it falls through to `src/update/orchestrated-entry.ts:224-233` and prints a result with `error: "plan-unavailable"`, exit 10. So without a change every npm/pnpm Child that has a census entry ends -`launcher-bypassed` (010's interim row), which contradicts A4. +`launcher-bypassed` (010's interim row, child-update-contract.ts:129), which contradicts A4. **Decision (revision 2, audit H2).** The launcher route is used **only for the apply step** and **only when the parsed plan says `installer` is `npm` or `pnpm`**. The Home decides from the plan it parsed, not the remote script from a path -pattern: a Bun global install's census root (`~/.bun/install/global/node_modules/@bitkyc08/opencodex`, -src/config/serving-runtimes.ts:50-58) also contains `/node_modules/` and `bin/ocx.mjs`, and sending it through Node -would run the launcher's ordinary bootstrap (`bin/ocx.mjs:1362` excludes Bun global installs from the npm apply). +pattern: a Bun global install's census root (`~/.bun/install/global/node_modules/@bitkyc08/opencodex`) also contains +`/node_modules/` and `bin/ocx.mjs`, and sending it through Node would run the launcher's ordinary bootstrap (the npm +apply at `bin/ocx.mjs:1362-1363` excludes it through `isBunGlobalInstall`, :109-111). - **Plan:** always the served entry, as today. The script only *reports* whether a launcher Node exists (`ocx-fanout:launcher:available` / `unavailable`) and never exits because Node is missing. The served-entry plan is also what the launcher's own apply recomputes before it mutates (its `plan` callback runs `bun --no-env-file @@ -90,6 +103,8 @@ would run the launcher's ordinary bootstrap (`bin/ocx.mjs:1362` excludes Bun glo `"bin": {"ocx": "./bin/ocx.mjs"}`). The launcher's package-manager self-update path (bin/ocx.mjs:1359) requires `OCX_UPDATE_ORCHESTRATED_CHILD=1`; the script `unset`s it before `exec`, so the remote apply can only take the orchestrated branch (or refuse). No `src/update/*` file changes. + (Revision 5: the self-update branch is `bin/ocx.mjs:1359-1361`; `childArgs` is parsed only when the variable is + `"1"`, :1351-1352.) - **Apply, installer bun (or any other):** the served entry, unchanged. **Node resolution, in order** (the npm shim relies on `env node`, which a non-interactive SSH shell often lacks, for @@ -102,14 +117,23 @@ plan: `launcher:unavailable` and the plan still runs; apply: `ocx-fanout:child-n `launcher: "none" | "report" | "apply"` (`remotePinnedSyncArgv` passes `"none"`), and `assertGated` gains the audit r2 R5 check in the same diff (Revision 4: it cannot land in wp1, which has no such field): `if (cmd.launcher !== "none" && cmd.launcher !== "report" && cmd.launcher !== "apply") throw new LinkSshArgumentError("invalid gated ocx launcher");` - The final lines of the selected-ocx branch become: + Revision 5 placement against the merged script (`remoteGatedScript`, ssh-argv.ts:360-419): `launcher=` and the + `find_launcher_node` definition go at the top of the script, after `umask 077` (:375) and before `try_ocx()` + (:379), so the function is defined once; the two lines that end `try_ocx` (:414-415, `printf ... stdout-begin` and + `exec "$c" ${ocx_entry:+"$ocx_entry"} "$@"`) are replaced by the block below. Inside `try_ocx`, `"$@"` is already + the command's arguments (`c=$1; shift`, :380), and the `rm -rf "$dir"; trap - ...` cleanup (:412) still runs first. + Top of script: ```sh + launcher= find_launcher_node() { case "$1" in */lib/node_modules/@bitkyc08/opencodex) n=${1%/lib/node_modules/@bitkyc08/opencodex}/bin/node if [ -x "$n" ] && "$n" --version >/dev/null 2>&1; then printf '%s\n' "$n"; return 0; fi;; esac n=$(command -v node 2>/dev/null) && [ -x "$n" ] && "$n" --version >/dev/null 2>&1 && printf '%s\n' "$n" } + ``` + End of `try_ocx`: + ```sh root=; [ -n "$ocx_entry" ] && root=${ocx_entry%/src/cli/index.ts} case "$root" in */node_modules/*) [ -f "$root/bin/ocx.mjs" ] || root=;; *) root=;; esac if [ -n "$root" ] && [ "$launcher" = report ]; then @@ -130,11 +154,25 @@ plan: `launcher:unavailable` and the plan still runs; apply: `ocx-fanout:child-n `launcher=` is a constant spliced from the closed union (like the version literals); no Home-supplied value enters the script except `"$@"`, as today. A `launcher=apply` run without a census pair (the `ocx` shim was selected) falls through to `exec "$c"`, which already is the launcher under the shim's Node. + The template literal escapes `$` and backslashes as the existing script does (`\${...}`, `\\n`). +- `src/link/ssh-argv.ts` `remoteGatedOcxArgv` Windows branch (Revision 5, carried item d), before (:424-425): + `if (cmd.args[0] === "update" && cmd.args.includes("--yes")) throw new LinkSshArgumentError("unsupported:windows-apply");` + After: any `update` that is not a plan is refused, so an apply spelled without `--yes` (or a future mutating flag) + can never be built for Windows: + `if (cmd.args[0] === "update" && (!cmd.args.includes("--plan") || cmd.args.includes("--yes"))) throw new LinkSshArgumentError("unsupported:windows-apply");` + Cases in `tests/clients/link-gated-ocx-argv.test.ts` (MODIFY): Windows `["update", "--json", "--version", "2.100.0"]` + and `["update", "--plan", "--yes", "--json"]` throw `unsupported:windows-apply`; `remoteUpdatePlanArgv(v, "windows")` + still builds; POSIX `update` without `--plan` still builds. - `src/link/child-gated-ocx.ts`: `remoteUpdatePlanArgv` passes `launcher: "report"`. `remoteUpdateApplyArgv`'s input gains `viaLauncher: boolean` and passes `launcher: viaLauncher ? "apply" : "none"`. Windows builders ignore the field (apply is refused there, D20; a Windows plan reports nothing). `readGatedFraming` learns at most one `launcher:(available|unavailable|exec)` line (two ⇒ `invalid`) and returns it as `launcher`; the 121 marker set gains `child-node-unavailable`, and `classifyUpdateApply` maps that refusal to `owner-action`/`launcher-node-missing`. + Revision 5 anchors: `UPDATE_GATE` (:17) gains `launcher: "none"`; `GatedFraming`'s `ran` arm (:11) gains + `launcher: "available" | "unavailable" | "exec" | null`; `GatedRefusal` (:7) and the `REFUSAL` pattern (:47) gain + `child-node-unavailable`; in `classifyUpdateApply` (:110-112) the refused arm checks it before the `legacy-ocx` + fallback; `classifyUpdatePlanProbe` (:100-102) passes `launcher: framing.launcher === "exec" ? null : framing.launcher` + into the classifier ctx (a plan never execs the launcher). - `src/link/child-update-contract.ts`: `classifyChildUpdatePlan`'s ctx gains `launcher: "available" | "unavailable" | null`; the `launcher-bypassed` row becomes three rows for `canApply`, `installer ∈ {npm, pnpm}`, `entry: "pair"`: `launcher: "available"` ⇒ `orchestrated`; `"unavailable"` ⇒ `owner-action`/`launcher-node-missing` with the hint @@ -185,31 +223,34 @@ global Child never touches Node. | Path | Kind | Purpose | Current anchor | |---|---|---|---| | src/link/child-ops-types.ts | NEW | Job, item, env and runner types; closed op and state vocabularies | — | -| src/link/child-ops-engine.ts | MODIFY (020 file) | Job registry, per-link lock, bulk queue, retention, `lastOperation` write, shared instance | 020 | +| src/link/child-ops-engine.ts | MODIFY (020 file, rewritten) | Job registry, per-link lock, wp2's engine-wide queue plus one update slot, retention, `lastOperation` write, shared instance and its test reset | whole file (162 lines) | | src/link/child-ops-runners.ts | NEW | Item runners for `observe` (020's dispatch moved here), `sync`, `restart-codex`, `update` (D12, D13) | — | -| src/link/ssh-argv.ts, src/link/child-gated-ocx.ts, src/link/child-update-contract.ts | MODIFY (010 files) | A4 launcher field, `viaLauncher`, framing and classifier rows | 010 | -| src/link/sync-fanout-classify.ts | MODIFY (only if wp1 left `childSkipHint` in src/cli) | Export `childSkipHint` | wp1 file | -| src/cli/sync-fanout.ts | MODIFY (same condition) | Re-export `childSkipHint` | :88-97 | -| src/server/management/link-child-ops-routes.ts | MODIFY (020 file) | All ops on `POST /api/link/children/operations`, env builder | 020 | -| src/server/management/link-routes.ts | MODIFY | Delegating branch; DELETE 409 | :201-205, :285-286 | -| src/cli/link-child-ops.ts | MODIFY (020 file) | `ocx link update|sync|restart-codex`, job polling, exit mapping, tolerant job validator | 020 | -| src/cli/link.ts | MODIFY | Usage lines, switch arms, exit passthrough | :36-47, :385-398 | -| src/cli/capabilities-access-remote.ts | MODIFY | Three capabilities | :167-168 | +| src/link/ssh-argv.ts, src/link/child-gated-ocx.ts, src/link/child-update-contract.ts | MODIFY (010 files) | A4 launcher field, `viaLauncher`, framing and classifier rows; Windows `update` without `--plan` refused | ssh-argv.ts:328-353, :360-419, :424-425; child-gated-ocx.ts:7-19, :37-52, :100-112; child-update-contract.ts:118, :129 | +| src/link/sync-fanout-classify.ts | MODIFY | Export `childSkipHint(reason, alias): string`, moved byte for byte | wp1 left it private in src/cli/sync-fanout.ts:13-23 | +| src/cli/sync-fanout.ts | MODIFY | Delete the private copy; import it from `../link/sync-fanout-classify` for :70 | :13-23, :70 | +| src/server/management/link-child-ops-routes.ts | MODIFY (020 file) | All ops on `POST /api/link/children/operations`, env builder, shared engine accessor | :41-66, :84, :88-116 | +| src/server/management/link-route-state.ts | MODIFY (comment only) | `childOps` becomes the test seam for the shared engine | :53 | +| src/server/management/link-routes.ts | MODIFY | DELETE 409 (the delegating branch exists since wp2) | :201-205; branch :352-356 | +| src/cli/link-child-ops.ts | MODIFY (020 file) | `ocx link update|sync|restart-codex`, shared wait loop with the queued-aware deadline, partial reasons in the exit mapping, `restart.requested` in the validator, 403 and no-links messages | :14-21, :33-70, :73-82, :90-136 | +| src/cli/link.ts | MODIFY | Usage lines, switch arms, exit passthrough | :38-51, :393-410 | +| src/cli/capabilities-access-remote.ts | MODIFY | Three capabilities; the `link check` detail's no-links wording | :168-177 (check entry), before `];` at :178 | | src/cli/capability-contracts.ts | MODIFY | `link.child-ops/1` | :2 | -| src/cli/registry.ts | MODIFY | Link help lines | :154-163 | +| src/cli/registry.ts | MODIFY | Link help lines | :153-166 | | skills/ocx/references/01_surface_access-remote.md, 01_management_surface.md | REGENERATE | `bun run skill:surface` | generated | -| skills/ocx/references/05_remote_hub.md, 04_failure_semantics.md, 02_json_shapes.md | MODIFY | Hand pages | :216, :136, :154 | -| docs-site/src/content/docs/guides/remote-link.md + fr, ja, ko, ru, tr, zh-cn, zh-tw | MODIFY | "Operating Children from the Home" section and CLI reference block | :137-148 | -| docs-site/src/content/docs/reference/cli/lifecycle.md + the same 7 locales | MODIFY | `ocx link update|sync|restart-codex` section | before :1398 | -| structure/remote-link.md, structure/manifest.json, structure/INDEX.md | MODIFY / REGENERATE | Ownership of the two new non-`src/link` files; behaviour paragraph | :67-71; manifest :1343 | +| skills/ocx/references/05_remote_hub.md, 04_failure_semantics.md, 02_json_shapes.md | MODIFY | Hand pages | 05: "Checking Children from the Home" :330-345; 04: exit table row 6 :17, check paragraph :32, "A pinned sync that refuses" :143; 02: "Remote Link Child block and job document" :401-419 | +| docs-site/src/content/docs/guides/remote-link.md + fr, ja, ko, ru, tr, zh-cn, zh-tw | MODIFY | "Operating Children from the Home" section and CLI reference block | CLI reference :139-152 (check line :149); "Home and Child versions" :165-193 | +| docs-site/src/content/docs/reference/cli/lifecycle.md + the same 7 locales | MODIFY | `ocx link update|sync|restart-codex` section | after the check paragraph (:1369), before "Remote Link Child attestations" (:1377) | +| structure/remote-link.md, structure/INDEX.md | MODIFY / REGENERATE | Rewrite wp2's job paragraph for all four ops (manifest :1356 already lists both files, INDEX.md :261, :463) | :73 | | tests/clients/link-child-ops-engine.test.ts, tests/clients/link-child-ops-runners.test.ts, tests/clients/link-child-update-launcher.test.ts, tests/cli/cli-link-child-ops.test.ts | NEW | Section 5 | — | | tests/server/link-child-ops-routes.test.ts, tests/cli/cli-link-check.test.ts, tests/clients/link-child-update-contract.test.ts, tests/clients/link-gated-ocx-argv.test.ts | MODIFY (020 / 010 files) | Section 5 | — | -| scripts/test-layout/layout-clients.json, tests/fixtures/test-layout-expected.json | MODIFY | Register the four NEW tests | before each closing `}` | +| scripts/test-layout/layout-clients.json, tests/fixtures/test-layout-expected.json | MODIFY | Register the four NEW tests | after the last entries (layout-clients.json:191, test-layout-expected.json:1662), before each closing `}` | -Registration follows 000 "Test registration rule": `scripts/test-layout/layout.json` is 1998 lines on 61151e3457 -and the ratchet fails at 2000, so no entry goes there; every new test goes into the `layout-clients.json` shard, -which already maps `cli` and `server` tests (layout-clients.json:14-15, :175) as well as `clients` ones. +Registration follows 000 "Test registration rule": `scripts/test-layout/layout.json` is 1999 lines on 87e005f93c +(one line from the ratchet), so no entry goes there; every new test goes into the `layout-clients.json` shard (192 +lines), which already maps `cli` and `server` tests (layout-clients.json:14-15, :190-191) as well as `clients` ones. +The last entry gains a comma; the new lines follow it. `link-child-ops-routes.test.ts` is 020's file and is not registered again. +None of the files this phase touches is in `tests/fixtures/file-size-baseline.json` (checked on 87e005f93c). ## 2. Changes @@ -217,6 +258,10 @@ which already maps `cli` and `server` tests (layout-clients.json:14-15, :175) as 020 declared the job types inline in its engine; wp3 moves them here and the engine imports them. `ChildOp` is declared once, in 010's `child-observations.ts` (000 "Canonical names"). +Revision 5: wp2's inline declarations are child-ops-engine.ts:8-17 and `CHILD_JOB_SCHEMA` at :28; wp2's +`ChildJobItem` has no `sync` or `restart` and its `ChildJob.restart` is always `"none"`. The only importer of those +types outside the engine is src/cli/link-child-ops.ts:1, which switches its import to `../link/child-ops-types` +(2.8). The engine re-exports `CHILD_JOB_SCHEMA` so its existing import sites keep compiling. ```ts import type { LinkRecord, LinkStore } from "./store"; @@ -295,17 +340,32 @@ export type ItemRunner = (record: LinkRecord, job: Readonly; } export interface ChildOpsEngineOptions { runners?: Partial>; - newJobId?: () => string; - retainJobs?: number; // default 32 - retainMs?: number; // default 3_600_000 - bulkConcurrency?: number; // default 3; update is always 1 + jobId?: () => string; + /** Default and maximum CHILD_OPS_CONCURRENCY. */ + concurrency?: number; } -export const CHILD_JOB_ID = /^ljob_[0-9a-f]{16}$/; -const DEFAULT_RUNNERS: Record = { - observe: runObserveItem, sync: runSyncItem, "restart-codex": runRestartCodexItem, update: runUpdateItem, -}; -const DONE = new Set(["succeeded", "up-to-date", "busy", "offline", "refused", "owner-action", "unsupported", "failed", "uncertain"]); -const iso = (ms: number): string => new Date(ms).toISOString(); - -/** Revision 4 (CodeRabbit 4529521982): a counting semaphore. The engine owns one per bound, shared by every job. */ -function createSlots(size: number): { take(): Promise; give(): void } { - let free = size; - const waiters: Array<() => void> = []; - return { - async take() { if (free > 0) { free -= 1; return; } await new Promise(resolve => waiters.push(resolve)); }, - give() { const next = waiters.shift(); if (next) next(); else free += 1; }, - }; -} +const DONE: ReadonlySet = new Set(["succeeded", "up-to-date", "busy", "offline", "refused", "owner-action", "unsupported", "failed", "uncertain"]); +const errorName = (error: unknown): string => error instanceof Error ? error.name : "unknown"; +const iso = (env: ChildOpsEnv): string => new Date(env.now()).toISOString(); /** 010's persisted slice; called after finishedAt is set. */ export function summarize(job: ChildJob, item: ChildJobItem): ChildOperationSummary { @@ -352,124 +399,153 @@ export function summarize(job: ChildJob, item: ChildJobItem): ChildOperationSumm ...(item.fromVersion ? { fromVersion: item.fromVersion } : {}), ...(item.toVersion ? { toVersion: item.toVersion } : {}) }; } +/** + * The D11 job model: one in-memory registry, one lock per link per Home process, at most three items running across + * every job and at most one of them an update, finished jobs kept one hour and at most 32. A Home restart loses the + * jobs only; the observation cache keeps each Child's last result. Items carry closed tokens, never remote text. + */ export function createChildOpsEngine(options: ChildOpsEngineOptions = {}): ChildOpsEngine { - const runners = { ...DEFAULT_RUNNERS, ...options.runners }; - const newJobId = options.newJobId ?? (() => `ljob_${randomBytes(8).toString("hex")}`); - const retainJobs = options.retainJobs ?? 32; - const retainMs = options.retainMs ?? 3_600_000; - const width = Math.max(1, Math.min(options.bulkConcurrency ?? 3, 3)); - // Process-wide bounds: at most one update item and at most `width` other items run at once, across all jobs. - const updateSlots = createSlots(1); - const bulkSlots = createSlots(width); - const jobs = new Map(); - const holds = new Map(); // linkId -> jobId - const running = new Set>(); - - const prune = (now: number): void => { - const finished = [...jobs.values()].filter(job => job.state === "done"); - for (const job of finished) if (now - Date.parse(job.finishedAt!) > retainMs) jobs.delete(job.jobId); - const rest = [...jobs.values()].filter(job => job.state === "done"); - for (const job of rest.slice(0, Math.max(0, rest.length - retainJobs))) jobs.delete(job.jobId); + const runners: Record = { observe: runObserveItem, sync: runSyncItem, "restart-codex": runRestartCodexItem, + update: runUpdateItem, ...options.runners }; + const newJobId = options.jobId ?? (() => `ljob_${randomBytes(8).toString("hex")}`); + const concurrency = Math.max(1, Math.min(options.concurrency ?? CHILD_OPS_CONCURRENCY, CHILD_OPS_CONCURRENCY)); + const jobs = new Map(); + const locks = new Map(); // linkId -> jobId + const idle = new Set<() => void>(); + // One engine-wide FIFO: the bound is on SSH sessions from this Home, not per job. + const pending: Array<{ job: ChildJob; item: ChildJobItem; env: ChildOpsEnv }> = []; + let active = 0; + let activeUpdates = 0; + + const write = (env: ChildOpsEnv, linkId: string, patch: Partial): void => { + try { env.writeObservation(linkId, patch); } catch (error) { + // The file is a cache and the job document already carries the result. + console.warn(`[link] child observation write failed linkId=${linkId} error=${errorName(error)}`); + } }; - - const finishItem = (job: ChildJob, item: ChildJobItem, env: ChildOpsEnv): void => { - item.finishedAt = iso(env.now()); - if (holds.get(item.linkId) === job.jobId) holds.delete(item.linkId); - // lastOperation survives a Home restart (D4); a cache write failure never fails the item. - try { env.writeObservation(item.linkId, { lastOperation: summarize(job, item) }); } catch { /* cache only */ } + const running = (): boolean => [...jobs.values()].some(({ job }) => job.state !== "done"); + const prune = (): void => { + const finished = [...jobs.values()].filter(({ job }) => job.state === "done"); + for (const { job, env } of finished) if (Date.parse(job.finishedAt!) < env.now() - CHILD_JOB_RETENTION_MS) jobs.delete(job.jobId); + const kept = finished.filter(({ job }) => jobs.has(job.jobId)).sort((a, b) => Date.parse(a.job.finishedAt!) - Date.parse(b.job.finishedAt!)); + for (const { job } of kept.slice(0, Math.max(0, kept.length - CHILD_JOB_RETENTION_COUNT))) jobs.delete(job.jobId); }; - - const execute = async (job: ChildJob, env: ChildOpsEnv): Promise => { - const queue = job.items.filter(item => item.state === "queued"); - if (queue.length > 0) job.state = "running"; - let cursor = 0; - const lanes = job.op === "update" ? 1 : Math.min(width, queue.length); - const slots = job.op === "update" ? updateSlots : bulkSlots; - await Promise.all(Array.from({ length: lanes }, async () => { - for (let item = queue[cursor++]; item; item = queue[cursor++]) { - await slots.take(); // the item stays "queued" while another job holds the slot - try { - item.state = "running"; - item.startedAt = iso(env.now()); - let record: LinkRecord | undefined; - try { record = env.readStore().links.find(link => link.id === item.linkId); } catch { record = undefined; } - if (!record) Object.assign(item, { state: "refused", reason: "link-removed" }); - else { - try { Object.assign(item, await runners[job.op](record, job, env)); } - catch { Object.assign(item, { state: "failed", reason: "internal-error" }); } - if (!DONE.has(item.state)) Object.assign(item, { state: "failed", reason: "internal-error" }); - } - finishItem(job, item, env); - } finally { slots.give(); } - } - })); + /** ran = false: the item never touched its link (busy, child-initiated), so it writes no lastOperation. */ + const finishItem = (job: ChildJob, item: ChildJobItem, env: ChildOpsEnv, outcome: ItemOutcome, ran = true): void => { + Object.assign(item, outcome); + item.finishedAt = iso(env); + if (!ran) return; + if (locks.get(item.linkId) === job.jobId) locks.delete(item.linkId); + write(env, item.linkId, { lastOperation: summarize(job, item) }); + }; + const finishJob = (job: ChildJob, env: ChildOpsEnv): void => { + if (job.state === "done" || job.items.some(item => item.state === "queued" || item.state === "running")) return; job.state = "done"; - job.finishedAt = iso(env.now()); + job.finishedAt = iso(env); + if (!running()) { for (const resolve of idle) resolve(); idle.clear(); } + }; + const runItem = async (job: ChildJob, item: ChildJobItem, env: ChildOpsEnv): Promise => { + item.state = "running"; + item.startedAt = iso(env); + if (job.state === "queued") job.state = "running"; + let record: LinkRecord | undefined; + try { record = env.readStore().links.find(link => link.id === item.linkId); } catch { record = undefined; } + if (!record) { finishItem(job, item, env, { state: "refused", reason: "link-removed" }); return; } + let outcome: ItemOutcome; + try { outcome = await runners[job.op](record, job, env); } catch (error) { + console.warn(`[link] child ${job.op} failed linkId=${item.linkId} error=${errorName(error)}`); + outcome = { state: "failed", reason: "internal-error" }; + } + finishItem(job, item, env, DONE.has(outcome.state) ? outcome : { state: "failed", reason: "internal-error" }); + }; + const pump = (): void => { + // FIFO across jobs; an update waiting for the update place does not hold up the items behind it. + for (let index = 0; index < pending.length && active < concurrency;) { + const entry = pending[index]!; + const update = entry.job.op === "update"; + if (update && activeUpdates >= CHILD_OPS_UPDATE_CONCURRENCY) { index += 1; continue; } + pending.splice(index, 1); + active += 1; + if (update) activeUpdates += 1; + void runItem(entry.job, entry.item, entry.env).finally(() => { + active -= 1; + if (update) activeUpdates -= 1; + finishJob(entry.job, entry.env); + pump(); + }); + } }; return { submit(request, env) { - const now = env.now(); - prune(now); + prune(); const store = env.readStore(); - let targets: LinkRecord[]; - if (request.linkIds === "all") targets = store.links.filter(link => link.direction === "hub-initiated"); - else { - targets = []; - for (const id of request.linkIds) { - const record = store.links.find(link => link.id === id); - if (!record) return { ok: false, code: "link_not_found", linkId: id }; - targets.push(record); + let records: LinkRecord[]; + if (request.linkIds === "all") { + records = store.links; + if (records.length === 0) return { ok: false, code: "no_eligible_links", linkId: null }; + } else { + records = []; + for (const linkId of request.linkIds) { + const record = store.links.find(link => link.id === linkId); + if (!record) return { ok: false, code: "link_not_found", linkId }; + records.push(record); } - const only = targets.length === 1 ? targets[0]! : null; - if (only?.direction === "client-initiated") return { ok: false, code: "link_not_home_initiated", linkId: only.id }; - if (only && holds.has(only.id)) return { ok: false, code: "link_operation_in_progress", linkId: only.id }; + const only = records.length === 1 ? records[0]! : null; + // observe answers a client-initiated link itself without SSH (020); the other ops need the Home's SSH trust. + if (only && request.op !== "observe" && only.direction === "client-initiated") return { ok: false, code: "link_not_home_initiated", linkId: only.id }; + if (only && locks.has(only.id)) return { ok: false, code: "link_operation_in_progress", linkId: only.id }; } - if (targets.length === 0) return { ok: false, code: "no_eligible_links", linkId: null }; const job: ChildJob = { schema: CHILD_JOB_SCHEMA, jobId: newJobId(), op: request.op, trigger: request.trigger, - restart: request.restart, state: "queued", createdAt: iso(now), finishedAt: null, - items: targets.map(record => { - const held = holds.has(record.id); - const remote = record.direction === "hub-initiated"; - return { linkId: record.id, alias: record.alias, - state: held ? "busy" : remote ? "queued" : "unsupported", - reason: held ? "link-operation-in-progress" : remote ? null : "child-initiated", - startedAt: null, finishedAt: held || !remote ? iso(now) : null }; - }) }; - for (const item of job.items) if (item.state === "queued") holds.set(item.linkId, job.jobId); - jobs.set(job.jobId, job); - if (!job.items.some(item => item.state === "queued")) { job.state = "done"; job.finishedAt = iso(now); } - else { - const run = execute(job, env).finally(() => { running.delete(run); }); - running.add(run); - } + restart: request.restart, state: "queued", createdAt: iso(env), finishedAt: null, + items: records.map(record => ({ linkId: record.id, alias: record.alias, state: "queued" as const, reason: null, + startedAt: null, finishedAt: null })) }; + jobs.set(job.jobId, { job, env }); + job.items.forEach((item, index) => { + if (locks.has(item.linkId)) finishItem(job, item, env, { state: "busy", reason: "link-operation-in-progress" }, false); + else if (request.op !== "observe" && records[index]!.direction === "client-initiated") { + finishItem(job, item, env, { state: "unsupported", reason: "child-initiated" }, false); + } else { locks.set(item.linkId, job.jobId); pending.push({ job, item, env }); } + }); + pump(); + finishJob(job, env); return { ok: true, job: structuredClone(job) }; }, job(jobId) { - const job = jobs.get(jobId); - return job ? structuredClone(job) : undefined; + prune(); + const entry = jobs.get(jobId); + return entry ? structuredClone(entry.job) : undefined; }, activeJob(linkId) { - const jobId = holds.get(linkId); - const job = jobId ? jobs.get(jobId) : undefined; - const item = job?.items.find(entry => entry.linkId === linkId); - return job && item && (item.state === "queued" || item.state === "running") - ? { jobId: job.jobId, op: job.op, state: item.state } : null; + const jobId = locks.get(linkId); + const job = jobId ? jobs.get(jobId)?.job : undefined; + if (!job || job.state === "done") return null; + return { jobId: job.jobId, op: job.op, state: job.state }; + }, + settled() { + if (!running()) return Promise.resolve(); + return new Promise(resolve => { idle.add(resolve); }); }, - async settled() { while (running.size > 0) await Promise.all([...running]); }, }; } let shared: ChildOpsEngine | null = null; -/** One engine per Home process, shared by routes (wp3) and the scheduler (wp5, wp7). */ +/** One engine per Home process, shared by the routes (wp3) and the scheduler (wp5, wp7). */ export function sharedChildOpsEngine(): ChildOpsEngine { return shared ??= createChildOpsEngine(); } +/** Tests that reach the routes through handleManagementAPI cannot set LinkRouteState.childOps; they await + * sharedChildOpsEngine().settled() and call this in afterEach. Precedent: resetQuotaResetActivationForTests. */ +export function resetSharedChildOpsEngineForTests(): void { + shared = null; +} ``` The engine imports no server, CLI or update module. It is reached only from `link-child-ops-routes.ts` (lazy through management-api's `/api/link` import) and from the wp5 -scheduler, so `tests/lab/core-link-boundary.test.ts` keeps passing unchanged. +scheduler, so `tests/lab/core-link-boundary.test.ts` keeps passing unchanged. A submit for `observe` keeps wp2's +behaviour exactly: client-initiated items run `runObserveItem`, which answers without SSH and writes the peer-only +observation (wp2 test "second job on a held link", link-child-ops-routes.test.ts:148-167, keeps passing). ### 2.3 src/link/child-ops-runners.ts (NEW, full content) @@ -527,7 +603,9 @@ export async function pinnedSync(record: LinkRecord, platform: Platform, restart try { const result = await env.runner.run(buildExecArgv({ alias: record.alias, knownHostsFile: env.knownHostsFile, argv }), { timeoutMs: SYNC_TIMEOUT_MS }); - const verdict = classifySyncResult(result); + // Revision 5: wp1's classifier reads the selected-kind marker only when asked (sync-fanout-classify.ts:47-51), and + // remotePinnedSyncArgv prints it only for a restarting sync; without the option executable is never set. + const verdict = classifySyncResult(result, { expectSelectedKind: restart !== "none" }); // Only a synced Home-run sync proves the Child applied the catalog; a GET of /v1/catalog proves only a fetch. if (verdict.state === "synced" && etag !== null) { try { env.writeObservation(record.id, { catalogSyncedEtag: etag }); } catch { /* cache only */ } @@ -577,9 +655,11 @@ export function syncOutcome(sync: SyncResult, alias: string): ItemOutcome { : { state: "failed", reason: sync.reason ?? "child-sync-failed", sync }; } -/** 020's observe dispatch as a runner: fresh fields only, so catalogPulledAt, lastOperation and suppressedTarget survive. */ +/** wp2's observe dispatch (child-ops-engine.ts:91-106 on 87e005f93c) as a runner: fresh fields only, so every carried field survives. */ export const runObserveItem: ItemRunner = async (record, _job, env) => { - const outcome = await env.observe(record); + let outcome: Awaited>; + // wp2 reports a throwing observe as failed/observe-error, not the engine's internal-error; keep that reason. + try { outcome = await env.observe(record); } catch { return { state: "failed", reason: "observe-error" }; } // Revision 4: every carried field (010 CARRIED_OBSERVATION_FIELDS) survives, including the three revision 4 added. if (outcome.observation) env.writeObservation(record.id, withoutCarriedFields(outcome.observation)); return { state: outcome.state, reason: outcome.reason }; @@ -728,36 +808,35 @@ token would only be refused by the Child. `ObserveProbe.platform` is the platfor ### 2.4 src/server/management/link-child-ops-routes.ts (MODIFY, 020 file; target content of the parts wp3 changes) -020's `attachLinkChildOpsForState`, the status hook and the job GET stay as 020 wrote them. wp3 replaces the engine -accessor, the env builder, `parseOperation` and the POST handler: +Revision 5 base: wp2's file (116 lines on 87e005f93c). `catalogRevisionForRoutes` (:27-38) and the job GET matcher +(:106-114) stay; `attachLinkChildOpsForState` (:69-86) keeps its pull flush and changes one line (`activeJob` reads the +shared engine, so a job the wp5 scheduler started shows on a status read that never submitted); wp3 replaces +`parseOperation` (:40-49), `childOpsEngineFor` (:51-66) and the POST branch (:93-104), and adds `childOpsEnvFor` and +`refusal`. The handler keeps wp2's order: `auth(ctx, "either")` for every path, then `409 runtime_role_client` on a +Child, then the body, then `503 link_unavailable` when the store read throws inside `submit`. ```ts -import { auth, fail, isRecord, knownHostsFile, readStoreFor, runnerFor, type LinkRouteState } from "./link-route-state"; -import { readManagementJsonBodyOr } from "./body"; -import type { ManagementContext } from "./context"; -import { packageVersion } from "../../lib/package-version"; -import { projectLinkStatus } from "../../link/status-projection"; -import { inspectRemoteChildRuntime } from "../../link/remote-plant"; -import { requireRemotePlatform } from "../../link/remote-platform"; import { DEFAULT_IDLE_AFTER_MS, isLinkIdle } from "../../link/child-activity"; +import { createCatalogRevisionMonitor, type CatalogRevisionMonitor } from "../../link/child-catalog-freshness"; import { observeChild } from "../../link/child-observe"; -import { CHILD_JOB_ID, sharedChildOpsEngine, type ChildOpsEngine, type SubmitRefusal } from "../../link/child-ops-engine"; -import { CHILD_OPS, type ChildOp, type ChildOpsEnv, type ChildRestart } from "../../link/child-ops-types"; -// Revision 4 (4529522014): childObservationsPath is exported by src/link/paths.ts (010 file map), not child-observations. -import { childObservationsPath } from "../../link/paths"; import { patchChildObservation, readChildObservations, updateChildObservations } from "../../link/child-observations"; +import { sharedChildOpsEngine, type ChildOpsEngine, type SubmitRefusal, type SubmitResult } from "../../link/child-ops-engine"; +import { CHILD_OPS, type ChildOp, type ChildOpsEnv, type ChildRestart } from "../../link/child-ops-types"; +import { requireRemotePlatform } from "../../link/remote-platform"; +import { inspectRemoteChildRuntime } from "../../link/remote-plant"; +import { attachLinkChildOps, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; +// ... the remaining wp2 imports (:9-14) are unchanged; `isRecord` joins the link-route-state import. -const LINK_ID = /^lnk_[0-9a-f]{16}$/; -const MAX_LINK_IDS = 64; const NO_STORE = { "cache-control": "no-store" }; -/** One engine per process: route state is keyed by the config object (link-routes.ts:54-69), so a per-state engine - * would lose its locks on a config reload, and the wp5 scheduler must submit to the instance the routes use. */ +/** One engine per Home process (Revision 5). wp2 kept one per route state, which link-routes.ts keys by the config + * object (:30, :54-70), so a config reload started a second engine without the first one's locks, and the wp5 + * scheduler must submit to the engine the routes read. state.childOps stays the seam a direct test can set. */ export function childOpsEngineFor(state: LinkRouteState): ChildOpsEngine { - return state.childOps ?? sharedChildOpsEngine(); + return state.childOps ??= sharedChildOpsEngine(); } -/** The env captures this request's seams for the job's lifetime; every read is live. wp5 swaps idleAfterMs for the policy value. */ +/** The env captures this request's seams for the job's lifetime; every read is live. wp5 passes the policy idleAfterMs. */ export function childOpsEnvFor(ctx: ManagementContext, state: LinkRouteState, idleAfterMs = DEFAULT_IDLE_AFTER_MS): ChildOpsEnv { const now = ctx.deps.now ?? Date.now; const runner = runnerFor(ctx); @@ -773,16 +852,20 @@ export function childOpsEnvFor(ctx: ManagementContext, state: LinkRouteState, id return snapshot ? { inFlight: snapshot.inFlight, idle: isLinkIdle(snapshot, now(), idleAfterMs) } : null; }, sleep: ms => Bun.sleep(ms), + // wp2's observe deps (:55-63), unchanged: the peer and activity listeners are optional on LinkRouteListener. observe: record => observeChild(record, { runner, knownHostsFile: hosts, homeVersion, - peer: linkId => state.listener.peerObservation(linkId), - platformOf: async link => requireRemotePlatform(runner, link.alias, hosts), + peer: linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null }, + platformOf: link => requireRemotePlatform(runner, link.alias, hosts), inspect: inspectRemoteChildRuntime, now: () => new Date(now()) }), - readObservation: linkId => readChildObservations(childObservationsPath()).children[linkId] ?? null, + // readChildObservations defaults to childObservationsPath() (child-observations.ts:146), as wp2 calls it. + readObservation: linkId => readChildObservations().children[linkId] ?? null, + // Never throws into a runner: a cache write failure is logged by error name, as wp2's engine did (:62-67). writeObservation: (linkId, patch) => { - updateChildObservations(file => patchChildObservation(file, linkId, patch, new Date(now()))); + try { updateChildObservations(file => patchChildObservation(file, linkId, patch, new Date(now()))); } + catch (error) { console.warn(`[link] child observation write failed linkId=${linkId} error=${error instanceof Error ? error.name : "unknown"}`); } }, - // Revision 4: 020's monitor on LinkRouteState.catalogRevision, created here on first use exactly as 020's status hook - // creates it (050 later defaults it to sharedCatalogRevisionMonitor()). null when the catalog cannot be read. + // Revision 4: wp2's monitor on LinkRouteState.catalogRevision (catalogEtag hashing, src/lib/catalog-etag.ts); + // 050 later defaults it to sharedCatalogRevisionMonitor(). null when the catalog cannot be read. catalogEtag: () => (state.catalogRevision ??= catalogRevisionForRoutes()).current()?.etag ?? null, }; } @@ -816,58 +899,48 @@ function refusal(code: SubmitRefusal): Response { switch (code) { case "link_not_found": return fail(code, "The link was not found.", 404); case "link_not_home_initiated": return fail(code, "Only a Home-initiated link can be operated from this computer.", 409); - case "link_operation_in_progress": return fail(code, "Another Child operation is running on this link.", 409); - case "no_eligible_links": return fail(code, "No Home-initiated link is available for this operation.", 409); + case "link_operation_in_progress": return fail(code, "Another operation is running on this link.", 409); + // Returned only when links.json has no link at all (2.2), so the message says exactly that (carried item a). + case "no_eligible_links": return fail(code, "This Home has no links.", 409); } } -// In handleLinkChildOpsRoutes, the POST branch (020 admitted only op:"observe"): +// In handleLinkChildOpsRoutes (after wp2's auth(ctx, "either") at :91-92), the POST branch: if (url.pathname === "/api/link/children/operations" && req.method === "POST") { - const denied = auth(ctx, "either"); - if (denied) return denied; - const parsed = parseOperation(await readManagementJsonBodyOr(ctx.req, null)); - if (!parsed) return fail("invalid_body", "op and linkIds are required; restart is app-server or codex; a restart needs explicit linkIds.", 400); - const result = childOpsEngineFor(state).submit({ ...parsed, trigger: "manual" }, childOpsEnvFor(ctx, state)); + if (ctx.config.runtimeRole === "client") return fail("runtime_role_client", "A Child runtime does not operate other Children.", 409); + const operation = parseOperation(await readManagementJsonBodyOr(req, null)); + if (!operation) return fail("invalid_body", "The body must be exactly {op, linkIds, restart?}; restart is app-server or codex and needs explicit linkIds.", 400); + let result: SubmitResult; + try { result = childOpsEngineFor(state).submit({ ...operation, trigger: "manual" }, childOpsEnvFor(ctx, state)); } + catch { return fail("link_unavailable", "The link store could not be read.", 503); } return result.ok ? Response.json({ job: result.job }, { status: 202, headers: NO_STORE }) : refusal(result.code); } +// The job GET (:106-114) reads childOpsEngineFor(state).job(jobId) instead of state.childOps?.job(jobId), and +// attachLinkChildOpsForState's activeJob source (:84) becomes linkId => childOpsEngineFor(state).activeJob(linkId). ``` The `trigger` is always `"manual"` here: no request body can claim `"auto"`, so the idle and suppression bypass in -`runUpdateItem` is reachable only from the wp5/wp7 scheduler. The job GET keeps 020's `/api/link/children/jobs/{jobId}` -matcher; `job()` returning `undefined` answers `404 job_not_found`. +`runUpdateItem` is reachable only from the wp5/wp7 scheduler. As in revision 4, an +`update` with explicit `linkIds` may carry a `restart` for its post-update sync (the CLI never sends one). The job GET keeps wp2's `/api/link/children/jobs/{jobId}` matcher; `job()` returning +`undefined` answers `404 job_not_found`. -### 2.5 src/server/management/link-route-state.ts (no change in wp3) +### 2.5 src/server/management/link-route-state.ts (comment only) -020 already added `childOps?` (and `catalogRevision`) to `LinkRouteState`; wp3 only changes what -`childOpsEngineFor` does with it (2.4). The shape 020 lands, for reference. Before 020: +The field exists since wp2 (:53-54). Before: ```ts -export interface LinkRouteState { - pendingHosts: Map; - operations?: Set; - confirmedHosts?: Map; - compensationFailures?: Map; - supervisor: import("../../link/supervisor").LinkSupervisor; - listener: LinkRouteListener; -} + /** Created on the first Child-operations request (link-child-ops-routes.ts). */ + childOps?: import("../../link/child-ops-engine").ChildOpsEngine; ``` -After 020: +After: ```ts -export interface LinkRouteState { - pendingHosts: Map; - operations?: Set; - confirmedHosts?: Map; - compensationFailures?: Map; - supervisor: import("../../link/supervisor").LinkSupervisor; - listener: LinkRouteListener; - /** Test seam; production uses the process-wide engine (sharedChildOpsEngine). */ + /** The process-wide engine (sharedChildOpsEngine), cached on first use; a direct test may set its own. */ childOps?: import("../../link/child-ops-engine").ChildOpsEngine; -} ``` ### 2.6 src/server/management/link-routes.ts (MODIFY) -020 already added the lazy delegating branch for `/api/link/children/` before the reconnect matcher (020 -"link-child-ops-routes.ts and link-routes.ts"); wp3 leaves it and its dynamic import as they are, so +wp2 added the lazy delegating branch for `/api/link/children/` before the reconnect matcher (link-routes.ts:352-356); +wp3 leaves it and its dynamic import as they are, so `link-routes.ts` gains no static import of the child-ops module. DELETE 409, before (:201-205): @@ -878,251 +951,233 @@ 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); ``` -After (three lines added after :205; applies to ordinary, `purge` and `force` removal alike): +After (three lines added after :205, before the body read at :206; applies to ordinary, `purge` and `force` removal alike): ```ts if (!record) return fail("link_not_found", "The link was not found.", 404); // A queued or running job would keep opening SSH sessions to a Child this removal disconnects. const { childOpsEngineFor } = await import("./link-child-ops-routes"); if (childOpsEngineFor(state).activeJob(id)) return fail("link_operation_in_progress", "A Child operation is running on this link; wait for it to finish, then remove the link.", 409); ``` -The delegating branch sits before the DELETE prefix match (:355), so `DELETE /api/link/children/...` never reaches +The delegating branch sits before the DELETE prefix match (:365), so `DELETE /api/link/children/...` never reaches `remove()`; the child-ops handler returns `null` for it and the API answers its usual 404. ### 2.7 src/server/management/route-registry.ts (no change in wp3) -020 declared both routes with the parameter spelled `{jobId}`: +wp2 declared both routes (route-registry.ts:440-442) with the parameter spelled `{jobId}`: `{ method: "POST", path: "/api/link/children/operations", ... mutates: true }` and `{ method: "GET", path: "/api/link/children/jobs/{jobId}", ... mutates: false, mechanism: "regex" }`. Both are capability-covered (2.10, which uses the same `{jobId}` spelling), so no exemption is declared. -`tests/server/management-route-registry.test.ts` scans every `src/server/management/*.ts` (:44-51). +`tests/server/management-route-registry.test.ts` scans every `src/server/management/*.ts`. + +### 2.8 src/cli/link-child-ops.ts (MODIFY, 020 file; the parts wp3 changes) + +Revision 5 base: wp2's file (136 lines on 87e005f93c). Kept as merged: `validateChildJob` (tolerant since wp2, audit H5; +it already drops unknown keys and keeps any enum string ≤ 256 for display), `readJob`, `OLD_HOME_MESSAGE`, +`RUNTIME_ROLE_CLIENT_MESSAGE`, `CONFLICT_MESSAGES`, `LINK_CHECK_POLL_MS` (2 s), the always-JSON output (`--json` is +accepted and changes nothing), and `--no-wait` returning the verdict of a job already done. wp3 changes six things. + +1. **Types** come from `../link/child-ops-types` (2.1) instead of `../link/child-ops-engine` (:1). +2. **No-links message** (carried item a). `no_eligible_links` is returned only when links.json is empty (2.2), so: + `export const NO_ELIGIBLE_LINKS_MESSAGE = "This Home has no links.";` (was "This Home has no Home-initiated links to + operate on.", :15). `tests/cli/cli-link-check.test.ts:127` compares against the constant and keeps passing. +3. **Validator keeps `restart.requested`.** In the nested loop (:51-58) the `restart` object also reads + `requested` with the same `text(...)` check as `outcome` (a wrong type throws, any string ≤ 256 is kept); a wp3 Home + always sends it. `sync.executable` stays + dropped (display needs only state and reason). +4. **Exit mapping** (Revision 3 R2 and Revision 4 4529522006 on wp2's `linkCheckExitCode`, :73-82). An unknown item + state still exits 1 as wp2 shipped it (D15); a success that carries a reason, or whose requested restart did not + happen, is partial: + ```ts + /** D15: 0 every item succeeded or up-to-date with nothing withheld; 6 any item needs the operator; 1 any failure or an unknown state. */ + export function linkCheckExitCode(job: ChildJob): number { + let code = 0; + for (const item of job.items) { + if (SUCCESS.has(item.state)) { + // restart-skipped:link-active, post-update-sync-failed, or a restart reported unsupported / not-run / skipped. + if (item.reason !== null || (item.restart !== undefined && item.restart.outcome !== "requested")) code = Math.max(code, 6); + continue; + } + if (PARTIAL.has(item.state)) code = Math.max(code, 6); + else return 1; + } + return code; + } + ``` + Observe items never carry a reason on success (child-observe.ts), so `ocx link check`'s exits do not change. +5. **Wait deadline** (carried item b). wp2 sets `deadline = now() + ceil(items/3) × 120 s + 30 s` once, at submission + (:125), so a job queued behind another job's items spends its budget waiting and exits 1 `job_still_running` while it + has not started. wp3 moves the loop into one helper both verbs use, with a per-op item budget, and keeps moving the + deadline while any item is still `queued`: + ```ts + /** How long one item may run once it has started: the sum of its runner's own SSH bounds plus a margin. */ + export const CHILD_ITEM_WAIT_MS: Readonly> = { + observe: 120_000, // wp2's value: one 60 s plan probe, one read-only inspection + sync: 420_000, // platform probe 30 s + two pinned syncs of SYNC_TIMEOUT_MS (180 s) + "restart-codex": 420_000, + update: 2_100_000, // probe 30 s + identity sync 180 s + plan 60 s + apply 900 s + 120 s grace + // + post-update sync with retry 2 × 360 s + 30 s + }; + /** Never poll longer than this from submission, queued or not; the Home keeps a finished job one hour. */ + export const CHILD_JOB_WAIT_CAP_MS = 10_800_000; + + async function waitForChildJob(job: ChildJob, deps: LinkCheckDeps, request: LinkRequest): Promise<{ job: ChildJob; done: boolean }> { + const sleep = deps.sleep ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))); + const now = deps.now ?? Date.now; + const cap = now() + CHILD_JOB_WAIT_CAP_MS; + const perItem = Object.hasOwn(CHILD_ITEM_WAIT_MS, job.op) ? CHILD_ITEM_WAIT_MS[job.op] : CHILD_ITEM_WAIT_MS.update; + const lanes = job.op === "update" ? 1 : 3; + const budget = (current: ChildJob): number => + Math.ceil(current.items.filter(item => item.finishedAt === null).length / lanes) * perItem + 30_000; + let deadline = now() + budget(job); + while (job.state !== "done") { + // A queued item has not started its SSH work: while one waits behind another job, the deadline moves with it. + if (job.items.some(item => item.state === "queued")) deadline = Math.max(deadline, now() + budget(job)); + if (now() >= Math.min(deadline, cap)) return { job, done: false }; + await sleep(LINK_CHECK_POLL_MS); + job = readJob(await request(`/api/link/children/jobs/${encodeURIComponent(job.jobId)}`, {}, deps)); + } + return { job, done: true }; + } + ``` + The 35-minute update budget (2 040 s of runner bounds plus a margin) is finite because every runner step has its own + timeout, so a queued item always starts; + `mutationBoundMs` is the fixed `ORCHESTRATED_MUTATION_BOUND_MS` (900 000, src/update/orchestrated-plan.ts:5). A later + Child with a larger bound can outlast the CLI's budget; the CLI then exits 1 with the existing + `job_still_running` message, which names the GET that reads the job again. wp2's deadline test + (cli-link-check.test.ts:97-107: one `running` item, 60 s steps) still sees 150 s and four calls. +6. **Shared submit, the 403 and the new verbs.** `runLinkCheck`'s body after its argument parsing (:107-135) becomes + `submitChildJob(body, wait, deps, request)`, which `runLinkChildOp` calls too. It keeps wp2's 404 and 409 mapping and + adds the Revision 4 (4529521950) 403 mapping: + ```ts + export const LOOPBACK_REQUIRED_MESSAGE = "Child operations from the CLI need a Home bound to loopback; use the Remote Link page in the dashboard."; + // in submitChildJob's catch, beside the 404 and 409 branches: + if (error instanceof RuntimeApiError && error.status === 403) throw new RuntimeApiError(LOOPBACK_REQUIRED_MESSAGE, 403, error.body); + ``` + `runCliAction` maps a 403 to exit 1 (src/cli/runtime-api.ts:555). The verbs: + ```ts + export type LinkChildVerb = "update" | "sync" | "restart-codex"; + export const LINK_CHILD_OPS_USAGE = [ + " ocx link update (--link-id | --all) [--no-wait] [--json]", + " ocx link sync (--link-id | --all) [--no-wait] [--json]", + " ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json]", + " ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json]", + ] as const; + + export function parseChildOpArgs(verb: LinkChildVerb, args: string[], usage: string): { body: { op: LinkChildVerb; linkIds: string[] | "all"; restart?: "app-server" | "codex" }; wait: boolean } { + takeJsonFlag(args); + const noWait = takeFlag(args, "--no-wait"); + const all = takeFlag(args, "--all"); + const yes = takeFlag(args, "--yes"); + // Repeatable like ocx link check (:94-98); a restart needs exactly one. + const linkIds: string[] = []; + for (let id = takeOption(args, "--link-id"); id !== undefined; id = takeOption(args, "--link-id")) { + if (!LINK_ID.test(id)) throw new CliUsageError(`${verb} requires valid --link-id values`, usage); + if (!linkIds.includes(id)) linkIds.push(id); + } + let restart: "app-server" | "codex" | undefined; + if (verb === "sync") { + const narrow = takeFlag(args, "--restart-app-server-only"); + const full = takeFlag(args, "--restart-codex"); + if (narrow && full) throw new CliUsageError("choose one of --restart-app-server-only and --restart-codex", usage); + restart = narrow ? "app-server" : full ? "codex" : undefined; + } + if (verb === "restart-codex") { + const scope = takeOption(args, "--scope") ?? "app-server"; + if (scope !== "app-server" && scope !== "codex") throw new CliUsageError("--scope must be app-server or codex", usage); + if (!yes) throw new CliUsageError("restart-codex ends running Codex turns on the Child; pass --yes", usage); + restart = scope; + } + rejectArgs(args, usage); + if (all === (linkIds.length > 0)) throw new CliUsageError(`${verb} requires --link-id or --all, not both`, usage); + if (linkIds.length > 64) throw new CliUsageError(`${verb} accepts at most 64 --link-id values`, usage); + // A2 / M8: a restart names one link, and the codex scope (quit and relaunch the desktop app) needs --yes on every verb. + if (restart !== undefined && (all || linkIds.length !== 1)) throw new CliUsageError(`${verb} with a restart needs exactly one --link-id; --all is refused`, usage); + if (restart === "codex" && !yes) throw new CliUsageError("--restart-codex quits the Child's Codex app and ends its turns; pass --yes", usage); + if (yes && restart === undefined) throw new CliUsageError("--yes only confirms a restart", usage); + return { body: { op: verb, linkIds: all ? "all" : linkIds, ...(restart ? { restart } : {}) }, wait: !noWait }; + } + + export async function runLinkChildOp(verb: LinkChildVerb, args: string[], deps: LinkCheckDeps, request: LinkRequest, usage: string): Promise { + const parsed = parseChildOpArgs(verb, args, usage); + return submitChildJob(parsed.body, parsed.wait, deps, request); + } + ``` + `submitChildJob` prints the final job with `console.log(JSON.stringify(job))` exactly as `runLinkCheck` does (:121, + :134), prints the `job_still_running` error when the wait returns `done: false`, and returns `linkCheckExitCode(job)`. + A `404 job_not_found` while polling (the Home restarted) surfaces through `runCliAction` as exit 4 with the route's + message. -### 2.8 src/cli/link-child-ops.ts (MODIFY, 020 file; exported API and core logic after wp3) +### 2.9 src/cli/link.ts (MODIFY) +Usage, before (:38-51): ```ts -import { CliUsageError, RuntimeApiError, rejectArgs, takeFlag, takeJsonFlag, takeOption, printData } from "./runtime-api"; -import type { LinkCliDeps } from "./link"; -import { CHILD_JOB_SCHEMA } from "../link/child-ops-types"; -import { CHILD_JOB_ID } from "../link/child-ops-engine"; - -export type LinkChildVerb = "update" | "sync" | "restart-codex"; -export type LinkRequest = (path: string, init: RequestInit, deps: LinkCliDeps) => Promise; -export interface LinkChildOpsDeps { sleep?: (ms: number) => Promise; pollIntervalMs?: number; now?: () => number } -export const LINK_CHILD_OPS_USAGE = [ - " ocx link update (--link-id | --all) [--no-wait] [--json]", - " ocx link sync (--link-id | --all) [--no-wait] [--json]", - " ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json]", - " ocx link restart-codex --link-id [--scope app-server|codex] --yes [--json]", -] as const; -const LINK_ID = /^lnk_[0-9a-f]{16}$/; -const POLL_MS = 2_000; -/** The Home keeps finished jobs for one hour; polling longer cannot succeed. */ -const POLL_DEADLINE_MS = 3_600_000; -const SUCCESS = new Set(["succeeded", "up-to-date"]); -const FAILED = new Set(["failed", "uncertain"]); - -/** What the CLI reads from a Home of any later version: enum fields are open strings (H5). */ -export interface ChildJobItemView { - linkId: string; alias: string; state: string; reason: string | null; hint?: string; fromVersion?: string; toVersion?: string; - sync?: { state: string; reason: string | null }; restart?: { requested: string; outcome: string }; - startedAt: string | null; finishedAt: string | null; -} -export interface ChildJobView { - schema: typeof CHILD_JOB_SCHEMA; jobId: string; op: string; trigger: string; restart: string; state: string; - createdAt: string; finishedAt: string | null; items: ChildJobItemView[]; -} - -export interface ParsedChildOpArgs { body: { op: LinkChildVerb; linkIds: string[] | "all"; restart?: "app-server" | "codex" }; wait: boolean; json: boolean } - -export function parseChildOpArgs(verb: LinkChildVerb, args: string[], usage: string): ParsedChildOpArgs { - const json = takeJsonFlag(args); - const noWait = takeFlag(args, "--no-wait"); - const all = takeFlag(args, "--all"); - const linkId = takeOption(args, "--link-id"); - const yes = takeFlag(args, "--yes"); - let restart: "app-server" | "codex" | undefined; - if (verb === "sync") { - const narrow = takeFlag(args, "--restart-app-server-only"); - const full = takeFlag(args, "--restart-codex"); - if (narrow && full) throw new CliUsageError("choose one of --restart-app-server-only and --restart-codex", usage); - restart = narrow ? "app-server" : full ? "codex" : undefined; - } - if (verb === "restart-codex") { - const scope = takeOption(args, "--scope") ?? "app-server"; - if (scope !== "app-server" && scope !== "codex") throw new CliUsageError("--scope must be app-server or codex", usage); - if (!yes) throw new CliUsageError("restart-codex ends running Codex turns on the Child; pass --yes", usage); - restart = scope; - } - rejectArgs(args, usage); - // A2 / M8: a restart names its links, and the codex scope (quit and relaunch the desktop app) needs --yes on every verb. - if (restart !== undefined && all) throw new CliUsageError(`${verb} with a restart needs one --link-id; --all is refused`, usage); - if (restart === "codex" && !yes) throw new CliUsageError("--restart-codex quits the Child's Codex app and ends its turns; pass --yes", usage); - if (yes && restart === undefined) throw new CliUsageError("--yes only confirms a restart", usage); - if (all === (linkId !== undefined)) throw new CliUsageError(`${verb} requires exactly one of --link-id and --all`, usage); - if (linkId !== undefined && !LINK_ID.test(linkId)) throw new CliUsageError(`${verb} requires a valid --link-id`, usage); - return { body: { op: verb, linkIds: all ? "all" : [linkId!], ...(restart ? { restart } : {}) }, wait: !noWait, json }; -} - -/** - * Tolerant ocx.link.child-job/1 validation (architect G1, audit H5). Known keys are type-checked; unknown keys at job, - * item, sync and restart level are dropped; an enum field accepts any lowercase token so a value a later Home adds - * (a new restart.outcome, item state or reason) is passed through for display. The schema string and jobId stay exact. - */ -const text = (value: unknown, nullable = false): boolean => - (nullable && value === null) || (typeof value === "string" && value.length > 0 && value.length <= 256); -const token = (value: unknown): value is string => typeof value === "string" && /^[a-z][a-z0-9:_-]{0,79}$/.test(value); -const optional = (o: Record, key: string): Record => - key in o && o[key] !== undefined ? (text(o[key]) ? { [key]: o[key] as string } : bad()) : {}; -function bad(): never { throw new Error("invalid link API response: job"); } -function object(v: unknown): Record { - return v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : bad(); -} -export function validateChildJob(value: unknown): ChildJobView { - const job = object(value); - if (job.schema !== CHILD_JOB_SCHEMA || typeof job.jobId !== "string" || !CHILD_JOB_ID.test(job.jobId) - || !token(job.op) || !token(job.trigger) || !token(job.restart) || !token(job.state) - || !text(job.createdAt) || !text(job.finishedAt, true) || !Array.isArray(job.items) || job.items.length > 256) bad(); - const items = (job.items as unknown[]).map((raw): ChildJobItemView => { - const item = object(raw); - if (!text(item.linkId) || !text(item.alias) || !token(item.state) || !(item.reason === null || token(item.reason)) - || !text(item.startedAt, true) || !text(item.finishedAt, true)) bad(); - let sync: ChildJobItemView["sync"]; - if (item.sync !== undefined) { const s = object(item.sync); - if (!token(s.state) || !(s.reason === null || token(s.reason))) bad(); - sync = { state: s.state as string, reason: s.reason as string | null }; } - let restart: ChildJobItemView["restart"]; - if (item.restart !== undefined) { const r = object(item.restart); - if (!token(r.requested) || !token(r.outcome)) bad(); - restart = { requested: r.requested as string, outcome: r.outcome as string }; } - return { linkId: item.linkId as string, alias: item.alias as string, state: item.state as string, reason: item.reason as string | null, - ...optional(item, "hint"), ...optional(item, "fromVersion"), ...optional(item, "toVersion"), - ...(sync ? { sync } : {}), ...(restart ? { restart } : {}), - startedAt: item.startedAt as string | null, finishedAt: item.finishedAt as string | null }; - }); - return { schema: CHILD_JOB_SCHEMA, jobId: job.jobId as string, op: job.op as string, trigger: job.trigger as string, - restart: job.restart as string, state: job.state as string, createdAt: job.createdAt as string, - finishedAt: job.finishedAt as string | null, items }; -} - -/** 0 every item succeeded or up-to-date; 1 any failed or uncertain; 6 anything else, including a state this CLI does not know. */ -export function childJobExitCode(job: ChildJobView): 0 | 1 | 6 { - if (job.items.some(item => FAILED.has(item.state))) return 1; - return job.items.every(item => SUCCESS.has(item.state)) ? 0 : 6; -} - -export function renderChildJobHuman(job: ChildJobView): string[] { - return job.items.map(item => `${item.alias} (${item.linkId}): ${item.state}` - + (item.reason ? ` (${item.reason})` : "") - + (item.fromVersion && item.toVersion && item.fromVersion !== item.toVersion ? ` ${item.fromVersion} -> ${item.toVersion}` : "") - + (item.restart ? ` restart ${item.restart.requested}: ${item.restart.outcome}` : "") - + (item.hint ? ` — ${item.hint}` : "")); -} + ocx link cleanup [--link-id ] [--json] + ocx link check (--link-id | --all) [--no-wait] [--json] -export async function runLinkChildOp(verb: LinkChildVerb, args: string[], deps: LinkCliDeps & LinkChildOpsDeps, - request: LinkRequest, usage: string): Promise { - const parsed = parseChildOpArgs(verb, args, usage); - let job: ChildJobView; - try { - const created = await request<{ job: unknown }>("/api/link/children/operations", { method: "POST", - headers: { "content-type": "application/json" }, body: JSON.stringify(parsed.body) }, deps); - job = validateChildJob(created?.job); - } catch (error) { - // A Home before wp3 answers a bare 404 without a link error code. - if (error instanceof RuntimeApiError && error.status === 404 && !isLinkNotFound(error.body)) { - throw new RuntimeApiError("This Home does not support Child operations; update OpenCodex on the Home.", 404, null); - } - throw error; - } - const sleep = deps.sleep ?? (ms => Bun.sleep(ms)); - const now = deps.now ?? Date.now; - const deadline = now() + POLL_DEADLINE_MS; - while (parsed.wait && job.state !== "done" && now() < deadline) { - await sleep(deps.pollIntervalMs ?? POLL_MS); - const polled = await request<{ job: unknown }>(`/api/link/children/jobs/${job.jobId}`, {}, deps); - job = validateChildJob(polled?.job); - } - printData(job, parsed.json, renderChildJobHuman(job)); - if (!parsed.wait) return 0; - return job.state === "done" ? childJobExitCode(job) : 1; // job_still_running after the deadline -} +Forced revoke skips remote cleanup. Run ocx disconnect on the remote client. +Cleanup reports each Child's platform with disconnect guidance; it changes nothing. +Check reads each Home-initiated Child's installed version and update route; it changes no install.`; ``` -`isLinkNotFound(body)` reads only `body.error.code === "link_not_found"` from the reduced body -`linkRequest` already produces (`errorCodeBody`, link.ts:257-266). A `404 job_not_found` while polling -(the Home restarted) surfaces through `runCliAction` as exit 4 with the route's message. 020's `runLinkCheck` uses the -same `validateChildJob` and `childJobExitCode` (020 ships them tolerant from the start). - -### 2.9 src/cli/link.ts (MODIFY) - -Usage, before (:36-47) ends with ` ocx link cleanup [--link-id ] [--json]`; after, three lines are -appended inside the template from `LINK_CHILD_OPS_USAGE` and one sentence is added: +After (the four `LINK_CHILD_OPS_USAGE` lines after `check`, one sentence added; wp5's `policy` line joins the same block): ```ts ocx link cleanup [--link-id ] [--json] + ocx link check (--link-id | --all) [--no-wait] [--json] ${LINK_CHILD_OPS_USAGE.join("\n")} Forced revoke skips remote cleanup. Run ocx disconnect on the remote client. Cleanup reports each Child's platform with disconnect guidance; it changes nothing. +Check reads each Home-initiated Child's installed version and update route; it changes no install. Update, sync and restart-codex run on the Child over the link's SSH trust and wait for the job unless --no-wait.`; ``` -(wp2's `check` line and wp5's `policy` line sit in the same block.) -Switch, before (:385-398): +Switch, before (:393-410): wp2 already threads a verb's own exit beside `runCliAction`'s through `verdictExit`. ```ts -export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}): Promise { - return runCliAction(async () => { - const args = [...rawArgs]; - const command = args.shift(); - ... - else if (command === "cleanup") await runLinkCleanup(args, deps, LINK_USAGE); - else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup", LINK_USAGE); - }); -} + else if (command === "check") verdictExit = await runLinkCheck(args, deps, linkRequest, LINK_USAGE); + else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup, check", LINK_USAGE); ``` After: ```ts -export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps & LinkChildOpsDeps = {}): Promise { - let jobExit = 0; - const code = await runCliAction(async () => { - const args = [...rawArgs]; - const command = args.shift(); - ... - else if (command === "cleanup") await runLinkCleanup(args, deps, LINK_USAGE); - else if (command === "update" || command === "sync" || command === "restart-codex") jobExit = await runLinkChildOp(command, args, deps, linkRequest, LINK_USAGE); - else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup, update, sync, restart-codex", LINK_USAGE); - }); - return code !== 0 ? code : jobExit; -} + else if (command === "check") verdictExit = await runLinkCheck(args, deps, linkRequest, LINK_USAGE); + else if (command === "update" || command === "sync" || command === "restart-codex") verdictExit = await runLinkChildOp(command, args, deps, linkRequest, LINK_USAGE); + else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup, check, update, sync, restart-codex", LINK_USAGE); ``` -`linkRequest` (:239) becomes exported-in-module only through the parameter; nothing else changes. -`src/cli/dispatch.ts:416-417` already forwards every `link` argument, so `src/cli/index.ts` is untouched. +The import at :13 becomes `import { LINK_CHILD_OPS_USAGE, runLinkCheck, runLinkChildOp } from "./link-child-ops";`. +`runLinkCommand(rawArgs, deps: LinkCliDeps = {})` keeps its signature: `LinkCheckDeps` only adds optional seams, so the +tests pass a `LinkCheckDeps` as wp2's `cli-link-check.test.ts` already does. `linkRequest` (:247) and +`errorCodeBody` (:265) are unchanged. `src/cli/dispatch.ts` already forwards every `link` argument, so +`src/cli/index.ts` is untouched. ### 2.10 Capabilities, contract and help -`src/cli/capabilities-access-remote.ts`, appended before the closing `];` at :168: +`src/cli/capabilities-access-remote.ts`, appended after wp2's `link check` entry (:168-177) before the closing `];` at :178 +(Revision 5: the file writes `routes` and `flags` as JSON-style literals; either spelling typechecks): ```ts { command: ["link", "update"], summary: "Update a Home-initiated Child's OpenCodex to this Home's version.", usage: "ocx link update (--link-id | --all) [--no-wait] [--json]", routes: [{ method: "POST", path: "/api/link/children/operations" }, { method: "GET", path: "/api/link/children/jobs/{jobId}" }], - flags: [{ name: "--link-id", value: "string", summary: "Home-initiated link to update." }, { name: "--all", value: "boolean", summary: "Every Home-initiated link, one Child at a time." }, + flags: [{ name: "--link-id", value: "string", summary: "Home-initiated link to update; repeatable, up to 64." }, { name: "--all", value: "boolean", summary: "Every link in the Home's store, one Child at a time; Child-initiated links report unsupported." }, { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document." }], mutates: true, json: "payload", - details: ["Runs the Child's own ocx update --plan through the served entry, then --yes with --expect-plan and a --not-after 120 s after the plan on the Child's own clock, through the link's pinned SSH trust. npm and pnpm installs apply through their Node launcher; Bun installs stay on Bun. The target is always this Home's version; the Child refuses a downgrade.", "Children below 2.97.0 run nothing and report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply) and update only through the Desktop request route once Home and Child run the release with it. A manual update ends any turn currently relayed for that Child. A pinned sync runs after every update.", "Exit 0 all succeeded or up-to-date, 6 partial, 1 failed or uncertain, 5 another operation holds the link, 4 the Home lacks Child operations."], + details: ["Runs the Child's own ocx update --plan through the served entry, then --yes with --expect-plan and a --not-after 120 s after the plan on the Child's own clock, through the link's pinned SSH trust. npm and pnpm installs apply through their Node launcher; Bun installs stay on Bun. The target is always this Home's version; the Child refuses a downgrade.", "Children below 2.97.0 run nothing and report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply) and update only through the Desktop request route once Home and Child run the release with it. A manual update ends any turn currently relayed for that Child. A pinned sync runs after every update.", "Exit 0 all succeeded or up-to-date, 6 partial (including a kept update whose follow-up sync failed), 1 failed, uncertain or an unknown state, 5 another operation holds the link, the Home has no links or the command ran on a Child, 4 the link is unknown or the Home lacks Child operations. On a Home not bound to loopback it exits 1 and names the dashboard."], }, { command: ["link", "sync"], summary: "Run the pinned catalog and settings sync on Home-initiated Children.", - usage: "ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex] [--no-wait] [--json]", + usage: "ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex --yes] [--no-wait] [--json]", routes: [{ method: "POST", path: "/api/link/children/operations" }, { method: "GET", path: "/api/link/children/jobs/{jobId}" }], - flags: [{ name: "--link-id", value: "string", summary: "Home-initiated link to sync." }, { name: "--all", value: "boolean", summary: "Every Home-initiated link, three at a time." }, + flags: [{ name: "--link-id", value: "string", summary: "Home-initiated link to sync; repeatable, up to 64, exactly one with a restart." }, { name: "--all", value: "boolean", summary: "Every link in the Home's store, three at a time; refused with a restart." }, { name: "--restart-app-server-only", value: "boolean", summary: "Also restart the Child's Codex app-servers after the sync writes." }, { name: "--restart-codex", value: "boolean", summary: "Also quit and relaunch the Child's Codex desktop app; ends live turns." }, - { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document." }], + { name: "--yes", value: "boolean", summary: "Confirms --restart-codex." }, { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document." }], mutates: true, json: "payload", details: ["The same sync --expect-link the ocx sync fan-out runs, gated at 2.93.0. Restart flags are refused on Windows and ignored by ocx-child; the item reports restart unsupported.", "A restart needs one --link-id (--all is refused) and --restart-codex needs --yes. The restart is withheld (restart skipped, reason restart-skipped:link-active) while a relayed model request for that Child is in flight; the sync still runs.", "Exit codes as for ocx link update."], }, { command: ["link", "restart-codex"], summary: "Restart Codex on one Home-initiated Child so it reloads the catalog.", - usage: "ocx link restart-codex --link-id [--scope app-server|codex] --yes [--json]", + usage: "ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json]", routes: [{ method: "POST", path: "/api/link/children/operations" }, { method: "GET", path: "/api/link/children/jobs/{jobId}" }], flags: [{ name: "--link-id", value: "string", required: true, summary: "Home-initiated link." }, { name: "--scope", value: "string", summary: "app-server (default) or codex (also relaunches the desktop app)." }, - { name: "--yes", value: "boolean", required: true, summary: "Confirms that running Codex turns on the Child end." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document." }], + { name: "--yes", value: "boolean", required: true, summary: "Confirms that running Codex turns on the Child end." }, + { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document." }], mutates: true, json: "payload", details: ["A pinned sync with the restart flag; works on full Children from 2.93.0. app-server scope leaves the Codex desktop app's own app-server running.", "Refused on Windows Children (unsupported windows-restart) and reported unsupported for ocx-child. The codex scope needs a logged-in GUI session on the Child; its outcome is requested, not confirmed."], }, @@ -1131,10 +1186,14 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps & Link ```ts export const CAPABILITY_CONTRACTS = ["sync.home-fanout/1", "update.orchestrated/1", "link.child-ops/1"] as const; ``` -`src/cli/registry.ts:154-163`: the `link` entry's usage becomes -`ocx link ` and gains three detail -lines copied from the capability usages plus "Update, sync and restart-codex need loopback admin -authority on the Home and act only on Home-initiated links." +`src/cli/registry.ts:153-167`: the `link` entry's usage (:155) becomes +`ocx link `, it gains three detail +lines copied from the capability usages after the `Check:` line (:164), and the closing sentence (:165) becomes +"Issue, status, revoke, reconnect, check, update, sync and restart-codex use the running proxy's loopback management +API and admin token; update, sync and restart-codex act only on Home-initiated links." + +wp2's `link check` capability (details at :176) says a 409 means "--all found no Home-initiated link"; wp3 changes that clause to +"the Home has no links" (carried item a), the only edit to that entry. ## 3. Deviations from 004 @@ -1144,7 +1203,7 @@ authority on the Home and act only on Home-initiated links." 2. **Restart outcome gains `"not-run"`.** D11 has `requested | unsupported`. When the pinned sync does not reach `synced` the restart never ran; reporting `requested` would be false. `"not-run"` is added. 3. **CLI exit codes follow `runCliAction`.** D15 says "1 any failed/uncertain or API error". The existing - wrapper maps a `RuntimeApiError` 404 to exit 4 and 409 to exit 5 (src/cli/runtime-api.ts:553-555), and + wrapper maps a `RuntimeApiError` 404 to exit 4 and 409 to exit 5 (src/cli/runtime-api.ts:555), and every link verb already relies on that. wp3 keeps it: job verdict 0/6/1, an older Home 4 (with the D21 message), a held link 5. Revision 4 (4529522006): an item that ends `succeeded` with reason `post-update-sync-failed` counts as partial (exit 6) like `restart-skipped:link-active`: the update is kept and @@ -1156,12 +1215,13 @@ authority on the Home and act only on Home-initiated links." link's activity when the item runs and withholds the restart (`restart.outcome: "skipped"`, reason `restart-skipped:link-active`) while a relayed model request is in flight; `restart-codex` then sends nothing. 5. **The GUI part of the new error code lands in 060.** `link_operation_in_progress` is server-only here. - An unknown code already renders `remoteLink.error.generic` (gui/src/pages/RemoteLink.tsx:128), and the - gui parity test only checks codes that are in `LINK_ERROR_CODES` (gui/tests/remote-link.test.tsx:60-73), + An unknown code already renders `remoteLink.error.generic` (gui/src/pages/RemoteLink.tsx:129; `link_not_home_initiated` + is already a GUI code, :73, from reconnect), and the gui parity test only checks codes that are in `LINK_ERROR_CODES` + (gui/tests/remote-link.test.tsx:60-73), so the build stays green with no gui diff. 060 adds the code, its `ERROR_TKEY` entry and 11 locale keys, and must add `"link-child-ops-routes"` to that test's scanned file list (:61) for `job_not_found`, `invalid_job_id` and `no_eligible_links`. -6. **Test registration in a shard.** r3 suggests `layout.json` `explicit`; it is 1998 lines with the ratchet at 2000, +6. **Test registration in a shard.** r3 suggests `layout.json` `explicit`; it is 1999 lines on 87e005f93c with the ratchet at 2000, so the `layout-clients.json` shard is used (000 "Test registration rule"). 7. **Apply deadline on the Child's clock (audit M9).** D12 sends `now + 120 s` from the Home's clock, which the Child evaluates on its own clock. The plan probe now prints `ocx-fanout:child-now`; `--not-after` is that value plus @@ -1169,30 +1229,47 @@ authority on the Home and act only on Home-initiated links." Home clock with a `clock-skew` hint on a `deadline-passed` refusal. 8. **Windows Desktop ordering (audit H4).** D20 refuses Windows update before any SSH; the runner now observes a Windows Child and refuses `windows-apply` only after the `desktop-request` branch, which 080 fills in. +9. **wp2's engine model wins (Revision 5).** 020 planned a semaphore engine with a per-state instance; wp2 shipped one + engine-wide queue, busy items without `lastOperation`, and `"all"` as every link. wp3 keeps all three and adds the + update place, per-job envs and the shared instance (2.2). Consequence: `ocx link update --all` and `sync --all` list + Child-initiated links as `unsupported/child-initiated` items instead of omitting them, and `no_eligible_links` means an + empty links.json. +10. **Shared engine instead of one per route state (Revision 5).** wp2's `state.childOps ??= createChildOpsEngine(...)` + is replaced by `state.childOps ??= sharedChildOpsEngine()`, because route state is keyed by the config object and a + reload would otherwise drop every lock. Route tests reset it with `resetSharedChildOpsEngineForTests()`. +11. **CLI output and exits follow wp2's `check` (Revision 5).** Revision 4 planned human lines and "unknown state ⇒ 6"; + wp2 shipped always-JSON output and "unknown state ⇒ 1" (D15). The new verbs share `check`'s output and its + `linkCheckExitCode`, extended with the two partial-success reasons and a withheld restart (2.8 item 4). +12. **Queued time does not spend the CLI's wait budget (Revision 5, carried item b).** The deadline is per op and moves + while any item is `queued`, under a 3-hour cap from submission (2.8 item 5). +13. **Windows gated `update` needs `--plan` (Revision 5, carried item d).** wp1 refused only `--yes`; any other non-plan + `update` argv is now refused as `unsupported:windows-apply` (A4). ## 4. Field chains (PLAN-FIELD-CHAIN-01) | Field / value | Created | Serialized | Deserialized | Consumers | |---|---|---|---|---| -| `ChildJob` (all keys) | `createChildOpsEngine().submit` (2.2) | `Response.json({ job })` in link-child-ops-routes.ts (2.4), `structuredClone` snapshots | `validateChildJob` (2.8) | CLI human/JSON output; GUI parser in 060 (`gui/src/remote-link-child-ops-api.ts`); scheduler reads jobs in-process (050/070, no serialization) | +| `ChildJob` (all keys) | `createChildOpsEngine().submit` (2.2) | `Response.json({ job })` in link-child-ops-routes.ts (2.4), `structuredClone` snapshots | `validateChildJob` (wp2, tolerant; 2.8 adds `restart.requested`) | CLI human/JSON output; GUI parser in 060 (`gui/src/remote-link-child-ops-api.ts`); scheduler reads jobs in-process (050/070, no serialization) | | `ChildJob.trigger` `auto` | Only the wp5/wp7 scheduler (in-process `submit`) | wire as above | `validateChildJob` accepts both | `runUpdateItem` idle and suppression branch; GUI label in 060 | -| `ChildJobItem.state` values | runners (2.3), engine (`busy`, `unsupported`, `refused/link-removed`, `failed/internal-error`) | job document | `CHILD_ITEM_STATES` check | `childJobExitCode`; `lastOperation`; `?children=1` row `lastOperation` (020 attacher); GUI badges (060) | -| `reason` vocabulary | runners: `tunnel-`, `link-not-in-runtime`, `ssh-timeout`, `ssh-failed`, `ssh-runner-failed`, `platform-probe-failed`, `deadline-exceeded`, sync classifier reasons, `windows-restart`, `windows-apply`, `ephemeral`, `ephemeral-restart`, `update-in-flight`, `link-active`, `restart-skipped:link-active`, `target-failed-before`, `plan-expired`, `deadline-passed`, `launcher-node-missing`, `rolled-back`, `legacy-ocx`, `desktop-app`, `post-update-sync-failed`, plan reasons; engine: `link-operation-in-progress`, `child-initiated`, `link-removed`, `internal-error` | job document | lowercase token ≤ 80, unknown values passed through (CLI, H5) | human output; GUI reason text (060, unknown reason ⇒ raw code shown) | +| `ChildJobItem.state` values | runners (2.3), engine (`busy`, `unsupported/child-initiated`, `refused/link-removed`, `failed/internal-error`) | job document | `validateChildJob` keeps any string ≤ 256 | `linkCheckExitCode` (unknown ⇒ 1); `lastOperation` for items that ran; `?children=1` row `lastOperation` (020 attacher); GUI badges (060) | +| `reason` vocabulary | runners: `tunnel-`, `link-not-in-runtime`, `ssh-timeout`, `ssh-failed`, `ssh-runner-failed`, `platform-probe-failed`, `deadline-exceeded`, sync classifier reasons, `windows-restart`, `windows-apply`, `ephemeral`, `ephemeral-restart`, `update-in-flight`, `link-active`, `restart-skipped:link-active`, `target-failed-before`, `plan-expired`, `deadline-passed`, `launcher-node-missing`, `rolled-back`, `legacy-ocx`, `desktop-app`, `post-update-sync-failed`, plan reasons; engine: `link-operation-in-progress`, `child-initiated`, `link-removed`, `internal-error`; observe runner: `observe-error` (wp2) | job document | any string ≤ 256 kept for display (wp2 validator, H5) | human output; GUI reason text (060, unknown reason ⇒ raw code shown) | | `restart.outcome` `not-run`, `skipped` | `runSyncItem`, `runRestartCodexItem`, `runUpdateItem` post-sync | job document | CLI token, unknown values passed through | CLI human line; GUI (060) | | `ocx-fanout:child-now` → `ObserveProbe.childNowMs` | gated plan script (010) | SSH stderr | `readGatedFraming` | `runUpdateItem` `--not-after` | -| `ChildObservation.lastOperation` | `finishItem` (2.2) via `env.writeObservation` | `child-observations.json` (wp1 writer) | wp1 tolerant reader | `?children=1` `child.lastOperation` (020); 070 re-observe scheduler reads `uncertain` | +| `ChildObservation.lastOperation` | `finishItem` (2.2) via `env.writeObservation`, only for an item that ran (never `busy` or an admission-time `unsupported`) | `child-observations.json` (wp1 writer) | wp1 tolerant reader | `?children=1` `child.lastOperation` (020); 070 re-observe scheduler reads `uncertain` | | `ChildObservation.suppressedTarget` set / cleared | `runUpdateItem` on `rolled-back` / manual update | same file | same reader | `runUpdateItem` auto branch; 070 skip rule; GUI hint (060) | | `installedVersion`, `updateRoute` after success | `runUpdateItem` | same file | same reader | `?children=1` `versionSkew` (020) | | `link_operation_in_progress` (409) | `remove()` in link-routes.ts and `refusal()` | `fail()` envelope | `errorCodeBody` (link.ts:257) / GUI `LinkApiError` | CLI exit 5; GUI generic text until 060 adds the key | -| `job_not_found` (404), `invalid_job_id` (400), `no_eligible_links` (409) | link-child-ops-routes.ts | `fail()` | same | CLI exit 4/2... via `runCliAction`; GUI generic until 060 | +| `job_not_found` (404), `invalid_job_id` (400), `no_eligible_links` (409, links.json empty), `runtime_role_client` (409), `link_unavailable` (503) | link-child-ops-routes.ts | `fail()` | same | CLI: 404 ⇒ exit 4, 409 ⇒ exit 5 with `CONFLICT_MESSAGES` text, 503 ⇒ exit 1 via `runCliAction`; GUI generic until 060 | | contract `link.child-ops/1` | capability-contracts.ts:2 | `ocx capabilities --json` (capabilities-command.ts:63) | GOAT and agents | `tests/cli/cli-capabilities-arguments.test.ts:36-45` (derived) | -| `LinkRouteState.childOps` | tests only | N/A (in-memory) | N/A | `childOpsEngineFor` | +| `LinkRouteState.childOps` | `childOpsEngineFor` caches `sharedChildOpsEngine()`; a direct test may set it | N/A (in-memory) | N/A | `childOpsEngineFor`, the DELETE 409, `attachLinkChildOpsForState` | +| `catalogSyncedEtag` | `pinnedSync` from `env.catalogEtag()` read before the sync (wp2 monitor, `catalogEtag` in src/lib/catalog-etag.ts) | `child-observations.json` | wp1 reader | `catalogVerdict` (020), fan-out eligibility (050) | ## 5. Tests Four files are new (engine, runners, launcher fixture in A4, CLI); each fails before implementation because its import -target does not exist. Register them per 000 "Test registration rule": one line each before the closing `}` of -`scripts/test-layout/layout-clients.json` and of `tests/fixtures/test-layout-expected.json`: +target does not exist. Register them per 000 "Test registration rule": one line each after the last entry +(`"cli-link-check.test.ts": "cli"`, which gains a comma) of `scripts/test-layout/layout-clients.json` (:191) and of +`tests/fixtures/test-layout-expected.json` (:1662): `"link-child-ops-engine.test.ts": "clients"`, `"link-child-ops-runners.test.ts": "clients"`, `"link-child-update-launcher.test.ts": "clients"`, `"cli-link-child-ops.test.ts": "cli"`. `link-child-ops-routes.test.ts` (020) and `cli-link-check.test.ts` (020) are MODIFY and already registered. @@ -1200,24 +1277,37 @@ target does not exist. Register them per 000 "Test registration rule": one line routes sibling file. **tests/clients/link-child-ops-engine.test.ts** — engine with stub runners and a fake env (in-memory store, -`now` stepped by the test). +`now` stepped by the test). wp2 has no engine test file; its engine cases live in the routes file and stay there. - `single link held ⇒ submit refuses link_operation_in_progress`: submit a `sync` whose stub runner awaits a deferred; a second single-link submit returns `{ ok:false, code:"link_operation_in_progress" }`; `activeJob()` reports `running`. - `bulk all skips held link as busy`: hold link A, submit `linkIds:"all"` for A,B ⇒ A item `busy/link-operation-in-progress` - with `finishedAt` set, B runs. -- `all expands only hub-initiated` and `explicit client-initiated id in a list ⇒ unsupported/child-initiated`; - `single client-initiated id ⇒ link_not_home_initiated`; `unknown id ⇒ link_not_found`; `no links ⇒ no_eligible_links`. + with `finishedAt` set and no `writeObservation` call for A, B runs (wp2 rule). +- `all lists every link` (Revision 5): a `sync` over a hub-initiated and a client-initiated link ⇒ the client item ends + `unsupported/child-initiated` at admission with no runner call and no `lastOperation`; the same store with `observe` ⇒ + the observe runner runs for both (wp2 behaviour). `explicit client-initiated id in a list ⇒ unsupported/child-initiated`; + `single client-initiated id ⇒ link_not_home_initiated` for `sync`, `restart-codex`, `update`, and a 202 job for + `observe`; `unknown id ⇒ link_not_found`; `links.json empty ⇒ no_eligible_links`; `only client-initiated links, + sync all ⇒ 202, every item unsupported`; `every link held ⇒ 202, job done at once, every item busy`. +- `a waiting update does not block a sync` (Revision 5): an update runner held on a deferred, a second update job and + then a sync job ⇒ the sync item runs while the second update item stays `queued`. - `update runs one at a time; sync runs three at a time`: five links, stub runner records max concurrency ⇒ 1 for `update`, 3 for `sync`. - `record removed before its turn ⇒ refused/link-removed`: the stub removes link B from the store while A runs. - `runner throws ⇒ failed/internal-error` and `runner returns non-terminal state ⇒ failed/internal-error`. - `lastOperation written per item` (spy `writeObservation`) and `write failure does not fail the item`. -- `retention`: 34 finished jobs ⇒ the two oldest are gone; a job finished 61 min ago is pruned on the next submit. +- `retention`: 34 finished jobs ⇒ the two oldest are gone; a job finished 61 min ago on its env's clock is pruned on + the next submit or `job()` read. +- `shared instance`: `sharedChildOpsEngine()` returns one object until `resetSharedChildOpsEngineForTests()`. - `locks released`: after `settled()`, `activeJob()` is null for every link. **tests/clients/link-child-ops-runners.test.ts** — runners with a fake `runner.run` keyed by argv content, the -real wp1 builders and classifiers, a stubbed `observeChild` (`mock.module` on `../../src/link/child-observe`). +real wp1 builders and classifiers, and a scripted `env.observe` (Revision 5: the runners reach observe only through +the env, so no `mock.module` is needed; bun's module mocks leak across files). +- Revision 5: `observe runner` ⇒ the observation is written without carried fields; `env.observe` throws ⇒ + `failed/observe-error`; `restarting sync passes expectSelectedKind` ⇒ a second sync whose stderr carries + `ocx-fanout:selected-kind:ocx-child` after the selection marker yields restart `unsupported`, and the same stderr on a + plain sync is ignored. - sync: `posix restart app-server ⇒ argv ends with --json --restart-app-server-only`; `windows restart ⇒ no flag, restart unsupported`; `ephemeral restart ⇒ no flag`; `exit 7 ⇒ busy/child-updating with hint`; `exit 5 mismatch ⇒ refused/child-link-mismatch:other_link`; `exit 121 lacks-expect-link ⇒ owner-action`; `exit 255 ⇒ offline/ssh-failed`; @@ -1279,6 +1369,11 @@ whose runners are stubs. - `DELETE /api/link/{id} answers 409 while held, and removes the link once the job is done` (stub runner gated on a deferred; the SSH runner fake records the `disconnect` call only after release). - `trigger is manual even when the body says auto`: body with `trigger` ⇒ 400 (unknown key). +- Revision 5: `no links ⇒ 409 no_eligible_links with message "This Home has no links."`; `{op:"sync", linkIds:[CLIENT]} ⇒ + 409 link_not_home_initiated` while wp2's `{op:"observe", linkIds:[CLIENT]}` still answers 202; a Child runtime still + answers `409 runtime_role_client` for every op. The harness reaches the routes through `handleManagementAPI`, so + `afterEach` awaits `sharedChildOpsEngine().settled()` and calls `resetSharedChildOpsEngineForTests()`; wp2's cases keep + passing because each harness uses a fresh config object and every case polls its job to `done`. - `registry`: covered by the existing `tests/server/management-route-registry.test.ts` after 2.7. **tests/cli/cli-link-child-ops.test.ts** — `runLinkCommand` with `baseUrl` and a fetch fake. @@ -1290,41 +1385,67 @@ whose runners are stubs. Child operations"; 404 `link_not_found` ⇒ 4 with the route message; poll 404 `job_not_found` ⇒ 4. - Restart flags (M8): `sync --all --restart-app-server-only` ⇒ exit 2; `sync --link-id A --restart-codex` without `--yes` ⇒ exit 2; `sync --link-id A --restart-codex --yes` posts `restart:"codex"`; `--yes` without a restart ⇒ exit 2. -- Job tolerance (H5): a job with an extra top-level key, an item with an extra key and `restart.outcome: "deferred"` - (a value this CLI does not know) validates, renders `restart app-server: deferred` and exits 6; an item state this CLI - does not know exits 6; `jobId: 7`, `items: {}` or `schema: "ocx.link.child-job/2"` ⇒ exit 1. -- `poll deadline`: a job that never reaches `done` (fake clock past 1 h) prints the job and exits 1. +- Job tolerance (H5, Revision 5 exits): a job with an extra top-level key, an item with an extra key and a `succeeded` + item whose `restart.outcome` is `"deferred"` (a value this CLI does not know) validates, prints the job with + `restart.requested` kept and exits 6; an item state this CLI does not know exits 1 (wp2's D15 rule); `jobId: 7`, + `items: {}` or `schema: "ocx.link.child-job/2"` ⇒ exit 1. +- Partial successes: `succeeded/restart-skipped:link-active` ⇒ 6; `succeeded/post-update-sync-failed` ⇒ 6; a + `succeeded` item with `restart.outcome: "requested"` and reason null ⇒ 0. +- Wait deadline (carried item b): a job whose only item stays `queued` for 20 polls of 60 s, then `running`, then + `done` ⇒ exit 0 (the queued time spent no budget); the same job never leaving `running` ⇒ exit 1 `job_still_running` + after the `sync` budget (420 s + 30 s) counted from the first poll without a queued item; an `update` job uses 2 100 s; + a job queued forever ⇒ exit 1 at the 3 h cap. +- 403 (Revision 4, 4529521950): the POST answers 403 ⇒ stderr carries `LOOPBACK_REQUIRED_MESSAGE`, exit 1. +- Repeatable `--link-id` for `update` and plain `sync`; two ids with a restart flag ⇒ exit 2. - `ocx capabilities --json lists link.child-ops/1`. +**tests/cli/cli-link-check.test.ts** (MODIFY, wp2 file) — a `check` job whose item stays `queued` past wp2's 150 s +budget, then finishes ⇒ exit 0; a 403 ⇒ the loopback message, exit 1; :127 already compares through +`NO_ELIGIBLE_LINKS_MESSAGE`, so the new text needs no edit there. + +**tests/clients/link-gated-ocx-argv.test.ts** (MODIFY, wp1 file) — the A4 Windows cases (non-plan `update` refused), +the `launcher` field validation and the framing `launcher:` line (two lines ⇒ `invalid`). + Existing suites that must stay green: `tests/cli/cli-capabilities.test.ts`, `cli-capabilities-arguments.test.ts`, `tests/ci-workflows/skill-ocx.test.ts`, `tests/cli/cli-link.test.ts`, `tests/server/link-management-routes.test.ts`, `tests/lab/core-link-boundary.test.ts`, `gui/tests/remote-link.test.tsx`. ## 6. Docs and source-of-truth sync -- **structure/remote-link.md** — "Tunnels, management and CLI" (:67-71): one paragraph naming the engine - (`src/link/child-ops-engine.ts`: process-wide, per-link lock, update one at a time, others three, 32 jobs / - 1 h, `lastOperation` in `child-observations.json`), the three ops and their SSH commands, the DELETE 409, the - Windows and ocx-child refusals, and that the route always submits `trigger:"manual"`. Add - `ocx link update|sync|restart-codex` to the CLI sentence (:71). In `structure/manifest.json` (:1343 entry) - add `src/server/management/link-child-ops-routes.ts` and `src/cli/link-child-ops.ts` to `documents` - (`src/link/` is already owned). Then `bun run structure:index` and `bun run structure:check` with the new - files staged (structure/AGENTS.md). -- **docs-site English** — `guides/remote-link.md`: new `## Operating Children from the Home` after the - "Home and Child versions" subsection (:162-180): what each op runs, version floors (sync and restart 2.93.0, - update 2.97.0), "the target is always this Home's version", Windows and ocx-child limits, exit codes, and - the sentence "Do not link a machine you do not own: the Home runs commands there with your SSH key." - CLI reference block (:137-148) gains the three usage lines. `reference/cli/lifecycle.md`: new - `## Child operations from the Home` before "Forced link revoke" (:1398) with the `ocx.link.child-job/1` - shape and the exit table. -- **docs-site translations** — the same two edits in `fr`, `ja`, `ko`, `ru`, `tr`, `zh-cn`, `zh-tw` copies of - `guides/remote-link.md` and `reference/cli/lifecycle.md` (14 files); command lines and codes verbatim, - prose translated, nothing that contradicts English. Validate with `cd docs-site && bun run build`. -- **skills/ocx** — `bun run skill:surface` regenerates `01_surface_access-remote.md` and - `01_management_surface.md`; hand edits: `05_remote_hub.md` "Commands" (:216) gains a "Child operations" - subsection (when to use `sync` vs `restart-codex` vs `update`, `--yes` semantics, never run `update --all` - without the operator's request), `04_failure_semantics.md` near "A pinned sync that refuses (exit 5)" (:136) - gains the 0/6/1/5/4 table and `link_operation_in_progress`, `02_json_shapes.md` near "Error shape" (:154) - gains `ocx.link.child-job/1`. `bun run skill:surface:check` and `tests/ci-workflows/skill-ocx.test.ts` verify. +Revision 5 anchors are on 87e005f93c, where wp2 already documented `ocx link check` in every surface below; wp3 extends +those passages instead of adding parallel ones. + +- **structure/remote-link.md** — rewrite wp2's job paragraph (:73, "Child operations run as jobs") for four ops: the + body becomes `{op, linkIds, restart?}` with `restart` refused on `observe` and with `"all"`; `409 + link_not_home_initiated` for a single Child-initiated link outside observe; `no_eligible_links` means links.json is + empty; the engine is one per process (`sharedChildOpsEngine`), three items across all jobs with at most one update; + the runners' SSH commands (pinned sync, the restart as a second pinned sync after a fresh activity read, the identity + sync, plan and apply with the launcher route, post-update sync); the DELETE 409; the Windows and ocx-child refusals; + the route always submits `trigger:"manual"`. Add the activity limit from Section 10 (a legacy key admission counts from + its response headers only). The manifest entry (:1356) already lists `link-child-ops-routes.ts` and + `link-child-ops.ts` (wp2; INDEX.md :261, :463) and `src/link/` is owned, so no manifest or INDEX change is needed; run + `bun run structure:check` with the new files staged (structure/AGENTS.md). +- **docs-site English** — `guides/remote-link.md`: new `## Operating Children from the Home` after the "Home and Child + versions" subsection (:165-193): what each op runs, version floors (sync and restart 2.93.0, update 2.97.0), "the + target is always this Home's version", Windows and ocx-child limits, that a restart waits while the Child has a model + request in flight and that a Child older than 2.69 is seen only once its response starts for a non-streaming request, + exit codes, the loopback requirement for the CLI, and the sentence "Do not link a machine you do not own: the Home + runs commands there with your SSH key." The check paragraph (:193) changes its exit-5 clause to "5 when another + operation is running on that link or the Home has no links". The CLI reference block (:139-152) gains the four usage + lines after the `check` line (:149). `reference/cli/lifecycle.md`: new `## Child operations from the Home` after the + `ocx link check` paragraph (:1369-1376), before "Remote Link Child attestations" (:1377), with the + `ocx.link.child-job/1` shape and the exit table. +- **docs-site translations** — the same edits in the `fr`, `ja`, `ko`, `ru`, `tr`, `zh-cn`, `zh-tw` copies of + `guides/remote-link.md` and `reference/cli/lifecycle.md` (14 files); command lines and codes verbatim, prose + translated, nothing that contradicts English. Validate with `cd docs-site && bun run build`. +- **skills/ocx** — `bun run skill:surface` regenerates `01_surface_access-remote.md` (its `link check` block at :700 + picks up the no-links wording) and `01_management_surface.md`; hand edits: `05_remote_hub.md` gains "Operating + Children from the Home" after "Checking Children from the Home (`ocx link check`)" (:330-345): when to use `sync` vs + `restart-codex` vs `update`, `--yes` semantics, never run `update --all` without the operator's request. + `04_failure_semantics.md`: the exit table's 6 row (:17) adds the new verbs and the two partial-success reasons; the + check paragraph (:32-37) replaces "`--all` found no Home-initiated link" (:36) with "the Home has no links" and + covers all four verbs; "A pinned sync that refuses (exit 5)" (:143) gains `link_operation_in_progress`. + `02_json_shapes.md` "Remote Link Child block and job document" (:401-419) gains `sync` and `restart` item fields. + `bun run skill:surface:check` and `tests/ci-workflows/skill-ocx.test.ts` verify. - **CLI help** — `registry.ts` and `LINK_USAGE` (2.9, 2.10). - **GUI** — none in this phase (Deviation 5). @@ -1333,8 +1454,9 @@ Existing suites that must stay green: `tests/cli/cli-capabilities.test.ts`, `cli | Guard | Tier | Executing surface | Known bypass | Residual risk | Wording | |---|---|---|---|---|---| | Per-link lock in the engine | Runtime, in-process | `ChildOpsEngine.submit` | `ocx sync` fan-out, a manual SSH session, GOAT, or a second Home process do not consult it | Two writers on one Child; the Child's sync/update mutex (`src/update/sync-update-mutex.mjs`) serializes them and returns exit 7 / `update-in-flight` | "serializes this Home's operations per link", never "exclusive access to the Child" | -| DELETE 409 while held | Runtime | `remove()` | Restarting the Home drops the job and its hold; a job submitted between the 409 check and `holdLink` | The job's next SSH step finds the record gone (`link-removed`) or the Child refuses with `child-link-mismatch` (exit 5) | "refuses removal while this Home runs an operation on the link" | +| DELETE 409 while held | Runtime | `remove()` | Restarting the Home drops the job and its hold; a job submitted after `remove()` passed its check (the check is synchronous with `activeJob`, the removal that follows awaits) | The job's next SSH step finds the record gone (`link-removed`) or the Child refuses with `child-link-mismatch` (exit 5) | "refuses removal while this Home runs an operation on the link" | | Version gate before `update` | Remote script (wp1) | `remoteGatedOcxArgv` | An operator running `ocx update` on the Child directly | None added | "never sends an update command to a Child below 2.97.0" | +| Windows builds only `update --plan` | Argv builder | `remoteGatedOcxArgv` Windows branch (A4, Revision 5) | A caller building PowerShell argv without `remoteGatedOcxArgv` | None in this unit; `windowsGatedOcxArgv` has no other caller that passes `update` | "never builds a Windows apply" | | `trigger:"auto"` only from the scheduler | Code path | `parseOperation` rejects unknown keys | A local process with the admin token cannot set it; it can only request manual ops it is already authorized for | None | "the management API submits manual operations only" | | Windows refusals | Runtime | runners | None from the Home; the operator can act on the Child | Windows Children stay manual | "refused on Windows (reason windows-restart / windows-apply)" | @@ -1355,6 +1477,23 @@ Run on 00680193cb while writing revision 1; facts refreshed by audit r1 on 61151 | `bun run test:changed` | PR gate | follows the import graph from the changed files | ### 8.1 Results + +Revision 5, on 87e005f93c (wp1 and wp2 merged, no wp3 code), 2026-11-11: + +- `bun run typecheck` — exit 0, "No type errors in 2,397 files". +- `bun test tests/server/link-child-ops-routes.test.ts tests/cli/cli-link-check.test.ts tests/clients/link-gated-ocx-argv.test.ts + tests/clients/link-child-update-contract.test.ts tests/clients/link-child-observe.test.ts tests/clients/link-child-activity.test.ts + tests/lab/core-link-boundary.test.ts tests/cli/cli-link.test.ts tests/cli/sync-fanout.test.ts + tests/cli/cli-capabilities-arguments.test.ts tests/server/management-route-registry.test.ts` — exit 0, 215 pass, 0 fail + across 11 files. These are the existing suites wp3 modifies or must keep green; the four new files do not exist yet. +- `bun run skill:surface:check` — exit 0, "All 9 generated management surface files are current." +- `bun run structure:check` — exit 0, "structure/ SSOT checks passed". +- `wc -l scripts/test-layout/layout.json` — 1999. +- NOT RUN: `bun run privacy:scan`, `cd docs-site && bun run build`, `bun run test:changed`, the full suite (a + plan-only change; the wp3 PR runs them). + +Earlier revisions: + Run on 00680193cb (before any wp1–wp3 code exists), 2026-11-11: - `bun run typecheck` — exit 0, "No type errors in 2,379 files". @@ -1374,8 +1513,9 @@ Run on 00680193cb (before any wp1–wp3 code exists), 2026-11-11: 1. `POST /api/link/children/operations {op:"sync",linkIds:[A]}` with the admin token on loopback answers 202 with an `ocx.link.child-job/1` document whose item ends `succeeded` when the fake Child exits 0 with a synced fan-out document (routes test). -2. `ocx link restart-codex --link-id A --yes` against a POSIX full Child sends one SSH command whose remote argv - contains `--restart-app-server-only` after `--json`, and prints `restart app-server: requested` (runners + CLI tests). +2. `ocx link restart-codex --link-id A --yes` against a POSIX full Child sends a plain pinned sync, then one SSH command + whose remote argv contains `--restart-app-server-only` after `--json`, and prints a job whose item carries + `restart: {requested: "app-server", outcome: "requested"}`; exit 0 (runners + CLI tests). 3. The same command for a Windows Child makes no SSH call and ends `unsupported (windows-restart)`; exit 6. 4. `ocx link update --link-id A` on a 2.97+ npm Child with `canApply` runs the plan on the served entry, the apply through ` /bin/ocx.mjs` with `--version --expect-plan --not-after `, then the pinned @@ -1388,18 +1528,25 @@ Run on 00680193cb (before any wp1–wp3 code exists), 2026-11-11: `DELETE /api/link/A` answers the same 409; after the job finishes the DELETE proceeds. 9. `ocx link update --all` over five links never runs two items at once; `ocx link sync --all` runs at most three. 10. `ocx capabilities --json` lists `link.child-ops/1`; `bun run skill:surface:check` passes after regeneration. -11. A Home without these routes makes `ocx link update` exit 4 with "This Home does not support Child operations". +11. A Home without these routes makes `ocx link update` exit 4 with wp2's `OLD_HOME_MESSAGE` ("This Home does not + support Child operations; update the Home."). 12. `bun run typecheck`, `bun run structure:check` and the management-route-registry test pass with the two new routes. 13. A restart request with `linkIds:"all"` answers 400; a restart sent while a relayed request is in flight is withheld with `restart-skipped:link-active`; `ocx link sync --restart-codex` without `--yes` exits 2 (A2, M8). 14. A Windows Child whose plan routes `desktop-request` never ends `windows-apply` (H4). 15. A job document with an unknown key and an unknown `restart.outcome` validates in this CLI (H5). 16. `--not-after` is computed from the Child's clock marker (M9). +17. (Revision 5) A job whose items wait `queued` behind another job does not exit 1 `job_still_running` before its + items have run for their op's budget (carried item b). +18. (Revision 5) `remoteGatedOcxArgv` throws `unsupported:windows-apply` for any Windows `update` argv without `--plan` + (carried item d); `ocx link sync --all` with no links exits 5 with "This Home has no links." (carried item a). +19. (Revision 5) The wp2 suites `tests/server/link-child-ops-routes.test.ts` and `tests/cli/cli-link-check.test.ts` + keep passing with the shared engine and the extended exit mapping. ## 10. Security review notes, risks and Windows - **Trigger for review:** two new mutating `/api/link` routes and remote execution of the Child's updater. Review - focus: auth (`either` = dashboard session or loopback admin token; Tailscale identity refused, link-route-state.ts:94-101), + focus: auth (`either` = dashboard session or loopback admin token; Tailscale identity refused, link-route-state.ts:104-111), no new credential, artifact source or trust root (D20), body strictness (unknown keys rejected, ≤ 64 ids), no remote output stored or logged (items carry closed reason codes and `boundHint`-bounded hints only), `--not-after` 120 s and `--expect-plan` binding, the pre-update and post-update `--expect-link` syncs as identity checks. @@ -1410,6 +1557,14 @@ Run on 00680193cb (before any wp1–wp3 code exists), 2026-11-11: - **Restart residual (Revision 4, 4529521916):** a relayed request that starts during the restart's own SSH round trip (after the second activity read) is cut by the restart. The Home cannot fence it: the restart runs on the Child, and delaying the request at the Home would not save a turn whose app-server is being restarted. +- **Legacy activity (Revision 5, carried item c):** activity is counted at the Home's relay + (src/server/index/link-relay-sessions.ts:136-153). A relay-session request counts from session validation, but a + legacy key admission, which is how a Child older than 2.69 reaches the Home, counts only once its response status is + known and below 400, that is from its response headers. For a non-streaming model request the headers arrive when + the model has finished, so while such a request is still running the link reads `inFlight` 0: the restart guard + (`linkBusy`) and the automatic idle check cannot see it, and a restart sent then can end that turn. Streaming requests + are seen from their first response bytes. A refused or unauthenticated legacy request never counts. The fix is on the + Child (update it to 2.69 or later, which uses relay sessions); the docs and skills page state the limit (Section 6). - **Consenting principal:** the operator holding the SSH key. The docs say not to link a machine you do not own. - **Risks:** (1) An update stops the Child's runtime and ends relayed turns; manual updates do not check idle by design (the operator asked), automatic ones do (070). (2) The env captures one request's `ctx` for the job @@ -1435,6 +1590,92 @@ Run on 00680193cb (before any wp1–wp3 code exists), 2026-11-11: ## Revision 3 (audit r2 residuals) -- R2: ocx link sync with a restart that was withheld (restart-skipped:link-active) exits 6, matching ocx sync --restart-children (040); the item state stays succeeded because the sync ran. CLI exit rule: exit 6 if any item is non-success or carries reason restart-skipped:link-active. Test added beside the sync restart guard case. +- R2: ocx link sync with a restart that was withheld (restart-skipped:link-active) exits 6, matching ocx sync --restart-children (040); the item state stays succeeded because the sync ran. CLI exit rule: exit 6 if any item is non-success or carries reason restart-skipped:link-active. Test added beside the sync restart guard case. (Revision 5: implemented as "a success with a reason or a restart that was not requested is 6; an unknown state is 1", 2.8 item 4.) - R3: the plan-expired branch is defensive (see the comment at the apply step); its unit test drives it with an injected clock, the only activation today. - R6: see the scope note on parseOperation. + + +## wp3 P re-verification (base dev 87e005f93c) + +Re-verified on 2026-11-11 against `dev` 87e005f93c (wp1 #8505, wp2 #8508 and #8509 merged), reading the merged wp1 +modules (`ssh-argv.ts`, `child-gated-ocx.ts`, `child-update-contract.ts`, `remote-windows.ts`, `sync-fanout-classify.ts`, +`child-observations.ts`) and wp2 modules (`child-ops-engine.ts`, `child-observe.ts`, `child-activity.ts`, +`child-catalog-freshness.ts`, `status-projection.ts`, `link-child-ops-routes.ts`, `link-child-ops.ts`, +`link-status-child.ts`, `src/lib/catalog-etag.ts`, `link-relay-sessions.ts`). Changes made to this document: + +1. **Interfaces table** rewritten with the merged signatures and line anchors, including wp2's engine, routes and CLI, + which revision 4 described as planned. +2. **Engine (2.2)** rebuilt on wp2's code: single engine-wide queue (three items across jobs) plus one update place + that does not block items queued behind it; busy and admission-time `unsupported` items write no `lastOperation`; + `"all"` is every link, so `no_eligible_links` stays "links.json is empty"; an all-held multi-link job finishes busy + with 202; per-job `ChildOpsEnv` (retention uses the job's own clock); `link_not_home_initiated` only outside + `observe`; wp2's option names `jobId` and `concurrency` and its log-by-error-name rule kept; + `sharedChildOpsEngine()` plus `resetSharedChildOpsEngineForTests()` for the route tests. +3. **Runners (2.3):** `pinnedSync` passes `{ expectSelectedKind: restart !== "none" }` to `classifySyncResult` + (without it the ocx-child restart outcome could never be detected); `runObserveItem` keeps wp2's + `failed/observe-error` for a throwing observe. +4. **Routes (2.4, 2.5):** kept wp2's auth-first order, `409 runtime_role_client`, `503 link_unavailable` and the + pull flush; `childOpsEngineFor(state)` caches the shared engine (wp2 created one per config-keyed route state); + `childOpsEnvFor` uses the optional listener seams, `readChildObservations()`'s default path and a never-throwing + `writeObservation`; the job GET and the status `activeJob` read the shared engine; `link-route-state.ts` changes its + comment only. +5. **CLI (2.8, 2.9):** extends wp2's `validateChildJob` (keeps `restart.requested`), `linkCheckExitCode` (a success with a + reason or a restart that was not requested exits 6; an unknown state stays 1 as wp2 shipped), JSON output and + conflict messages; `runLinkChildOp` and `runLinkCheck` share one submit-and-wait helper with the 403 loopback + message; `--link-id` is repeatable as in `check` except with a restart; `link.ts` reuses wp2's `verdictExit`. +6. **Carried item (a):** `NO_ELIGIBLE_LINKS_MESSAGE`, the route message, the `link check` capability detail and the + skills page say "This Home has no links" (2.4, 2.8, 2.10, Section 6). +7. **Carried item (b):** per-op item budgets (observe 120 s, sync and restart-codex 420 s, update 2 100 s) with the + deadline moved forward while any item is `queued`, under a 3-hour cap (2.8 item 5); tests in Section 5. +8. **Carried item (c):** Section 10 "Legacy activity" and Section 6 document that a legacy key admission (a Child + before 2.69, `relay-auth/2` in src/link/compatibility.ts:25) counts as activity only from its response headers. +9. **Carried item (d):** the Windows branch of `remoteGatedOcxArgv` refuses any `update` argv without `--plan` (or with + `--yes`); tests and a Section 7 row added. +10. **A4:** stale anchors fixed (ssh-argv.ts:152-200 and :360-419, bin/ocx.mjs:1351-1393 and :109-111, + orchestrated-entry.ts:224-233, child-update-contract.ts:129); the Bun-global claim no longer cites + serving-runtimes.ts; `find_launcher_node` and `launcher=` are placed at the top of `remoteGatedScript`; framing, + refusal and classifier anchors added. +11. **File map, registration and docs:** `childSkipHint` rows made unconditional (wp1 left it at + src/cli/sync-fanout.ts:13-23); `link-route-state.ts` added; every anchor refreshed (link.ts, link-routes.ts, + capabilities-access-remote.ts, registry.ts, skills pages, docs-site guide and lifecycle reference, structure); + `layout.json` is 1999 lines, so registration stays in `layout-clients.json` after its :191 entry; the manifest + needs no change because wp2 already owns both files. +12. **Kept unchanged:** canonical names (000 table updated only for the engine, routes and CLI rows), the test + registration rule, no `src/update/*` change, the C4 class and the explicit security review before merge, D12/D13 + runner logic, A2/M8 restart guards, M9 Child clock, H4 Desktop-before-Windows, H5 tolerance, and every Revision 3 + and 4 decision. Deviations 9-13 record the Revision 5 changes. + + +## Revision 6 (wp3 plan audit, base 87e005f93c) + +Audit verdict GO-WITH-FIXES (blockers 5; F5 fix-or-waive). All findings accepted; binding on the implementation: + +1. **F1 (High).** `tests/clients/link-child-observe.test.ts` is MODIFY: its seven `createChildOpsEngine({...})` calls move to the + process-wide engine with a per-job env (or the test reset function); refusal assertions use the new shape including + `linkId`; the test env's `writeObservation` is a guarded writer (logs and swallows), so the cache-write test still sees two + warnings and a `succeeded` item. `runObserveItem` and every runner call `env.writeObservation` only through that + never-throwing contract. Line ~1280's claim that engine cases live in the routes file is corrected: they stay in + `link-child-observe.test.ts` (observe) and a new `link-child-ops-runners.test.ts` (sync/restart/update), registered per rule. +2. **F2 (Medium).** The second, restarting sync maps to `restart.outcome: "not-run"` only when the result is `skipped` or a + pre-selection refusal (exit 121/125/127 with no selection marker). Any other non-synced result after selection + (`ssh-failed` exit 255, `child-sync-failed`, timeout) reports `restart.outcome: "requested"` and ends the item + `uncertain` with reason `restart-result-unknown`. Runner test: exit 255 on the second sync. +3. **F3 (Medium).** The CLI maps a `400 invalid_body` answer to a non-observe op (a Home that has wp2 but not wp3) to + `OLD_HOME_MESSAGE` and exit 4, like the 404 case. Test for update and sync. +4. **F4 (Medium).** `find_launcher_node` runs `node --version` under the same 10 s watchdog pattern as `try_ocx`; a stuck or + failing Node means `launcher: unavailable` in the report and never fails the plan probe. The capability text and docs say + that observe on an npm/pnpm Child also probes Node, read-only. +5. **F5 (Medium), fixed.** `ocx link update` requires `--yes` (single link or `--all`), like `restart-codex`: an update stops the + Child runtime and ends relayed turns. Missing `--yes` is a usage error (exit 2). The API route is unchanged (the GUI asks in + its confirmation dialog, 060). Capability, registry, skill pages and docs updated. +6. **F6 (Low/Medium).** `tests/server/link-child-ops-routes.test.ts` is MODIFY: the `{op:"sync"}` → 400 assertion (`:128`) now + expects 202 with a job; route tests drive operations through the harness's SSH `gate` (real runners, fake SSH), not stub + runners. Acceptance criterion 19 lists the wp2 assertions that change. +7. **F7 (Low).** `tests/clients/link-gated-ocx-argv.test.ts` literals (`:243`, `:270-273`) gain `launcher`; `assertGated` checks + version, lacks marker, args, then launcher, so the existing message assertion holds. +8. **F8 (Low).** The apply argv is built before the apply `try`; a builder error ends the item `failed/ssh-argument-invalid` + without writing `pendingUpdateRecovery`. +9. **F9 (Low).** `ChildOpsEnv.writeObservation` carries a doc comment on the type: it never throws (logs and drops). +10. **F10 (Low).** DELETE /api/link/{id} re-checks `engine.activeJob(linkId)` immediately before the store write, after the + awaited body read and remote cleanup; a job that took the link in that gap makes DELETE answer 409. + From 69a641acd79ab0847d4d5118095b7cfb261f136b Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:07:41 +0900 Subject: [PATCH 02/18] feat(link): route npm and pnpm Child applies through their Node launcher (A4) --- src/link/child-gated-ocx.ts | 40 +++- src/link/child-update-contract.ts | 15 +- src/link/ssh-argv.ts | 46 ++++- .../link-child-update-contract.test.ts | 22 ++ .../link-child-update-launcher.test.ts | 190 ++++++++++++++++++ tests/clients/link-gated-ocx-argv.test.ts | 53 +++-- 6 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 tests/clients/link-child-update-launcher.test.ts diff --git a/src/link/child-gated-ocx.ts b/src/link/child-gated-ocx.ts index e4b3d30b562..1becfee27e5 100644 --- a/src/link/child-gated-ocx.ts +++ b/src/link/child-gated-ocx.ts @@ -4,28 +4,32 @@ import { classifyChildUpdatePlan, mapUpdateApplyExit, parseChildUpdatePlan, PLAN type UpdateApplyVerdict } from "./child-update-contract"; export type SshResult = { code: number; stdout: string; stderr: string }; -export type GatedRefusal = "child-ocx-lacks-expect-link" | "child-ocx-lacks-orchestrated-update" | "child-ocx-version-unknown" | "child-ocx-ephemeral" | "child-gate-failed"; +export type GatedRefusal = "child-ocx-lacks-expect-link" | "child-ocx-lacks-orchestrated-update" | "child-ocx-version-unknown" | "child-ocx-ephemeral" + | "child-node-unavailable" | "child-gate-failed"; export type GatedFraming = | { kind: "transport"; reason: "ssh-failed" | "child-ocx-not-found" | "child-ocx-not-contained" | "child-platform-unsupported" } | { kind: "refused"; reason: GatedRefusal } - | { kind: "ran"; code: number; selectedVersion: string; entry: "pair" | "binary" | null; childNowMs: number | null; payload: string; lastStderrLine: string | undefined } + | { kind: "ran"; code: number; selectedVersion: string; entry: "pair" | "binary" | null; childNowMs: number | null; + launcher: "available" | "unavailable" | "exec" | null; payload: string; lastStderrLine: string | undefined } | { kind: "invalid" }; function assertHomeVersion(version: string): void { if (version.length > 128 || !parseStrictSemver(version)) throw new LinkSshArgumentError("invalid Home version"); } -const UPDATE_GATE = { minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: true, reportClock: false } as const; +const UPDATE_GATE = { minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: true, reportClock: false, + launcher: "none" } as const; /** * Never mutates an install: below 2.97.0 the gate exits 121 before update runs, and update --plan only computes. * Not free of side effects on every Child: without a census pair (an npm/pnpm Child whose service never recorded one) * the script execs the ocx shim, so the Node launcher's ordinary bootstrap runs first (sync-guard bookkeeping, Bun * resolution; the Desktop handoff is disabled), exactly as for every pinned sync already sent to that Child. The probe - * prints the Child's clock so the apply deadline can be set in the Child's time. + * prints the Child's clock so the apply deadline can be set in the Child's time, and (A4) reports whether a Node that + * could run the package launcher answers --version; it always plans through the served entry, whatever that report says. */ export function remoteUpdatePlanArgv(homeVersion: string, platform: "posix" | "windows"): string[] { assertHomeVersion(homeVersion); - return remoteGatedOcxArgv({ ...UPDATE_GATE, reportClock: true, args: ["update", "--plan", "--json", "--version", homeVersion] }, platform); + return remoteGatedOcxArgv({ ...UPDATE_GATE, reportClock: true, launcher: "report", args: ["update", "--plan", "--json", "--version", homeVersion] }, platform); } /** @@ -33,19 +37,24 @@ export function remoteUpdatePlanArgv(homeVersion: string, platform: "posix" | "w * A token starting with "-" cannot pass the Child's --expect-plan parser (orchestrated-arguments.mjs refuses a value * that looks like a flag), so it is omitted; the apply stays pinned by --version, bounded by --not-after, serialized * by the Child's update mutex and refused on downgrade. Only the plan-consistency check is lost for that token. + * viaLauncher (A4): the caller sets it only for a plan whose installer is npm or pnpm; the apply then runs through + * /bin/ocx.mjs of the served package, or exits 121 child-node-unavailable before anything runs. */ -export function remoteUpdateApplyArgv(input: { homeVersion: string; planToken: string; notAfter: number }, platform: "posix" | "windows"): string[] { +export function remoteUpdateApplyArgv(input: { homeVersion: string; planToken: string; notAfter: number; viaLauncher: boolean }, + platform: "posix" | "windows"): string[] { if (platform === "windows") throw new LinkSshArgumentError("unsupported:windows-apply"); assertHomeVersion(input.homeVersion); if (!PLAN_TOKEN_PATTERN.test(input.planToken)) throw new LinkSshArgumentError("invalid plan token"); if (!Number.isSafeInteger(input.notAfter) || input.notAfter <= 0) throw new LinkSshArgumentError("invalid deadline"); + if (typeof input.viaLauncher !== "boolean") throw new LinkSshArgumentError("invalid launcher route"); const expectPlan = input.planToken.startsWith("-") ? [] : ["--expect-plan", input.planToken]; - return remoteGatedOcxArgv({ ...UPDATE_GATE, args: ["update", "--yes", "--json", "--version", input.homeVersion, + return remoteGatedOcxArgv({ ...UPDATE_GATE, launcher: input.viaLauncher ? "apply" : "none", args: ["update", "--yes", "--json", "--version", input.homeVersion, ...expectPlan, "--not-after", String(input.notAfter)] }, platform); } -const REFUSAL = /^ocx-fanout:(child-ocx-lacks-expect-link|child-ocx-lacks-orchestrated-update|child-ocx-version-unknown|child-ocx-ephemeral)$/; +const REFUSAL = /^ocx-fanout:(child-ocx-lacks-expect-link|child-ocx-lacks-orchestrated-update|child-ocx-version-unknown|child-ocx-ephemeral|child-node-unavailable)$/; const CHILD_NOW = "ocx-fanout:child-now:"; +const LAUNCHER = "ocx-fanout:launcher:"; /** * The framing classifySyncResult applies, generalised to every gated command. A run is recognised by exactly one @@ -73,8 +82,12 @@ export function readGatedFraming(result: SshResult): GatedFraming { // Capture any value and validate it afterwards, so a malformed clock makes the framing invalid instead of absent. const clocks = lines.filter(line => line.startsWith(CHILD_NOW)).map(line => line.slice(CHILD_NOW.length)); if (clocks.length > 1 || (clocks.length === 1 && (!/^[0-9]{10,16}$/.test(clocks[0]!) || !Number.isSafeInteger(Number(clocks[0]))))) return { kind: "invalid" }; + // A4: at most one launcher line, from a closed set; anything else breaks the framing like a malformed clock does. + const launchers = lines.filter(line => line.startsWith(LAUNCHER)).map(line => line.slice(LAUNCHER.length)); + if (launchers.length > 1 || (launchers.length === 1 && !/^(available|unavailable|exec)$/.test(launchers[0]!))) return { kind: "invalid" }; return { kind: "ran", code, selectedVersion: selected[0]![1]!, entry: entries.length ? (entries[0]!.slice("ocx-fanout:entry:".length) as "pair" | "binary") : null, - childNowMs: clocks.length ? Number(clocks[0]) : null, payload: out.slice(delimiters[0]! + 1).join("\n").trim(), lastStderrLine: last }; + childNowMs: clocks.length ? Number(clocks[0]) : null, launcher: launchers.length ? launchers[0] as "available" | "unavailable" | "exec" : null, + payload: out.slice(delimiters[0]! + 1).join("\n").trim(), lastStderrLine: last }; } export type PlanProbeVerdict = @@ -99,7 +112,9 @@ export function classifyUpdatePlanProbe(result: SshResult, ctx: { homeVersion: s if (!parsed.ok) return { kind: "failed", reason: parsed.reason }; const entry = framing.entry ?? (ctx.platform === "windows" ? "binary" : null); return { kind: "plan", selectedVersion: framing.selectedVersion, childNowMs: framing.childNowMs, - classification: classifyChildUpdatePlan(parsed.value, { homeVersion: ctx.homeVersion, desktopUpdateRequest: ctx.desktopUpdateRequest, entry }) }; + // A plan never execs the launcher; an exec line here is treated as no report. + classification: classifyChildUpdatePlan(parsed.value, { homeVersion: ctx.homeVersion, desktopUpdateRequest: ctx.desktopUpdateRequest, entry, + launcher: framing.launcher === "exec" ? null : framing.launcher }) }; } export function classifyUpdateApply(result: SshResult): UpdateApplyVerdict { @@ -109,7 +124,10 @@ export function classifyUpdateApply(result: SshResult): UpdateApplyVerdict { : { state: "failed", reason: framing.reason, suppressTarget: false, reobserve: false }; if (framing.kind === "refused") return framing.reason === "child-ocx-ephemeral" ? { state: "unsupported", reason: "ephemeral", suppressTarget: false, reobserve: false } - : { state: "owner-action", reason: framing.reason === "child-gate-failed" ? "child-gate-failed" : "legacy-ocx", suppressTarget: false, reobserve: false }; + // A4: the npm/pnpm launcher route found no Node on the Child; nothing ran. + : framing.reason === "child-node-unavailable" + ? { state: "owner-action", reason: "launcher-node-missing", suppressTarget: false, reobserve: false } + : { state: "owner-action", reason: framing.reason === "child-gate-failed" ? "child-gate-failed" : "legacy-ocx", suppressTarget: false, reobserve: false }; // Framing broken after a command may have run: never assume it did not. if (framing.kind === "invalid") return { state: "uncertain", reason: "child-output-invalid", suppressTarget: false, reobserve: true }; return mapUpdateApplyExit(framing.code, framing.payload); diff --git a/src/link/child-update-contract.ts b/src/link/child-update-contract.ts index c3a71433de8..bff20058193 100644 --- a/src/link/child-update-contract.ts +++ b/src/link/child-update-contract.ts @@ -114,8 +114,12 @@ export function parseChildUpdateResult(payload: string): ContractParse ({ installKind, updateRoute, updateReason, installedVersion: plan.currentVersion === "unknown" ? null : plan.currentVersion, targetVersion: plan.targetVersion, runtimeOwner: plan.runtimeOwner, planToken: plan.planToken, updateInFlight: plan.updateInFlight, @@ -126,7 +130,12 @@ export function classifyChildUpdatePlan(plan: ChildUpdatePlan, ctx: { homeVersio if (plan.installer === "unknown" && GOAT_PATHS.some(marker => path.includes(marker))) return row("goat", "owner-action", "goat"); if (plan.reason === "up-to-date") return row(installer, "none", "up-to-date"); if (plan.canApply && plan.targetVersion !== ctx.homeVersion) return row(installer, "none", "target-mismatch"); - if (plan.canApply && (plan.installer === "npm" || plan.installer === "pnpm") && ctx.entry === "pair") return row(installer, "owner-action", "launcher-bypassed"); + if (plan.canApply && (plan.installer === "npm" || plan.installer === "pnpm") && ctx.entry === "pair") { + // A4: the apply goes through /bin/ocx.mjs, so it needs the Node the plan probe found. + if (ctx.launcher === "available") return row(installer, "orchestrated", null); + if (ctx.launcher === "unavailable") return row(installer, "owner-action", "launcher-node-missing"); + return row(installer, "owner-action", "launcher-bypassed"); + } if (plan.canApply) return row(installer, "orchestrated", null); if (plan.reason === "desktop-app-owned") return row("desktop-app", ctx.desktopUpdateRequest ? "desktop-request" : "owner-action", "desktop-app-owned"); if (plan.reason === "mise-managed") return row("mise", "owner-action", "mise-managed"); diff --git a/src/link/ssh-argv.ts b/src/link/ssh-argv.ts index 4bd4f07988b..aecfae92c35 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -337,6 +337,13 @@ export interface GatedOcxCommand { /** With acceptEphemeral, print ocx-fanout:selected-kind:ocx-child when the selected candidate is ocx-child. * Set only by remotePinnedSyncArgv when a restart is requested (ocx-child ignores the restart flags). */ reportSelectedKind?: boolean; + /** + * A4: what the script does with the Node launcher of a package install whose census pair sits under node_modules. + * "none": nothing. "report": print ocx-fanout:launcher:available|unavailable (the plan probe; never exits for it). + * "apply": exec /bin/ocx.mjs with the arguments, or exit 121 child-node-unavailable when no Node answers. + * A closed union spliced as a literal; only the Home's parsed plan (installer npm or pnpm) chooses "apply". + */ + launcher: "none" | "report" | "apply"; args: readonly string[]; } export const GATED_VERSION_PARTS: Readonly> = { @@ -349,6 +356,7 @@ function assertGated(cmd: GatedOcxCommand): readonly [string, string, string] { const parts = Object.hasOwn(GATED_VERSION_PARTS, cmd.minVersion) ? GATED_VERSION_PARTS[cmd.minVersion] : undefined; if (!parts || !LACKS_MARKERS.has(cmd.lacksMarker)) throw new LinkSshArgumentError("invalid gated ocx command"); if (cmd.args.length === 0 || cmd.args.some(arg => arg.includes("\0"))) throw new LinkSshArgumentError("invalid gated ocx arguments"); + if (cmd.launcher !== "none" && cmd.launcher !== "report" && cmd.launcher !== "apply") throw new LinkSshArgumentError("invalid gated ocx launcher"); return parts; } @@ -356,6 +364,8 @@ function assertGated(cmd: GatedOcxCommand): readonly [string, string, string] { * The pinned script for one gate: select a candidate, read its version inside the remote script, refuse below the * minimum, then exec it with "$@". OCX_NO_DESKTOP_HANDOFF=1 makes a package launcher answer --version for itself and * run the command itself, so a Desktop handoff can never pass the gate on behalf of an older launcher. + * With launcher "report" or "apply" and a census pair under node_modules that ships bin/ocx.mjs, the script looks for a + * Node that answers --version within 10 s (the npm prefix's bin/node first, then PATH) before the temp dir is removed. */ function remoteGatedScript(cmd: GatedOcxCommand): string { const [major, minor, patch] = assertGated(cmd); @@ -376,6 +386,23 @@ umask 077 dir=$(mktemp -d "\${TMPDIR:-/tmp}/ocx-fanout.XXXXXXXX") || exit 127 tmp=$dir/version; ready=$dir/ready trap 'rm -rf "$dir"' EXIT HUP INT TERM +launcher=${cmd.launcher} +node_ok() { + ( ulimit -c 0; exec "$1" --version ) >/dev/null 2>&1 & np=$! + ( sleep 10 & timer=$!; trap 'kill "$timer" 2>/dev/null; wait "$timer" 2>/dev/null; exit' TERM HUP INT; : > "$ready"; wait "$timer" 2>/dev/null; kill -9 "$np" 2>/dev/null ) >/dev/null 2>&1 & nwd=$! + while [ ! -f "$ready" ]; do kill -0 "$nwd" 2>/dev/null || break; sleep 0.01 >/dev/null 2>&1; done + { wait "$np"; nstatus=$?; } 2>/dev/null + kill "$nwd" 2>/dev/null; wait "$nwd" 2>/dev/null; rm -f "$ready" + [ "$nstatus" -eq 0 ] +} +find_launcher_node() { + node_bin= + case "$1" in */lib/node_modules/@bitkyc08/opencodex) + n=\${1%/lib/node_modules/@bitkyc08/opencodex}/bin/node + if [ -x "$n" ] && node_ok "$n"; then node_bin=$n; return 0; fi;; esac + n=$(command -v node 2>/dev/null) && [ -x "$n" ] && node_ok "$n" && node_bin=$n + return 0 +} try_ocx() { c=$1; shift [ -x "$c" ] || return @@ -409,8 +436,22 @@ try_ocx() { status=$? if [ "$status" -eq 1 ]; then printf '%s\\n' 'ocx-fanout:${cmd.lacksMarker}' >&2; exit 121; fi if [ "$status" -ne 0 ]; then printf '%s\\n' 'ocx-fanout:child-ocx-version-unknown' >&2; exit 121; fi${ephemeral} + root=; [ -n "$ocx_entry" ] && root=\${ocx_entry%/src/cli/index.ts} + case "$root" in */node_modules/*) [ -f "$root/bin/ocx.mjs" ] || root=;; *) root=;; esac + node_bin=; if [ -n "$root" ] && [ "$launcher" != none ]; then find_launcher_node "$root"; fi rm -rf "$dir"; trap - EXIT HUP INT TERM printf 'ocx-fanout:selected:%s\\n' "$version" >&2${kind}${entry}${clock} + if [ -n "$root" ] && [ "$launcher" = report ]; then + if [ -n "$node_bin" ]; then printf '%s\\n' 'ocx-fanout:launcher:available' >&2 + else printf '%s\\n' 'ocx-fanout:launcher:unavailable' >&2; fi + fi + if [ -n "$root" ] && [ "$launcher" = apply ]; then + [ -n "$node_bin" ] || { printf '%s\\n' 'ocx-fanout:child-node-unavailable' >&2; exit 121; } + printf '%s\\n' 'ocx-fanout:launcher:exec' >&2 + printf '%s\\n' 'ocx-fanout:stdout-begin' + unset OCX_UPDATE_ORCHESTRATED_CHILD + exec "$node_bin" "$root/bin/ocx.mjs" "$@" + fi printf '%s\\n' 'ocx-fanout:stdout-begin' exec "$c" \${ocx_entry:+"$ocx_entry"} "$@" } @@ -422,7 +463,8 @@ export function remoteGatedOcxArgv(cmd: GatedOcxCommand, platform: "posix" | "wi const versionParts = assertGated(cmd); if (platform === "windows") { // A dropped channel kills the orchestrating parent with the Job, so no Windows apply is ever built (D20). - if (cmd.args[0] === "update" && cmd.args.includes("--yes")) throw new LinkSshArgumentError("unsupported:windows-apply"); + // Only a plan is ever built for Windows: an update spelled without --plan (or a future mutating flag) is refused. + if (cmd.args[0] === "update" && (!cmd.args.includes("--plan") || cmd.args.includes("--yes"))) throw new LinkSshArgumentError("unsupported:windows-apply"); try { return windowsGatedOcxArgv({ ...cmd, versionParts }); } catch (error) { @@ -444,7 +486,7 @@ export function remotePinnedSyncArgv(linkId: string, platform: "posix" | "window if (opts.restart !== undefined && platform === "windows") throw new LinkSshArgumentError("unsupported:windows-restart"); const restart = opts.restart === undefined ? [] : [RESTART_FLAG[opts.restart]]; return remoteGatedOcxArgv({ minVersion: "2.93.0", lacksMarker: "child-ocx-lacks-expect-link", acceptEphemeral: true, - reportEntry: false, reportClock: false, reportSelectedKind: restart.length > 0, + reportEntry: false, reportClock: false, reportSelectedKind: restart.length > 0, launcher: "none", args: ["sync", "--expect-link", linkId, "--json", ...restart] }, platform); } diff --git a/tests/clients/link-child-update-contract.test.ts b/tests/clients/link-child-update-contract.test.ts index 704b01d11e1..381813372a3 100644 --- a/tests/clients/link-child-update-contract.test.ts +++ b/tests/clients/link-child-update-contract.test.ts @@ -67,6 +67,28 @@ describe("oracle: every plan buildOrchestratedPlan can emit parses", () => { const bun = parsedPlan(buildOrchestratedPlan(base)); expect(classifyChildUpdatePlan(bun, { ...ctx, entry: "pair" })).toMatchObject({ updateRoute: "orchestrated" }); }); + + test("A4 launcher rows for an npm or pnpm pair", () => { + for (const installer of ["npm", "pnpm"] as const) { + const plan = parsedPlan(buildOrchestratedPlan({ ...base, installer })); + expect(classifyChildUpdatePlan(plan, { ...ctx, entry: "pair", launcher: "available" })).toMatchObject({ installKind: installer, updateRoute: "orchestrated", updateReason: null }); + expect(classifyChildUpdatePlan(plan, { ...ctx, entry: "pair", launcher: "unavailable" })).toMatchObject({ installKind: installer, updateRoute: "owner-action", updateReason: "launcher-node-missing" }); + expect(classifyChildUpdatePlan(plan, { ...ctx, entry: "pair", launcher: null })).toMatchObject({ installKind: installer, updateRoute: "owner-action", updateReason: "launcher-bypassed" }); + } + // installer bun ignores the launcher report: it plans and applies on the served Bun entry. + const bun = parsedPlan(buildOrchestratedPlan(base)); + expect(classifyChildUpdatePlan(bun, { ...ctx, entry: "pair", launcher: "unavailable" })).toMatchObject({ installKind: "bun", updateRoute: "orchestrated", updateReason: null }); + }); + + test("the plan probe passes the launcher report into the classifier", () => { + const plan = buildOrchestratedPlan({ ...base, installer: "npm" }); + const probe = (launcher: string) => classifyUpdatePlanProbe({ code: 0, stdout: "ocx-fanout:stdout-begin\n" + JSON.stringify(plan) + "\n", + stderr: "ocx-fanout:selected:2.97.0\nocx-fanout:entry:pair\n" + launcher }, { homeVersion: "2.100.0", desktopUpdateRequest: false }); + expect(probe("ocx-fanout:launcher:available\n")).toMatchObject({ kind: "plan", classification: { updateRoute: "orchestrated" } }); + expect(probe("ocx-fanout:launcher:unavailable\n")).toMatchObject({ kind: "plan", classification: { updateRoute: "owner-action", updateReason: "launcher-node-missing" } }); + expect(probe("ocx-fanout:launcher:exec\n")).toMatchObject({ kind: "plan", classification: { updateRoute: "owner-action", updateReason: "launcher-bypassed" } }); + expect(probe("")).toMatchObject({ kind: "plan", classification: { updateReason: "launcher-bypassed" } }); + }); }); describe("oracle: every result shape parses and maps", () => { diff --git a/tests/clients/link-child-update-launcher.test.ts b/tests/clients/link-child-update-launcher.test.ts new file mode 100644 index 00000000000..a8affccce76 --- /dev/null +++ b/tests/clients/link-child-update-launcher.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { remoteUpdateApplyArgv, remoteUpdatePlanArgv } from "../../src/link/child-gated-ocx"; +import { recordServingRuntime } from "../../src/config/serving-runtimes"; + +// A4: which program receives which arguments when the Home plans and applies an update on a package-installed Child. +// The real gated script runs under sh against stub bun and node executables; nothing here installs anything. +const TOKEN = "AbCdEfGhIjKlMnOpQrStUvWxYz012345_-"; +const NOT_AFTER = 1_900_000_000_000; +const posix = test.skipIf(process.platform === "win32"); +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +/** The shipped script with the machine's own install locations swapped for missing paths. */ +function isolated(argv: string[]): string[] { + const copy = [...argv]; + copy[2] = copy[2]!.replace('export PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"', 'export PATH="$PATH"') + .replace('"/Applications/OpenCodex.app/Contents/MacOS/ocx"', '"$HOME/missing-app"').replace('"/usr/bin/ocx"', '"$HOME/missing-system"'); + return copy; +} + +function stubNode(path: string): void { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, "#!/bin/sh\nif [ \"$1\" = --version ]; then echo v22.11.0; exit 0; fi\n" + + "printf '%s\\n' \"$*\" >> \"$HOME/node.log\"\nprintf '%s\\n' \"${OCX_UPDATE_ORCHESTRATED_CHILD-unset}\" >> \"$HOME/node.env\"\nprintf 'ran\\n'\n", { mode: 0o755 }); +} + +/** + * A package install at root, served through the census pair [bun, root/src/cli/index.ts]. The stub bun answers the + * gate's version probe with version and logs every other call; it is outside root so only the census selects it. + */ +function install(root: string, options: { version?: string; launcherFile?: boolean } = {}) { + const home = mkdtempSync(join(tmpdir(), "ocx-launcher-")); dirs.push(home); + const bin = join(home, "bin"); mkdirSync(bin); + for (const command of ["sh", "uname", "mktemp", "sleep", "cat", "awk", "dd", "wc", "rm", "date"]) { + const executable = Bun.which(command); + if (!executable) throw new Error("Required test tool not found on PATH: " + command); + symlinkSync(executable, join(bin, command)); + } + const pkg = join(home, root); + const entry = join(pkg, "src", "cli", "index.ts"); + mkdirSync(join(pkg, "src", "cli"), { recursive: true }); + writeFileSync(entry, "// entry\n"); + if (options.launcherFile !== false) { mkdirSync(join(pkg, "bin"), { recursive: true }); writeFileSync(join(pkg, "bin", "ocx.mjs"), "// launcher\n"); } + const bun = join(home, "runtime", "bun"); + mkdirSync(join(home, "runtime")); + writeFileSync(bun, "#!/bin/sh\nif [ \"$2\" = --version ]; then printf 'opencodex " + (options.version ?? "2.98.0") + "\\n'; exit 0; fi\n" + + "printf '%s\\n' \"$*\" >> \"$HOME/bun.log\"\nprintf 'ran\\n'\n", { mode: 0o755 }); + recordServingRuntime({ command: [bun, entry], version: options.version ?? "2.98.0", servedAt: "2026-11-09T00:00:00.000Z" }, join(home, "ocx-home")); + return { home, bin, pkg, entry }; +} + +function run(fixture: { home: string; bin: string }, argv: string[], extraPath: string[] = [], env: Record = {}) { + const { home, bin } = fixture; + const proc = Bun.spawnSync(isolated(argv), { env: { HOME: home, OPENCODEX_HOME: join(home, "ocx-home"), CODEX_HOME: join(home, "codex-home"), TMPDIR: home, + PATH: [...extraPath, bin].join(":"), ...env }, stdout: "pipe", stderr: "pipe" }); + const read = (name: string) => existsSync(join(home, name)) ? readFileSync(join(home, name), "utf8") : ""; + return { code: proc.exitCode, stdout: proc.stdout.toString(), stderr: proc.stderr.toString(), bun: read("bun.log"), node: read("node.log"), + nodeEnv: read("node.env"), remaining: readdirSync(home).filter(name => name.startsWith("ocx-fanout.")) }; +} + +const plan = () => remoteUpdatePlanArgv("2.100.0", "posix"); +const apply = (viaLauncher: boolean) => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: NOT_AFTER, viaLauncher }, "posix"); +const APPLY_ARGS = "update --yes --json --version 2.100.0 --expect-plan " + TOKEN + " --not-after " + NOT_AFTER; +const NPM_ROOT = join("prefix", "lib", "node_modules", "@bitkyc08", "opencodex"); + +describe("A4 launcher route on fake package installs", () => { + posix("npm global: apply runs /bin/node /bin/ocx.mjs", () => { + const fixture = install(NPM_ROOT); + stubNode(join(fixture.home, "prefix", "bin", "node")); + const result = run(fixture, apply(true)); + expect(result.code).toBe(0); + expect(result.node).toBe(join(fixture.pkg, "bin", "ocx.mjs") + " " + APPLY_ARGS + "\n"); + expect(result.bun).not.toContain("update"); + expect(result.stderr.trimEnd().split("\n")).toEqual(["ocx-fanout:selected:2.98.0", "ocx-fanout:entry:pair", "ocx-fanout:launcher:exec"]); + expect(result.stdout).toBe("ocx-fanout:stdout-begin\nran\n"); + expect(result.remaining).toEqual([]); + }); + + posix("npm global: plan stays on the pair and reports the launcher", () => { + const fixture = install(NPM_ROOT); + stubNode(join(fixture.home, "prefix", "bin", "node")); + const result = run(fixture, plan()); + expect(result.code).toBe(0); + expect(result.bun).toBe(fixture.entry + " update --plan --json --version 2.100.0\n"); + expect(result.stderr).toContain("ocx-fanout:launcher:available\n"); + expect(result.node).toBe(""); + expect(result.remaining).toEqual([]); + }); + + posix("the plan never exits on a missing Node", () => { + const fixture = install(NPM_ROOT); + const result = run(fixture, plan()); + expect(result.code).toBe(0); + expect(result.bun).toBe(fixture.entry + " update --plan --json --version 2.100.0\n"); + expect(result.stderr).toContain("ocx-fanout:launcher:unavailable\n"); + expect(result.stderr).not.toContain("child-node-unavailable"); + }); + + posix("a Node that does not answer --version counts as missing", () => { + const fixture = install(NPM_ROOT); + const broken = join(fixture.home, "prefix", "bin", "node"); + mkdirSync(join(broken, ".."), { recursive: true }); + writeFileSync(broken, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/node.log\"\nexit 3\n", { mode: 0o755 }); + expect(run(fixture, plan()).stderr).toContain("ocx-fanout:launcher:unavailable\n"); + const applied = run(fixture, apply(true)); + expect(applied.code).toBe(121); + expect(applied.node).toBe("--version\n--version\n"); + }); + + posix("Bun global: plan and apply stay on the served Bun entry", () => { + const fixture = install(join(".bun", "install", "global", "node_modules", "@bitkyc08", "opencodex")); + const nodeDir = join(fixture.home, "path-node"); + stubNode(join(nodeDir, "node")); + const planned = run(fixture, plan(), [nodeDir]); + expect(planned.code).toBe(0); + const applied = run(fixture, apply(false), [nodeDir]); + expect(applied.code).toBe(0); + expect(applied.bun).toBe(fixture.entry + " update --plan --json --version 2.100.0\n" + fixture.entry + " " + APPLY_ARGS + "\n"); + expect(applied.node).toBe(""); + expect(applied.stderr).not.toContain("launcher:exec"); + rmSync(join(fixture.home, "bun.log")); + rmSync(nodeDir, { recursive: true }); + expect(run(fixture, plan()).code).toBe(0); + const noNode = run(fixture, apply(false)); + expect(noNode.code).toBe(0); + expect(noNode.bun).toBe(fixture.entry + " update --plan --json --version 2.100.0\n" + fixture.entry + " " + APPLY_ARGS + "\n"); + }); + + posix("pnpm layout uses the Node on PATH", () => { + const fixture = install(join("pnpm", "global", "5", "node_modules", "@bitkyc08", "opencodex")); + const nodeDir = join(fixture.home, "path-node"); + stubNode(join(nodeDir, "node")); + const result = run(fixture, apply(true), [nodeDir]); + expect(result.code).toBe(0); + expect(result.node).toBe(join(fixture.pkg, "bin", "ocx.mjs") + " " + APPLY_ARGS + "\n"); + expect(result.bun).toBe(""); + }); + + posix("apply without Node exits 121 child-node-unavailable and nothing ran", () => { + const fixture = install(NPM_ROOT); + const result = run(fixture, apply(true)); + expect(result.code).toBe(121); + expect(result.stderr.trimEnd().split("\n").at(-1)).toBe("ocx-fanout:child-node-unavailable"); + expect(result.stdout).toBe(""); + expect(result.bun).toBe(""); + expect(result.node).toBe(""); + expect(result.remaining).toEqual([]); + }); + + posix("OCX_UPDATE_ORCHESTRATED_CHILD is cleared before the launcher runs", () => { + const fixture = install(NPM_ROOT); + stubNode(join(fixture.home, "prefix", "bin", "node")); + const result = run(fixture, apply(true), [], { OCX_UPDATE_ORCHESTRATED_CHILD: "1" }); + expect(result.code).toBe(0); + expect(result.nodeEnv).toBe("unset\n"); + }); + + posix("a 2.96 pair never reaches either entry", () => { + const fixture = install(NPM_ROOT, { version: "2.96.0" }); + stubNode(join(fixture.home, "prefix", "bin", "node")); + for (const argv of [plan(), apply(true), apply(false)]) { + const result = run(fixture, argv); + expect(result.code).toBe(121); + expect(result.stderr).toBe("ocx-fanout:child-ocx-lacks-orchestrated-update\n"); + expect(result.bun).toBe(""); + expect(result.node).toBe(""); + } + }); + + posix("a source checkout pair keeps the Bun entry even when the apply asks for the launcher", () => { + const fixture = install(join("src-checkout", "opencodex")); + stubNode(join(fixture.home, "path-node", "node")); + const result = run(fixture, apply(true), [join(fixture.home, "path-node")]); + expect(result.code).toBe(0); + expect(result.bun).toBe(fixture.entry + " " + APPLY_ARGS + "\n"); + expect(result.node).toBe(""); + }); + + posix("a package root without bin/ocx.mjs keeps the Bun entry", () => { + const fixture = install(NPM_ROOT, { launcherFile: false }); + stubNode(join(fixture.home, "prefix", "bin", "node")); + const result = run(fixture, apply(true)); + expect(result.code).toBe(0); + expect(result.bun).toBe(fixture.entry + " " + APPLY_ARGS + "\n"); + expect(result.node).toBe(""); + }); +}); diff --git a/tests/clients/link-gated-ocx-argv.test.ts b/tests/clients/link-gated-ocx-argv.test.ts index 1d952997d3b..192c4187e74 100644 --- a/tests/clients/link-gated-ocx-argv.test.ts +++ b/tests/clients/link-gated-ocx-argv.test.ts @@ -65,7 +65,7 @@ function inflated(argv: string[]): string { return inflateRawSync(Buffer.from(Array.from(packed, ch => ch.charCodeAt(0) - 256))).toString("utf8"); } const planArgv = () => remoteUpdatePlanArgv("2.100.0", "posix"); -const applyArgv = (planToken = TOKEN) => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken, notAfter: 1_900_000_000_000 }, "posix"); +const applyArgv = (planToken = TOKEN) => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken, notAfter: 1_900_000_000_000, viaLauncher: false }, "posix"); describe("update gate on fake executables", () => { posix("update gate never sends update to 2.96.0", () => { @@ -241,10 +241,18 @@ describe("Windows scripts", () => { expect(() => remotePinnedSyncArgv(LINK, "windows", { restart: "codex" })).toThrow(new LinkSshArgumentError("unsupported:windows-restart")); expect(() => remotePinnedSyncArgv(LINK, "windows", { restart: "app-server" })).toThrow(new LinkSshArgumentError("unsupported:windows-restart")); expect(() => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: true, reportClock: false, - args: ["update", "--yes", "--json", "--version", "2.100.0"] }, "windows")).toThrow(new LinkSshArgumentError("unsupported:windows-apply")); + launcher: "none", args: ["update", "--yes", "--json", "--version", "2.100.0"] }, "windows")).toThrow(new LinkSshArgumentError("unsupported:windows-apply")); expect(remotePinnedSyncArgv(LINK, "windows")[0]).toBe("powershell.exe"); }); + test("a Windows update without --plan is refused whatever its other flags (Revision 5, item d)", () => { + const gate = { minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: true, reportClock: false, launcher: "none" } as const; + expect(() => remoteGatedOcxArgv({ ...gate, args: ["update", "--json", "--version", "2.100.0"] }, "windows")).toThrow(new LinkSshArgumentError("unsupported:windows-apply")); + expect(() => remoteGatedOcxArgv({ ...gate, args: ["update", "--plan", "--yes", "--json"] }, "windows")).toThrow(new LinkSshArgumentError("unsupported:windows-apply")); + expect(remoteUpdatePlanArgv("2.100.0", "windows")[0]).toBe("powershell.exe"); + expect(remoteGatedOcxArgv({ ...gate, args: ["update", "--json", "--version", "2.100.0"] }, "posix")[0]).toBe("sh"); + }); + test("the Windows sync script keeps its gate and also disables the handoff", () => { const script = inflated(remotePinnedSyncArgv(LINK, "windows")); expect(script).toContain("$b=@('2','93','0')"); @@ -254,7 +262,8 @@ describe("Windows scripts", () => { }); test("Windows apply is refused in the builder", () => { - expect(() => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 1 }, "windows")).toThrow(new LinkSshArgumentError("unsupported:windows-apply")); + expect(() => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 1, viaLauncher: false }, "windows")).toThrow(new LinkSshArgumentError("unsupported:windows-apply")); + expect(() => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 1, viaLauncher: true }, "windows")).toThrow(new LinkSshArgumentError("unsupported:windows-apply")); }); }); @@ -264,21 +273,29 @@ describe("builders validate input", () => { ["bad restart", () => remotePinnedSyncArgv(LINK, "posix", { restart: "all" as never })], ["bad Home version", () => remoteUpdatePlanArgv("2.100", "posix")], ["long Home version", () => remoteUpdatePlanArgv("2.100.0-" + "a".repeat(130), "posix")], - ["bad plan token", () => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: "short", notAfter: 1 }, "posix")], - ["bad deadline", () => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 1.5 }, "posix")], - ["zero deadline", () => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 0 }, "posix")], - ["NUL argument", () => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false, args: ["a\0b"] })], - ["unknown minimum", () => remoteGatedOcxArgv({ minVersion: "2.0.0" as never, lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false, args: ["x"] })], - ["unknown marker", () => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "x'; rm -rf /" as never, acceptEphemeral: false, reportEntry: false, reportClock: false, args: ["x"] })], - ["no arguments", () => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false, args: [] })], + ["bad plan token", () => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: "short", notAfter: 1, viaLauncher: false }, "posix")], + ["bad deadline", () => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 1.5, viaLauncher: false }, "posix")], + ["zero deadline", () => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 0, viaLauncher: false }, "posix")], + ["non-boolean launcher route", () => remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 1, viaLauncher: "yes" as never }, "posix")], + ["NUL argument", () => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false, launcher: "none", args: ["a\0b"] })], + ["unknown minimum", () => remoteGatedOcxArgv({ minVersion: "2.0.0" as never, lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false, launcher: "none", args: ["x"] })], + ["unknown marker", () => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "x'; rm -rf /" as never, acceptEphemeral: false, reportEntry: false, reportClock: false, launcher: "none", args: ["x"] })], + ["no arguments", () => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false, launcher: "none", args: [] })], + ["unknown launcher", () => remoteGatedOcxArgv({ minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false, launcher: "x; rm -rf /" as never, args: ["x"] })], ])("%s throws LinkSshArgumentError", (_name, build) => { expect(build).toThrow(LinkSshArgumentError); }); + test("an invalid launcher is reported after the version, marker and argument checks", () => { + const bad = { minVersion: "2.97.0", lacksMarker: "child-ocx-lacks-orchestrated-update", acceptEphemeral: false, reportEntry: false, reportClock: false } as const; + expect(() => remoteGatedOcxArgv({ ...bad, launcher: "x" as never, args: ["x"] })).toThrow(new LinkSshArgumentError("invalid gated ocx launcher")); + expect(() => remoteGatedOcxArgv({ ...bad, launcher: "x" as never, args: [] })).toThrow(new LinkSshArgumentError("invalid gated ocx arguments")); + }); + test("a plan token starting with - is not passed to --expect-plan", () => { - const dashed = remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: "-" + TOKEN, notAfter: 5 }, "posix"); + const dashed = remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: "-" + TOKEN, notAfter: 5, viaLauncher: false }, "posix"); expect(dashed.slice(4)).toEqual(["update", "--yes", "--json", "--version", "2.100.0", "--not-after", "5"]); - const plain = remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 5 }, "posix"); + const plain = remoteUpdateApplyArgv({ homeVersion: "2.100.0", planToken: TOKEN, notAfter: 5, viaLauncher: false }, "posix"); expect(plain.slice(4)).toEqual(["update", "--yes", "--json", "--version", "2.100.0", "--expect-plan", TOKEN, "--not-after", "5"]); }); }); @@ -294,6 +311,7 @@ describe("framing", () => { [121, "ocx-fanout:child-ocx-lacks-expect-link\n", { kind: "refused", reason: "child-ocx-lacks-expect-link" }], [121, "ocx-fanout:child-ocx-version-unknown\n", { kind: "refused", reason: "child-ocx-version-unknown" }], [121, "ocx-fanout:child-ocx-ephemeral\n", { kind: "refused", reason: "child-ocx-ephemeral" }], + [121, "ocx-fanout:selected:2.98.0\nocx-fanout:entry:pair\nocx-fanout:child-node-unavailable\n", { kind: "refused", reason: "child-node-unavailable" }], [121, "something else\n", { kind: "refused", reason: "child-gate-failed" }], ] as const)("exit %d with %j", (code, stderr, expected) => { expect(readGatedFraming({ code, stdout: "", stderr })).toEqual(expected); @@ -301,8 +319,11 @@ describe("framing", () => { test("selection, entry and clock are read once", () => { expect(ran("login noise\nocx-fanout:selected:2.97.0\nocx-fanout:entry:pair\nocx-fanout:child-now:1900000000000\n", "motd\nocx-fanout:stdout-begin\n{\"a\":1}\n")) - .toEqual({ kind: "ran", code: 0, selectedVersion: "2.97.0", entry: "pair", childNowMs: 1_900_000_000_000, payload: "{\"a\":1}", lastStderrLine: "ocx-fanout:child-now:1900000000000" }); - expect(ran("ocx-fanout:selected:2.97.0\n")).toMatchObject({ kind: "ran", entry: null, childNowMs: null }); + .toEqual({ kind: "ran", code: 0, selectedVersion: "2.97.0", entry: "pair", childNowMs: 1_900_000_000_000, launcher: null, payload: "{\"a\":1}", lastStderrLine: "ocx-fanout:child-now:1900000000000" }); + expect(ran("ocx-fanout:selected:2.97.0\n")).toMatchObject({ kind: "ran", entry: null, childNowMs: null, launcher: null }); + for (const value of ["available", "unavailable", "exec"] as const) { + expect(ran("ocx-fanout:selected:2.97.0\nocx-fanout:entry:pair\nocx-fanout:launcher:" + value + "\n")).toMatchObject({ kind: "ran", launcher: value }); + } }); test.each([ @@ -313,6 +334,8 @@ describe("framing", () => { ["two clock markers", "ocx-fanout:selected:2.97.0\nocx-fanout:child-now:1900000000000\nocx-fanout:child-now:1900000000001\n", "ocx-fanout:stdout-begin\n"], ["a 17-digit clock", "ocx-fanout:selected:2.97.0\nocx-fanout:child-now:19000000000000000\n", "ocx-fanout:stdout-begin\n"], ["a non-numeric clock", "ocx-fanout:selected:2.97.0\nocx-fanout:child-now:soon\n", "ocx-fanout:stdout-begin\n"], + ["two launcher lines", "ocx-fanout:selected:2.97.0\nocx-fanout:launcher:available\nocx-fanout:launcher:exec\n", "ocx-fanout:stdout-begin\n"], + ["an unknown launcher line", "ocx-fanout:selected:2.97.0\nocx-fanout:launcher:maybe\n", "ocx-fanout:stdout-begin\n"], ["an invalid selected version", "ocx-fanout:selected:2.97\n", "ocx-fanout:stdout-begin\n"], ])("%s is invalid", (_name, stderr, stdout) => { expect(readGatedFraming({ code: 0, stdout, stderr })).toEqual({ kind: "invalid" }); @@ -331,6 +354,8 @@ describe("framing", () => { expect(classifyUpdateApply({ code: 127, stdout: "", stderr: "" })).toEqual({ state: "failed", reason: "child-ocx-not-found", suppressTarget: false, reobserve: false }); expect(classifyUpdateApply({ code: 121, stdout: "", stderr: "ocx-fanout:child-ocx-lacks-orchestrated-update\n" })).toEqual({ state: "owner-action", reason: "legacy-ocx", suppressTarget: false, reobserve: false }); expect(classifyUpdateApply({ code: 121, stdout: "", stderr: "ocx-fanout:child-ocx-ephemeral\n" })).toEqual({ state: "unsupported", reason: "ephemeral", suppressTarget: false, reobserve: false }); + expect(classifyUpdateApply({ code: 121, stdout: "", stderr: "ocx-fanout:selected:2.98.0\nocx-fanout:entry:pair\nocx-fanout:child-node-unavailable\n" })) + .toEqual({ state: "owner-action", reason: "launcher-node-missing", suppressTarget: false, reobserve: false }); expect(classifyUpdateApply({ code: 0, stdout: "x", stderr: "ocx-fanout:selected:2.97.0\nocx-fanout:selected:2.97.0\n" })).toEqual({ state: "uncertain", reason: "child-output-invalid", suppressTarget: false, reobserve: true }); }); From 6f0135110826654a0a4c2e2d7ee876bf905495b4 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:13:11 +0900 Subject: [PATCH 03/18] wip(link): child ops engine, runners, routes and CLI verbs --- src/cli/link-child-ops.ts | 173 ++++++++--- src/cli/link.ts | 9 +- src/cli/sync-fanout.ts | 12 +- src/link/child-ops-engine.ts | 213 +++++++------ src/link/child-ops-runners.ts | 291 ++++++++++++++++++ src/link/child-ops-types.ts | 85 +++++ src/link/sync-fanout-classify.ts | 12 + .../management/link-child-ops-routes.ts | 135 +++++--- src/server/management/link-route-state.ts | 2 +- src/server/management/link-routes.ts | 13 + 10 files changed, 759 insertions(+), 186 deletions(-) create mode 100644 src/link/child-ops-runners.ts create mode 100644 src/link/child-ops-types.ts diff --git a/src/cli/link-child-ops.ts b/src/cli/link-child-ops.ts index 12a6040d4f2..0da02716bf4 100644 --- a/src/cli/link-child-ops.ts +++ b/src/cli/link-child-ops.ts @@ -1,4 +1,4 @@ -import type { ChildItemState, ChildJob, ChildJobItem } from "../link/child-ops-engine"; +import type { ChildItemState, ChildJob, ChildJobItem, ChildOp } from "../link/child-ops-types"; import { CliUsageError, RuntimeApiError, rejectArgs, takeFlag, takeJsonFlag, takeOption } from "./runtime-api"; import type { LinkCliDeps } from "./link"; @@ -12,19 +12,40 @@ export interface LinkCheckDeps extends LinkCliDeps { export const LINK_CHECK_POLL_MS = 2_000; export const OLD_HOME_MESSAGE = "This Home does not support Child operations; update the Home."; -export const NO_ELIGIBLE_LINKS_MESSAGE = "This Home has no Home-initiated links to operate on."; +export const NO_ELIGIBLE_LINKS_MESSAGE = "This Home has no links."; export const RUNTIME_ROLE_CLIENT_MESSAGE = "This machine is a Child; run Child operations on its Home."; +export const LOOPBACK_REQUIRED_MESSAGE = "Child operations from the CLI need a Home bound to loopback; use the Remote Link page in the dashboard."; const CONFLICT_MESSAGES: Readonly> = { no_eligible_links: NO_ELIGIBLE_LINKS_MESSAGE, runtime_role_client: RUNTIME_ROLE_CLIENT_MESSAGE, }; +/** How long one item may run once it has started: the sum of its runner's own SSH bounds plus a margin. */ +export const CHILD_ITEM_WAIT_MS: Readonly> = { + observe: 120_000, // wp2's value: one 60 s plan probe, one read-only inspection + sync: 420_000, // platform probe 30 s + two pinned syncs of 180 s + "restart-codex": 420_000, + update: 2_100_000, // probe 30 s + identity sync 180 s + plan 60 s + apply 900 s + 120 s grace + // + post-update sync with retry 2 × 360 s + 30 s +}; +/** Never poll longer than this from submission, queued or not; the Home keeps a finished job one hour. */ +export const CHILD_JOB_WAIT_CAP_MS = 10_800_000; + +export type LinkChildVerb = "update" | "sync" | "restart-codex"; +export const LINK_CHILD_OPS_USAGE = [ + " ocx link update (--link-id | --all) --yes [--no-wait] [--json]", + " ocx link sync (--link-id | --all) [--no-wait] [--json]", + " ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json]", + " ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json]", +] as const; + const LINK_ID = /^lnk_[0-9a-f]{16}$/; const JOB_ID = /^ljob_[0-9a-f]{16}$/; const SUCCESS: ReadonlySet = new Set(["succeeded", "up-to-date"]); const PARTIAL: ReadonlySet = new Set(["busy", "offline", "refused", "owner-action", "unsupported"]); const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); +const errorCode = (error: RuntimeApiError): unknown => (error.body as { error?: { code?: unknown } } | null)?.error?.code; /** * Read an `ocx.link.child-job/1` document. Tolerant from its first release (G1, audit H5): known keys are type-checked, @@ -55,7 +76,10 @@ export function validateChildJob(value: unknown): ChildJob { const nested = raw[key]; if (!isRecord(nested)) return fail(`item ${index} ${key}`); const field = key === "sync" ? "state" : "outcome"; - item[key] = { [field]: text(nested[field], `item ${index} ${key}.${field}`), + // Open enum strings are kept for display, so the nested shape is only checked for strings here. + (item as Record)[key] = { [field]: text(nested[field], `item ${index} ${key}.${field}`), + // A wp3 Home always sends restart.requested; an older document may not. + ...(key === "restart" && nested.requested !== undefined ? { requested: text(nested.requested, `item ${index} restart.requested`) } : {}), ...(nested.reason === undefined ? {} : { reason: nullableText(nested.reason, `item ${index} ${key}.reason`) }) }; } return item; @@ -67,11 +91,18 @@ export function validateChildJob(value: unknown): ChildJob { }; } -/** D15: 0 every item succeeded or up-to-date; 6 any item needs the operator; 1 any failure or an unknown state. */ +/** + * D15: 0 every item succeeded or up-to-date with nothing withheld; 6 any item needs the operator; 1 any failure or an + * unknown state. A success that carries a reason (restart-skipped:link-active, post-update-sync-failed, + * restart-result-unknown) or whose requested restart did not go out is partial. + */ export function linkCheckExitCode(job: ChildJob): number { let code = 0; for (const item of job.items) { - if (SUCCESS.has(item.state)) continue; + if (SUCCESS.has(item.state)) { + if (item.reason !== null || (item.restart !== undefined && item.restart.outcome !== "requested")) code = Math.max(code, 6); + continue; + } if (PARTIAL.has(item.state)) code = Math.max(code, 6); else return 1; } @@ -83,6 +114,63 @@ function readJob(response: unknown): ChildJob { return validateChildJob(response.job); } +async function waitForChildJob(job: ChildJob, deps: LinkCheckDeps, request: LinkRequest): Promise<{ job: ChildJob; done: boolean }> { + const sleep = deps.sleep ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))); + const now = deps.now ?? Date.now; + const cap = now() + CHILD_JOB_WAIT_CAP_MS; + const perItem = Object.hasOwn(CHILD_ITEM_WAIT_MS, job.op) ? CHILD_ITEM_WAIT_MS[job.op] : CHILD_ITEM_WAIT_MS.update; + const lanes = job.op === "update" ? 1 : 3; + const budget = (current: ChildJob): number => + Math.ceil(current.items.filter(item => item.finishedAt === null).length / lanes) * perItem + 30_000; + let deadline = now() + budget(job); + while (job.state !== "done") { + // A queued item has not started its SSH work: while one waits behind another job, the deadline moves with it. + if (job.items.some(item => item.state === "queued")) deadline = Math.max(deadline, now() + budget(job)); + if (now() >= Math.min(deadline, cap)) return { job, done: false }; + await sleep(LINK_CHECK_POLL_MS); + job = readJob(await request(`/api/link/children/jobs/${encodeURIComponent(job.jobId)}`, {}, deps)); + } + return { job, done: true }; +} + +/** POST one operation, then wait for it unless told not to; prints the job document and returns the verdict exit. */ +async function submitChildJob(body: { op: ChildOp; linkIds: string[] | "all"; restart?: "app-server" | "codex" }, wait: boolean, + deps: LinkCheckDeps, request: LinkRequest): Promise { + let job: ChildJob; + try { + job = readJob(await request("/api/link/children/operations", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }, deps)); + } catch (error) { + if (error instanceof RuntimeApiError) { + // A new Home answers link_not_found for a mistyped id; an older Home has no such route at all. + if (error.status === 404 && errorCode(error) !== "link_not_found") throw new RuntimeApiError(OLD_HOME_MESSAGE, 404, error.body); + // Revision 6 F3: a Home with observe only (wp2) refuses every other op as invalid_body; this CLI always sends a valid body. + if (error.status === 400 && body.op !== "observe" && errorCode(error) === "invalid_body") throw new RuntimeApiError(OLD_HOME_MESSAGE, 404, error.body); + // Every 409 still exits 5; the conflicts that are not "another operation holds the link" say what happened. + if (error.status === 409) { + const code = errorCode(error); + const message = typeof code === "string" && Object.hasOwn(CONFLICT_MESSAGES, code) ? CONFLICT_MESSAGES[code] : undefined; + if (message) throw new RuntimeApiError(message, 409, error.body); + } + // Revision 4 (4529521950): a Home not bound to loopback refuses the admin token on its TCP listener. + if (error.status === 403) throw new RuntimeApiError(LOOPBACK_REQUIRED_MESSAGE, 403, error.body); + } + throw error; + } + if (!wait) { + console.log(JSON.stringify(job)); + return job.state === "done" ? linkCheckExitCode(job) : 0; + } + const waited = await waitForChildJob(job, deps, request); + console.log(JSON.stringify(waited.job)); + if (!waited.done) { + console.error(`Error: job_still_running: ${waited.job.jobId} has not finished; read it again with GET /api/link/children/jobs/${waited.job.jobId}.`); + return 1; + } + return linkCheckExitCode(waited.job); +} + /** `ocx link check (--link-id | --all) [--no-wait] [--json]`: run the observe operation and return the exit code. */ export async function runLinkCheck(args: string[], deps: LinkCheckDeps, request: LinkRequest, usage: string): Promise { takeJsonFlag(args); @@ -96,41 +184,48 @@ export async function runLinkCheck(args: string[], deps: LinkCheckDeps, request: rejectArgs(args, usage); if (all === (linkIds.length > 0)) throw new CliUsageError("check requires --link-id or --all, not both", usage); if (linkIds.length > 64) throw new CliUsageError("check accepts at most 64 --link-id values", usage); - let job: ChildJob; - try { - job = readJob(await request("/api/link/children/operations", { - method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "observe", linkIds: all ? "all" : linkIds }), - }, deps)); - } catch (error) { - // A new Home answers link_not_found for a mistyped id; an older Home has no such route at all. - if (error instanceof RuntimeApiError && error.status === 404) { - const body = error.body as { error?: { code?: unknown } } | null; - if (body?.error?.code !== "link_not_found") throw new RuntimeApiError(OLD_HOME_MESSAGE, 404, error.body); - } - // Every 409 still exits 5; the conflicts that are not "another operation holds the link" say what happened. - if (error instanceof RuntimeApiError && error.status === 409) { - const code = (error.body as { error?: { code?: unknown } } | null)?.error?.code; - const message = typeof code === "string" && Object.hasOwn(CONFLICT_MESSAGES, code) ? CONFLICT_MESSAGES[code] : undefined; - if (message) throw new RuntimeApiError(message, 409, error.body); - } - throw error; + return submitChildJob({ op: "observe", linkIds: all ? "all" : linkIds }, !noWait, deps, request); +} + +export function parseChildOpArgs(verb: LinkChildVerb, args: string[], usage: string): + { body: { op: LinkChildVerb; linkIds: string[] | "all"; restart?: "app-server" | "codex" }; wait: boolean } { + takeJsonFlag(args); + const noWait = takeFlag(args, "--no-wait"); + const all = takeFlag(args, "--all"); + const yes = takeFlag(args, "--yes"); + // Repeatable like ocx link check; a restart needs exactly one. + const linkIds: string[] = []; + for (let id = takeOption(args, "--link-id"); id !== undefined; id = takeOption(args, "--link-id")) { + if (!LINK_ID.test(id)) throw new CliUsageError(`${verb} requires valid --link-id values`, usage); + if (!linkIds.includes(id)) linkIds.push(id); } - if (noWait) { - console.log(JSON.stringify(job)); - return job.state === "done" ? linkCheckExitCode(job) : 0; + let restart: "app-server" | "codex" | undefined; + if (verb === "sync") { + const narrow = takeFlag(args, "--restart-app-server-only"); + const full = takeFlag(args, "--restart-codex"); + if (narrow && full) throw new CliUsageError("choose one of --restart-app-server-only and --restart-codex", usage); + restart = narrow ? "app-server" : full ? "codex" : undefined; } - const sleep = deps.sleep ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))); - const now = deps.now ?? Date.now; - const deadline = now() + Math.ceil(job.items.length / 3) * 120_000 + 30_000; - while (job.state !== "done") { - if (now() >= deadline) { - console.log(JSON.stringify(job)); - console.error(`Error: job_still_running: ${job.jobId} has not finished; read it again with GET /api/link/children/jobs/${job.jobId}.`); - return 1; - } - await sleep(LINK_CHECK_POLL_MS); - job = readJob(await request(`/api/link/children/jobs/${encodeURIComponent(job.jobId)}`, {}, deps)); + if (verb === "restart-codex") { + const scope = takeOption(args, "--scope") ?? "app-server"; + if (scope !== "app-server" && scope !== "codex") throw new CliUsageError("--scope must be app-server or codex", usage); + if (!yes) throw new CliUsageError("restart-codex ends running Codex turns on the Child; pass --yes", usage); + restart = scope; } - console.log(JSON.stringify(job)); - return linkCheckExitCode(job); + rejectArgs(args, usage); + if (all === (linkIds.length > 0)) throw new CliUsageError(`${verb} requires --link-id or --all, not both`, usage); + if (linkIds.length > 64) throw new CliUsageError(`${verb} accepts at most 64 --link-id values`, usage); + // Revision 6 F5: an update stops the Child's runtime and ends the turns relayed for it, so it is confirmed like a restart. + if (verb === "update" && !yes) throw new CliUsageError("update restarts OpenCodex on each Child and ends its running turns; pass --yes", usage); + // A2 / M8: a restart names one link, and the codex scope (quit and relaunch the desktop app) needs --yes on every verb. + if (restart !== undefined && (all || linkIds.length !== 1)) throw new CliUsageError(`${verb} with a restart needs exactly one --link-id; --all is refused`, usage); + if (restart === "codex" && !yes) throw new CliUsageError("--restart-codex quits the Child's Codex app and ends its turns; pass --yes", usage); + if (yes && restart === undefined && verb !== "update") throw new CliUsageError("--yes only confirms a restart or an update", usage); + return { body: { op: verb, linkIds: all ? "all" : linkIds, ...(restart ? { restart } : {}) }, wait: !noWait }; +} + +/** `ocx link update|sync|restart-codex`: one job over the link's pinned SSH trust; prints it and returns the verdict exit. */ +export async function runLinkChildOp(verb: LinkChildVerb, args: string[], deps: LinkCheckDeps, request: LinkRequest, usage: string): Promise { + const parsed = parseChildOpArgs(verb, args, usage); + return submitChildJob(parsed.body, parsed.wait, deps, request); } diff --git a/src/cli/link.ts b/src/cli/link.ts index df93c85c46b..0e0582aef3b 100644 --- a/src/cli/link.ts +++ b/src/cli/link.ts @@ -10,7 +10,7 @@ import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings"; import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility"; import { runLinkEnrollment } from "./link-enrollment"; import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup"; -import { runLinkCheck } from "./link-child-ops"; +import { LINK_CHILD_OPS_USAGE, runLinkCheck, runLinkChildOp } from "./link-child-ops"; import { validateChildOps, type LinkChildOpsView } from "./link-status-child"; import { assertSshAlias } from "../link/ssh-argv"; import { boundHint } from "../link/ssh-runner"; @@ -45,10 +45,12 @@ export const LINK_USAGE = `Usage: ocx link reconnect --link-id [--json] ocx link cleanup [--link-id ] [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +${LINK_CHILD_OPS_USAGE.join("\n")} Forced revoke skips remote cleanup. Run ocx disconnect on the remote client. Cleanup reports each Child's platform with disconnect guidance; it changes nothing. -Check reads each Home-initiated Child's installed version and update route; it changes no install.`; +Check reads each Home-initiated Child's installed version and update route; it changes no install. +Update, sync and restart-codex run on the Child over the link's SSH trust and wait for the job unless --no-wait.`; /** * What `ocx link status` prints: the runtime's status DTO itself, so a field the server adds (like @@ -405,7 +407,8 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}): else if (command === "reconnect") await runReconnect(args, deps); else if (command === "cleanup") await runLinkCleanup(args, deps, LINK_USAGE); else if (command === "check") verdictExit = await runLinkCheck(args, deps, linkRequest, LINK_USAGE); - else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup, check", LINK_USAGE); + else if (command === "update" || command === "sync" || command === "restart-codex") verdictExit = await runLinkChildOp(command, args, deps, linkRequest, LINK_USAGE); + else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup, check, update, sync, restart-codex", LINK_USAGE); }); return code === 0 ? verdictExit : code; } diff --git a/src/cli/sync-fanout.ts b/src/cli/sync-fanout.ts index ae7bf5ffe94..9e7ccfd2796 100644 --- a/src/cli/sync-fanout.ts +++ b/src/cli/sync-fanout.ts @@ -1,6 +1,7 @@ import type { LinkRecord, LinkStore } from "../link/store"; import type { LinkStatusPayload } from "./link"; import { LinkStatusError } from "./sync-link-status"; +import { childSkipHint } from "../link/sync-fanout-classify"; export { classifySyncResult, classifyVersionProbe, parseOcxVersion, REMOTE_LINK_SYNC_MESSAGE, STALE_REMOTE_CATALOG_MESSAGE, type ChildVerdict } from "../link/sync-fanout-classify"; @@ -10,17 +11,6 @@ export type HomeResult = { state: "synced" | "stale" | "skipped" | "failed"; exi export type ChildResult = { linkId: string; alias: string; state: "synced" | "skipped" | "failed" | "timed-out"; reason: string | null; exit: number | null; hint?: string }; export type FanoutDocument = { schema: "ocx.sync.fanout/1"; home: HomeResult; fanout: { state: "ran" | "disabled" | "not-applicable" | "failed"; reason: string | null }; children: ChildResult[] }; -function childSkipHint(reason: string | null, alias: string): string { - if (reason === "child-ocx-lacks-expect-link") return `Update OpenCodex on ${alias} to >= 2.93.0 using the installer that owns its selected ocx. For Desktop on headless Windows, use Check for Updates in the signed-in Desktop tray.`; - if (reason === "child-updating") return "Wait for the Child update to finish, then retry sync."; - if (reason === "child-link-mismatch:pending_operation" || reason === "child-link-mismatch:disconnect_pending") return "Wait for the Child's link operation to finish, then check its link and retry sync."; - if (reason === "child-link-mismatch:disconnected" || reason === "child-link-mismatch:other_link") return "Please re-link this Child to this Home, then retry sync."; - if (reason?.startsWith("child-link-mismatch")) return "Check the Child's Remote Link connection and re-link it to this Home if needed, then retry sync."; - if (reason === "child-ocx-version-unknown") return "Check the Child's selected OpenCodex installation and version, then retry sync."; - if (reason === "child-platform-unsupported") return "Check the Child's platform and Remote Link setup before retrying sync."; - return "Check the Child's sync status and retry sync."; -} - export async function runHomeFanout(deps: { home: HomeResult; catalogCurrent: boolean; localOnly?: boolean; applicable?: boolean; readStore: () => LinkStore; readLinkStatus: () => Promise; diff --git a/src/link/child-ops-engine.ts b/src/link/child-ops-engine.ts index d674b7b3828..9fa06acca51 100644 --- a/src/link/child-ops-engine.ts +++ b/src/link/child-ops-engine.ts @@ -1,156 +1,176 @@ import { randomBytes } from "node:crypto"; -import { withoutCarriedFields, type ChildObservation, type ChildOp, type ChildOperationSummary } from "./child-observations"; -import type { ObserveOutcome } from "./child-observe"; -import type { LinkRecord, LinkStore } from "./store"; +import { parseStrictSemver } from "../lib/strict-semver"; +import type { ChildObservation, ChildOperationSummary } from "./child-observations"; +import { CHILD_JOB_SCHEMA, type ChildJob, type ChildJobItem, type ChildOp, type ChildOpsEnv, type ChildRestart, + type ChildTrigger, type ItemOutcome, type ItemRunner } from "./child-ops-types"; +import { runObserveItem, runRestartCodexItem, runSyncItem, runUpdateItem } from "./child-ops-runners"; +import type { LinkRecord } from "./store"; + +export { CHILD_JOB_SCHEMA } from "./child-ops-types"; +export const CHILD_OPS_CONCURRENCY = 3; +/** Update items run one at a time across the engine; each also takes one of the CHILD_OPS_CONCURRENCY places. */ +export const CHILD_OPS_UPDATE_CONCURRENCY = 1; +export const CHILD_JOB_RETENTION_MS = 60 * 60 * 1000; +export const CHILD_JOB_RETENTION_COUNT = 32; +/** The CLI's job validator keeps strings up to this length; a longer hint would make the whole document unreadable. */ +const HINT_MAX = 256; + +export interface ChildOpRequest { op: ChildOp; linkIds: readonly string[] | "all"; restart: ChildRestart; trigger: ChildTrigger } +export type SubmitRefusal = "link_not_found" | "link_not_home_initiated" | "link_operation_in_progress" | "no_eligible_links"; +export type SubmitResult = { ok: true; job: ChildJob } | { ok: false; code: SubmitRefusal; linkId: string | null }; +export interface ChildJobHold { jobId: string; op: ChildOp; state: "queued" | "running" } -// ChildOp is declared once, in child-observations.ts; wp2 admits "observe" only. 030 moves the job types to -// child-ops-types.ts and extends this engine with its runners. -export type ChildItemState = "queued" | "running" | "succeeded" | "up-to-date" | "busy" | "offline" | "refused" - | "owner-action" | "unsupported" | "failed" | "uncertain"; -export interface ChildJobItem { - linkId: string; alias: string; state: ChildItemState; reason: string | null; hint?: string; - fromVersion?: string; toVersion?: string; startedAt: string | null; finishedAt: string | null; -} -export interface ChildJob { - schema: "ocx.link.child-job/1"; jobId: string; op: ChildOp; trigger: "manual" | "auto"; - restart: "none" | "app-server" | "codex"; state: "queued" | "running" | "done"; createdAt: string; finishedAt: string | null; items: ChildJobItem[]; -} -export type SubmitResult = { ok: true; job: ChildJob } | { ok: false; code: "link_not_found" | "link_operation_in_progress" | "no_eligible_links" }; export interface ChildOpsEngine { - submit(request: { op: "observe"; linkIds: readonly string[] | "all"; trigger: "manual" | "auto" }): SubmitResult; + /** Synchronous admission: locks are taken before it returns, so a DELETE racing it sees them. */ + submit(request: ChildOpRequest, env: ChildOpsEnv): SubmitResult; /** A structuredClone snapshot. */ job(jobId: string): ChildJob | undefined; - activeJob(linkId: string): { jobId: string; op: ChildOp; state: "queued" | "running" } | null; - /** Resolves once no job of this engine is queued or running. */ + activeJob(linkId: string): ChildJobHold | null; + /** Resolves once no job of this engine is queued or running. Tests and shutdown only. */ settled(): Promise; } -export const CHILD_JOB_SCHEMA = "ocx.link.child-job/1"; -export const CHILD_OPS_CONCURRENCY = 3; -export const CHILD_JOB_RETENTION_MS = 60 * 60 * 1000; -export const CHILD_JOB_RETENTION_COUNT = 32; +export interface ChildOpsEngineOptions { + runners?: Partial>; + jobId?: () => string; + /** Default and maximum CHILD_OPS_CONCURRENCY. */ + concurrency?: number; +} +const DONE: ReadonlySet = new Set(["succeeded", "up-to-date", "busy", "offline", "refused", "owner-action", "unsupported", "failed", "uncertain"]); const errorName = (error: unknown): string => error instanceof Error ? error.name : "unknown"; +const iso = (env: ChildOpsEnv): string => new Date(env.now()).toISOString(); +const strictVersion = (value: string | undefined): value is string => typeof value === "string" && value.length <= 128 && parseStrictSemver(value) !== null; + +/** 010's persisted slice; called after finishedAt is set. A version the cache would refuse ("unknown") is left out. */ +export function summarize(job: ChildJob, item: ChildJobItem): ChildOperationSummary { + return { op: job.op, jobId: job.jobId, state: item.state, reason: item.reason, finishedAt: item.finishedAt!, + ...(strictVersion(item.fromVersion) ? { fromVersion: item.fromVersion } : {}), + ...(strictVersion(item.toVersion) ? { toVersion: item.toVersion } : {}) }; +} /** - * The D11 job model: one in-memory registry, one engine-wide lock per link, at most 3 items running across every job - * of the engine, finished jobs kept one hour and at most 32. A Home restart loses the jobs only; the observation cache - * keeps each Child's last result. Items carry closed tokens, never remote text. No timers: retention is applied on - * each submit and read. + * The D11 job model: one in-memory registry, one lock per link per Home process, at most three items running across + * every job and at most one of them an update, finished jobs kept one hour and at most 32. A Home restart loses the + * jobs only; the observation cache keeps each Child's last result. Items carry closed tokens, never remote text. + * No timers: retention is applied on each submit and read, on each job's own env clock. */ -export function createChildOpsEngine(deps: { - readStore: () => LinkStore; observe: (record: LinkRecord) => Promise; - /** Production: updateChildObservations(file => patchChildObservation(file, linkId, patch, now())). */ - writeObservation: (linkId: string, patch: Partial) => void; - now?: () => Date; jobId?: () => string; concurrency?: number; -}): ChildOpsEngine { - const now = deps.now ?? (() => new Date()); - const newJobId = deps.jobId ?? (() => `ljob_${randomBytes(8).toString("hex")}`); - const concurrency = Math.max(1, deps.concurrency ?? CHILD_OPS_CONCURRENCY); - const jobs = new Map(); - const locks = new Map(); +export function createChildOpsEngine(options: ChildOpsEngineOptions = {}): ChildOpsEngine { + const runners: Record = { observe: runObserveItem, sync: runSyncItem, "restart-codex": runRestartCodexItem, + update: runUpdateItem, ...options.runners }; + const newJobId = options.jobId ?? (() => `ljob_${randomBytes(8).toString("hex")}`); + const concurrency = Math.max(1, Math.min(options.concurrency ?? CHILD_OPS_CONCURRENCY, CHILD_OPS_CONCURRENCY)); + const jobs = new Map(); + const locks = new Map(); // linkId -> jobId const idle = new Set<() => void>(); - // One engine-wide queue: the concurrency limit bounds SSH sessions across jobs, not per job. - const pending: Array<{ job: ChildJob; item: ChildJobItem }> = []; + // One engine-wide FIFO: the bound is on SSH sessions from this Home, not per job. + const pending: Array<{ job: ChildJob; item: ChildJobItem; env: ChildOpsEnv }> = []; let active = 0; + let activeUpdates = 0; - const write = (linkId: string, patch: Partial): void => { - try { deps.writeObservation(linkId, patch); } catch (error) { - // The file is a cache and the job document already carries the result. + const write = (env: ChildOpsEnv, linkId: string, patch: Partial): void => { + try { env.writeObservation(linkId, patch); } catch (error) { + // The env contract is "never throws"; this guards an env that breaks it. The job document carries the result. console.warn(`[link] child observation write failed linkId=${linkId} error=${errorName(error)}`); } }; - const running = (): boolean => [...jobs.values()].some(job => job.state !== "done"); + const running = (): boolean => [...jobs.values()].some(({ job }) => job.state !== "done"); const prune = (): void => { - const cutoff = now().getTime() - CHILD_JOB_RETENTION_MS; - const finished = [...jobs.values()].filter(job => job.state === "done"); - for (const job of finished) if (Date.parse(job.finishedAt!) < cutoff) jobs.delete(job.jobId); - const kept = finished.filter(job => jobs.has(job.jobId)).sort((a, b) => Date.parse(a.finishedAt!) - Date.parse(b.finishedAt!)); - for (const job of kept.slice(0, Math.max(0, kept.length - CHILD_JOB_RETENTION_COUNT))) jobs.delete(job.jobId); + const finished = [...jobs.values()].filter(({ job }) => job.state === "done"); + for (const { job, env } of finished) if (Date.parse(job.finishedAt!) < env.now() - CHILD_JOB_RETENTION_MS) jobs.delete(job.jobId); + const kept = finished.filter(({ job }) => jobs.has(job.jobId)).sort((a, b) => Date.parse(a.job.finishedAt!) - Date.parse(b.job.finishedAt!)); + for (const { job } of kept.slice(0, Math.max(0, kept.length - CHILD_JOB_RETENTION_COUNT))) jobs.delete(job.jobId); }; - /** `ran` is false for an item that never touched its link (busy): the link's lastOperation belongs to its holder. */ - const finishItem = (job: ChildJob, item: ChildJobItem, state: ChildItemState, reason: string | null, ran = true): void => { - item.state = state; - item.reason = reason; - item.finishedAt = now().toISOString(); + /** ran = false: the item never touched its link (busy, child-initiated), so it writes no lastOperation. */ + const finishItem = (job: ChildJob, item: ChildJobItem, env: ChildOpsEnv, outcome: ItemOutcome, ran = true): void => { + Object.assign(item, outcome); + if (item.hint !== undefined && item.hint.length > HINT_MAX) item.hint = item.hint.slice(0, HINT_MAX); + item.finishedAt = iso(env); if (!ran) return; if (locks.get(item.linkId) === job.jobId) locks.delete(item.linkId); - const summary: ChildOperationSummary = { op: job.op, jobId: job.jobId, state, reason, finishedAt: item.finishedAt }; - write(item.linkId, { lastOperation: summary }); + write(env, item.linkId, { lastOperation: summarize(job, item) }); }; - const finishJob = (job: ChildJob): void => { + const finishJob = (job: ChildJob, env: ChildOpsEnv): void => { if (job.state === "done" || job.items.some(item => item.state === "queued" || item.state === "running")) return; job.state = "done"; - job.finishedAt = now().toISOString(); + job.finishedAt = iso(env); if (!running()) { for (const resolve of idle) resolve(); idle.clear(); } }; - const runItem = async (job: ChildJob, item: ChildJobItem): Promise => { + const runItem = async (job: ChildJob, item: ChildJobItem, env: ChildOpsEnv): Promise => { item.state = "running"; - item.startedAt = now().toISOString(); + item.startedAt = iso(env); if (job.state === "queued") job.state = "running"; let record: LinkRecord | undefined; - try { record = deps.readStore().links.find(link => link.id === item.linkId); } catch { record = undefined; } - if (!record) { finishItem(job, item, "refused", "link-removed"); return; } - let outcome: ObserveOutcome; - try { outcome = await deps.observe(record); } catch (error) { - console.warn(`[link] child observe failed linkId=${item.linkId} error=${errorName(error)}`); - finishItem(job, item, "failed", "observe-error"); - return; + try { record = env.readStore().links.find(link => link.id === item.linkId); } catch { record = undefined; } + if (!record) { finishItem(job, item, env, { state: "refused", reason: "link-removed" }); return; } + let outcome: ItemOutcome; + try { outcome = await runners[job.op](record, job, env); } catch (error) { + console.warn(`[link] child ${job.op} failed linkId=${item.linkId} error=${errorName(error)}`); + outcome = { state: "failed", reason: "internal-error" }; } - if (outcome.observation) write(item.linkId, withoutCarriedFields(outcome.observation)); - finishItem(job, item, outcome.state, outcome.reason); + finishItem(job, item, env, DONE.has(outcome.state) ? outcome : { state: "failed", reason: "internal-error" }); }; const pump = (): void => { - while (active < concurrency && pending.length > 0) { - const { job, item } = pending.shift()!; + // FIFO across jobs; an update waiting for the update place does not hold up the items behind it. + for (let index = 0; index < pending.length && active < concurrency;) { + const entry = pending[index]!; + const update = entry.job.op === "update"; + if (update && activeUpdates >= CHILD_OPS_UPDATE_CONCURRENCY) { index += 1; continue; } + pending.splice(index, 1); active += 1; - void runItem(job, item).finally(() => { + if (update) activeUpdates += 1; + void runItem(entry.job, entry.item, entry.env).finally(() => { active -= 1; - finishJob(job); + if (update) activeUpdates -= 1; + finishJob(entry.job, entry.env); pump(); }); } }; return { - submit(request) { + submit(request, env) { prune(); - const store = deps.readStore(); + const store = env.readStore(); let records: LinkRecord[]; if (request.linkIds === "all") { records = store.links; - if (records.length === 0) return { ok: false, code: "no_eligible_links" }; + if (records.length === 0) return { ok: false, code: "no_eligible_links", linkId: null }; } else { records = []; for (const linkId of request.linkIds) { const record = store.links.find(link => link.id === linkId); - if (!record) return { ok: false, code: "link_not_found" }; + if (!record) return { ok: false, code: "link_not_found", linkId }; records.push(record); } - if (records.length === 1 && locks.has(records[0]!.id)) return { ok: false, code: "link_operation_in_progress" }; + const only = records.length === 1 ? records[0]! : null; + // observe answers a client-initiated link itself without SSH (020); the other ops need the Home's SSH trust. + if (only && request.op !== "observe" && only.direction === "client-initiated") return { ok: false, code: "link_not_home_initiated", linkId: only.id }; + if (only && locks.has(only.id)) return { ok: false, code: "link_operation_in_progress", linkId: only.id }; } - const jobId = newJobId(); - const createdAt = now().toISOString(); - const job: ChildJob = { schema: CHILD_JOB_SCHEMA, jobId, op: request.op, trigger: request.trigger, restart: "none", state: "queued", - createdAt, finishedAt: null, items: records.map(record => ({ linkId: record.id, alias: record.alias, state: "queued" as const, reason: null, + const job: ChildJob = { schema: CHILD_JOB_SCHEMA, jobId: newJobId(), op: request.op, trigger: request.trigger, + restart: request.restart, state: "queued", createdAt: iso(env), finishedAt: null, + items: records.map(record => ({ linkId: record.id, alias: record.alias, state: "queued" as const, reason: null, startedAt: null, finishedAt: null })) }; - jobs.set(jobId, job); - for (const item of job.items) { - if (locks.has(item.linkId)) finishItem(job, item, "busy", "link-operation-in-progress", false); - else { locks.set(item.linkId, jobId); pending.push({ job, item }); } - } + jobs.set(job.jobId, { job, env }); + job.items.forEach((item, index) => { + if (locks.has(item.linkId)) finishItem(job, item, env, { state: "busy", reason: "link-operation-in-progress" }, false); + else if (request.op !== "observe" && records[index]!.direction === "client-initiated") { + finishItem(job, item, env, { state: "unsupported", reason: "child-initiated" }, false); + } else { locks.set(item.linkId, job.jobId); pending.push({ job, item, env }); } + }); pump(); - finishJob(job); + finishJob(job, env); return { ok: true, job: structuredClone(job) }; }, job(jobId) { prune(); - const job = jobs.get(jobId); - return job ? structuredClone(job) : undefined; + const entry = jobs.get(jobId); + return entry ? structuredClone(entry.job) : undefined; }, activeJob(linkId) { const jobId = locks.get(linkId); - const job = jobId ? jobs.get(jobId) : undefined; + const job = jobId ? jobs.get(jobId)?.job : undefined; if (!job || job.state === "done") return null; return { jobId: job.jobId, op: job.op, state: job.state }; }, @@ -160,3 +180,14 @@ export function createChildOpsEngine(deps: { }, }; } + +let shared: ChildOpsEngine | null = null; +/** One engine per Home process, shared by the routes (wp3) and the scheduler (wp5, wp7). */ +export function sharedChildOpsEngine(): ChildOpsEngine { + return shared ??= createChildOpsEngine(); +} +/** Tests that reach the routes through handleManagementAPI cannot set LinkRouteState.childOps; they await + * sharedChildOpsEngine().settled() and call this in afterEach. Precedent: resetQuotaResetActivationForTests. */ +export function resetSharedChildOpsEngineForTests(): void { + shared = null; +} diff --git a/src/link/child-ops-runners.ts b/src/link/child-ops-runners.ts new file mode 100644 index 00000000000..d483a9a480b --- /dev/null +++ b/src/link/child-ops-runners.ts @@ -0,0 +1,291 @@ +import { buildExecArgv, LinkSshArgumentError, remotePinnedSyncArgv } from "./ssh-argv"; +import { classifyUpdateApply, remoteUpdateApplyArgv } from "./child-gated-ocx"; +import { requireRemotePlatform } from "./remote-platform"; +import { SshRunnerError, type SshRunResult } from "./ssh-runner"; +import { classifySyncResult, childSkipHint } from "./sync-fanout-classify"; +import type { LinkRecord } from "./store"; +import { RESTART_SKIPPED, type ChildJobItem, type ChildOpsEnv, type ChildRestart, type ItemOutcome, + type ItemRunner } from "./child-ops-types"; +import { withoutCarriedFields } from "./child-observations"; + +export const SYNC_TIMEOUT_MS = 180_000; +/** The --not-after window, counted on the Child's clock from the plan probe (M9). */ +export const APPLY_NOT_AFTER_MS = 120_000; +/** Less than this left of the window when the apply would be sent ⇒ do not send; the next run re-plans. */ +export const APPLY_MIN_REMAINING_MS = 15_000; +export const APPLY_GRACE_MS = 120_000; +export const POST_UPDATE_SYNC_RETRY_MS = 30_000; +/** Revision 4: how long an uncertain update stays recorded for recovery (070 imports it from here). */ +export const UNCERTAIN_RECOVERY_WINDOW_MS = 3_600_000; +/** Revision 6 F2: a restarting sync that may have run but whose result is unknown. */ +export const RESTART_RESULT_UNKNOWN = "restart-result-unknown"; +const iso = (ms: number): string => new Date(ms).toISOString(); +type Platform = "posix" | "windows"; +type SyncResult = NonNullable; +type RestartReport = NonNullable; +/** A pinned sync's verdict plus whether it certainly ran nothing on the Child (Revision 6 F2). */ +interface PinnedSyncRun { sync: SyncResult; notRun: boolean } + +function unreachable(record: LinkRecord, env: ChildOpsEnv): ItemOutcome | null { + const wire = env.wireState(record.id); + if (wire === null) return { state: "offline", reason: "link-not-in-runtime" }; + return wire === "connected" ? null : { state: "offline", reason: `tunnel-${wire}` }; +} + +async function platformOf(record: LinkRecord, env: ChildOpsEnv): Promise { + if (record.remotePlatform) return record.remotePlatform; + try { return await requireRemotePlatform(env.runner, record.alias, env.knownHostsFile); } + catch (error) { + return error instanceof SshRunnerError && error.code === "timeout" + ? { state: "offline", reason: "ssh-timeout" } : { state: "failed", reason: "platform-probe-failed" }; + } +} + +/** A2 / M8: read when the item runs, not when the job was queued. Unknown activity counts as in flight (020). */ +function linkBusy(record: LinkRecord, env: ChildOpsEnv): boolean { + return (env.activity(record.id)?.inFlight ?? 1) > 0; +} + +/** A4 owner action: the npm or pnpm launcher route needs a Node the Child's SSH session can find. */ +function launcherHint(alias: string): string { + return `Install Node.js where the npm or pnpm that installed OpenCodex on ${alias} can find it, or update there with ocx update.`; +} + +async function runPinnedSync(record: LinkRecord, platform: Platform, restart: ChildRestart, env: ChildOpsEnv): Promise { + let argv: string[]; + try { + argv = buildExecArgv({ alias: record.alias, knownHostsFile: env.knownHostsFile, + argv: remotePinnedSyncArgv(record.id, platform, restart === "none" ? {} : { restart }) }); + } catch (error) { + if (error instanceof LinkSshArgumentError) return { sync: { state: "failed", reason: "ssh-argument-invalid" }, notRun: true }; + throw error; + } + // Revision 4 (4529521968): read the etag before the sync. If the catalog changes during the sync, the recorded etag + // is the older one, so the fan-out sees the Child as behind and syncs again; it is never credited with a newer one. + const etag = env.catalogEtag(); + let result: SshRunResult; + try { + result = await env.runner.run(argv, { timeoutMs: SYNC_TIMEOUT_MS }); + } catch (error) { + // A spawn failure sent nothing; a timeout, an output limit or a decode failure may follow a run. + return { sync: error instanceof SshRunnerError && error.code === "timeout" + ? { state: "timed-out", reason: "deadline-exceeded" } : { state: "failed", reason: "ssh-runner-failed" }, + notRun: error instanceof SshRunnerError && error.code === "spawn" }; + } + // wp1's classifier reads the selected-kind marker only when asked, and remotePinnedSyncArgv prints it only for a + // restarting sync; without the option executable is never set. + const sync: SyncResult = classifySyncResult(result, { expectSelectedKind: restart !== "none" }); + // Only a synced Home-run sync proves the Child applied the catalog; a GET of /v1/catalog proves only a fetch. + if (sync.state === "synced" && etag !== null) env.writeObservation(record.id, { catalogSyncedEtag: etag }); + // F2: the gate refused before selecting an ocx (121, 125, 126, 127 without the selection marker), or the Child's sync + // declined (skipped). A dropped channel (255), a failure after selection or a timeout may have restarted Codex. + const selected = /^ocx-fanout:selected:/m.test(result.stderr); + const notRun = sync.state === "skipped" || (sync.state === "failed" && !selected && [121, 125, 126, 127].includes(result.code)); + return { sync, notRun }; +} + +/** The pinned sync --expect-link of one Child, with an optional restart flag (D13). */ +export async function pinnedSync(record: LinkRecord, platform: Platform, restart: ChildRestart, env: ChildOpsEnv): Promise { + return (await runPinnedSync(record, platform, restart, env)).sync; +} + +/** + * The pinned sync with an optional restart that obeys A2: unsupported where it cannot run, withheld while busy. + * Revision 4 (4529521916): the catalog sync runs first without a restart; activity is read after it; the restart then + * goes out as a second pinned sync, which is close to a no-op sync. The gap between the activity read and the restart is + * one SSH round trip instead of a whole sync (up to SYNC_TIMEOUT_MS). It cannot be closed from the Home: the restart + * executes on the Child, and holding a relayed request at the Home would not save a turn whose app-server is restarting. + */ +async function syncWithRestart(record: LinkRecord, platform: Platform, requested: ChildRestart, + env: ChildOpsEnv): Promise<{ sync: SyncResult; restart?: RestartReport; restartUnknown?: true }> { + if (requested === "none") return { sync: await pinnedSync(record, platform, "none", env) }; + const sync = await pinnedSync(record, platform, "none", env); + if (platform !== "posix" || record.childKind === "ephemeral") return { sync, restart: { requested, outcome: "unsupported" } }; + if (sync.state !== "synced") return { sync, restart: { requested, outcome: "not-run" } }; + if (linkBusy(record, env)) return { sync, restart: { requested, outcome: "skipped" } }; + const second = await runPinnedSync(record, platform, requested, env); + // 4529521993: a record without childKind can still select ocx-child, which ignores the flag (010 selected-kind marker). + if (second.sync.executable === "ocx-child") return { sync, restart: { requested, outcome: "unsupported" } }; + if (second.sync.state === "synced") return { sync, restart: { requested, outcome: "requested" } }; + if (second.notRun) return { sync, restart: { requested, outcome: "not-run" } }; + // F2: the restart was sent and its result is unknown; the item reports the second sync beside it. + return { sync: second.sync, restart: { requested, outcome: "requested" }, restartUnknown: true }; +} + +export function syncOutcome(sync: SyncResult, alias: string): ItemOutcome { + if (sync.state === "synced") return { state: "succeeded", reason: null, sync }; + if (sync.state === "timed-out") return { state: "uncertain", reason: "deadline-exceeded", sync }; + if (sync.state === "skipped") { + const reason = sync.reason ?? "child-sync-skipped"; + const hint = childSkipHint(reason, alias); + if (reason === "child-updating") return { state: "busy", reason, hint, sync }; + if (reason === "child-ocx-lacks-expect-link") return { state: "owner-action", reason, hint, sync }; + if (reason === "child-platform-unsupported") return { state: "unsupported", reason, hint, sync }; + return { state: "refused", reason, hint, sync }; // child-link-mismatch:*, version unknown + } + return sync.reason === "ssh-failed" ? { state: "offline", reason: "ssh-failed", sync } + : { state: "failed", reason: sync.reason ?? "child-sync-failed", sync }; +} + +/** wp2's observe dispatch as a runner: fresh fields only, so every carried field survives. */ +export const runObserveItem: ItemRunner = async (record, _job, env) => { + let outcome: Awaited>; + // wp2 reports a throwing observe as failed/observe-error, not the engine's internal-error; keep that reason. + try { outcome = await env.observe(record); } catch (error) { + console.warn(`[link] child observe failed linkId=${record.id} error=${error instanceof Error ? error.name : "unknown"}`); + return { state: "failed", reason: "observe-error" }; + } + // Revision 4: every carried field (010 CARRIED_OBSERVATION_FIELDS) survives, including the three revision 4 added. + if (outcome.observation) env.writeObservation(record.id, withoutCarriedFields(outcome.observation)); + return { state: outcome.state, reason: outcome.reason }; +}; + +export const runSyncItem: ItemRunner = async (record, job, env) => { + // 4529522063: the scheduler checked idle and the settings digest when it queued the job; the item may have waited + // behind other Children since. Re-ask at item start; a no answer is busy with no SSH, retried at the next poll. + if (job.trigger === "auto" && env.autoSyncAllowed?.(record.id) === false) return { state: "busy", reason: "link-active" }; + const offline = unreachable(record, env); + if (offline) return offline; + const platform = await platformOf(record, env); + if (typeof platform !== "string") return platform; + const { sync, restart, restartUnknown } = await syncWithRestart(record, platform, job.restart, env); + if (restartUnknown) return { state: "uncertain", reason: RESTART_RESULT_UNKNOWN, sync, restart }; + const outcome = syncOutcome(sync, record.alias); + if (restart) outcome.restart = restart; + // The sync is the job's purpose here; a withheld restart is reported, not a failure (A2). + if (restart?.outcome === "skipped" && outcome.state === "succeeded") outcome.reason = RESTART_SKIPPED; + return outcome; +}; + +export const runRestartCodexItem: ItemRunner = async (record, job, env) => { + const requested = job.restart === "codex" ? "codex" : "app-server"; + const refused = (reason: string): ItemOutcome => + ({ state: "unsupported", reason, restart: { requested, outcome: "unsupported" } }); + if (record.remotePlatform === "windows") return refused("windows-restart"); // D20, no SSH at all + const offline = unreachable(record, env); + if (offline) return offline; + const platform = await platformOf(record, env); + if (typeof platform !== "string") return platform; + if (platform === "windows") return refused("windows-restart"); + if (record.childKind === "ephemeral") { + // ocx-child accepts and ignores the restart flags (F2): sync without them, report unsupported. + const sync = await pinnedSync(record, platform, "none", env); + const outcome = syncOutcome(sync, record.alias); + return outcome.state === "succeeded" ? { ...refused("ephemeral-restart"), sync } : { ...outcome, restart: { requested, outcome: "not-run" } }; + } + // A2 / M8: the restart is the purpose of this op, so a busy link runs nothing (no SSH) and reports partial (exit 6). + if (linkBusy(record, env)) return { state: "busy", reason: RESTART_SKIPPED, restart: { requested, outcome: "skipped" } }; + // Revision 4: same two-step path as sync with a restart, so activity is read again just before the restart goes out. + const { sync, restart, restartUnknown } = await syncWithRestart(record, platform, requested, env); + if (restartUnknown) return { state: "uncertain", reason: RESTART_RESULT_UNKNOWN, sync, restart }; + if (restart!.outcome === "unsupported") return syncOutcome(sync, record.alias).state === "succeeded" + ? { ...refused("ephemeral-restart"), sync } : { ...syncOutcome(sync, record.alias), restart: { requested, outcome: "not-run" } }; + if (restart!.outcome === "skipped") return { state: "busy", reason: RESTART_SKIPPED, sync, restart }; + return { ...syncOutcome(sync, record.alias), restart }; +}; + +export const runUpdateItem: ItemRunner = async (record, job, env) => { + if (record.childKind === "ephemeral") return { state: "unsupported", reason: "ephemeral" }; // D10 + const offline = unreachable(record, env); + if (offline) return offline; + // Revision 4 (4529521925): bind the Child's identity before anything can mutate it. The pinned sync is the Child's own + // check (sync --expect-link, under its lifecycle lock): a Child that disconnected or linked to another Home answers + // child-link-mismatch:* and nothing below runs. It precedes the plan probe, so the probe's 120 s window is not spent + // on it; the gap left before apply is the probe itself (at most 60 s). + const identityPlatform = await platformOf(record, env); + if (typeof identityPlatform !== "string") return identityPlatform; + const identity = await pinnedSync(record, identityPlatform, "none", env); + if (identity.state !== "synced") return syncOutcome(identity, record.alias); + // D3: a fresh plan every time. Windows Children are observed too: desktop-request is the one route they can take (H4, A5). + const observed = await env.observe(record); + if (observed.observation) env.writeObservation(record.id, withoutCarriedFields(observed.observation)); + const probe = observed.probe; + // 4529522000: observe ends "succeeded" with probe null for a record without childKind whose Child runs ocx-child. + // Nothing was updated, so this is unsupported, never a success. + if (observed.state === "succeeded" && probe === null) return { state: "unsupported", reason: "ephemeral" }; + if (observed.state !== "succeeded" || probe === null) return { state: observed.state, reason: observed.reason }; + const plan = probe.classification; + const target = env.homeVersion; + const versions = { ...(plan.installedVersion ? { fromVersion: plan.installedVersion } : {}), toVersion: target }; + if (plan.updateRoute === "none") return plan.updateReason === "up-to-date" + ? { state: "up-to-date", reason: null, ...(plan.installedVersion ? { fromVersion: plan.installedVersion, toVersion: plan.installedVersion } : {}) } + : { state: "refused", reason: plan.updateReason ?? "plan-unavailable", ...versions }; + if (plan.updateRoute === "owner-action" || plan.updateRoute === "replant") return { state: "owner-action", reason: plan.updateReason, + ...(plan.updateReason === "launcher-node-missing" ? { hint: launcherHint(record.alias) } : {}), ...versions }; + if (plan.updateInFlight) return { state: "busy", reason: "update-in-flight", ...versions }; + const suppressed = env.readObservation(record.id)?.suppressedTarget ?? null; + if (job.trigger === "auto") { + if (suppressed === target) return { state: "refused", reason: "target-failed-before", ...versions }; + if (env.activity(record.id)?.idle !== true) return { state: "busy", reason: "link-active", ...versions }; + } else if (suppressed !== null) { + env.writeObservation(record.id, { suppressedTarget: null }); // a manual update clears it (D12 step 2) + } + // H4: the Desktop branch precedes the Windows refusal. 080 replaces this line with the Desktop request runner. + if (plan.updateRoute === "desktop-request") return { state: "owner-action", reason: "desktop-app", ...versions }; + if (probe.platform === "windows") return { state: "unsupported", reason: "windows-apply", ...versions }; // D20 + // updateRoute === "orchestrated" on POSIX. + // M9: --not-after is evaluated on the Child's clock. childNowMs was printed at the start of the plan probe, so the + // window counts from the plan; what is left of it is the window minus the Home's own elapsed time since the probe. + // Defensive (audit r2 R3): the 60 s probe bound and no queueing between observe and apply make it unreachable today. + const remaining = APPLY_NOT_AFTER_MS - (env.now() - probe.startedAt); + if (remaining < APPLY_MIN_REMAINING_MS) return { state: "busy", reason: "plan-expired", ...versions }; + const homeClock = probe.childNowMs === null; // marker missing (should not happen): fall back to the Home clock + const notAfter = homeClock ? env.now() + remaining : probe.childNowMs! + APPLY_NOT_AFTER_MS; + const viaLauncher = plan.installKind === "npm" || plan.installKind === "pnpm"; // A4 (H2): apply only, npm/pnpm only + // F8: built before anything is sent, so a builder refusal is a plain failure with no recovery record. + let argv: string[]; + try { + if (plan.planToken === null) throw new LinkSshArgumentError("invalid plan token"); + argv = buildExecArgv({ alias: record.alias, knownHostsFile: env.knownHostsFile, + argv: remoteUpdateApplyArgv({ homeVersion: target, planToken: plan.planToken, notAfter, viaLauncher }, "posix") }); + } catch (error) { + if (error instanceof LinkSshArgumentError) return { state: "failed", reason: "ssh-argument-invalid", ...versions }; + throw error; + } + let result: SshRunResult; + try { + result = await env.runner.run(argv, { timeoutMs: plan.mutationBoundMs + APPLY_GRACE_MS }); + } catch (error) { + // The Child's update keeps running or has finished; only a re-observe can tell (R2 hazard 4). + // Revision 4 (4529522112, 4529521944): recorded apart from lastOperation, which the next observe replaces; 070 + // re-observes it, never re-applies, and resyncs once the Child reports no update in flight. + env.writeObservation(record.id, { pendingUpdateRecovery: { target, since: iso(env.now()), + until: iso(env.now() + UNCERTAIN_RECOVERY_WINDOW_MS) } }); + return { state: "uncertain", reason: error instanceof SshRunnerError && error.code === "timeout" + ? "deadline-exceeded" : "ssh-runner-failed", ...versions }; + } + const verdict = classifyUpdateApply(result); + if (verdict.suppressTarget) env.writeObservation(record.id, { suppressedTarget: target }); + const verdictVersions = { ...versions, ...(verdict.fromVersion ? { fromVersion: verdict.fromVersion } : {}), + ...(verdict.toVersion ? { toVersion: verdict.toVersion } : {}) }; + if (verdict.state === "refused" && verdict.reason === "deadline-passed") return { state: "refused", reason: "deadline-passed", + ...(homeClock ? { hint: `The clocks of this Home and ${record.alias} differ (clock-skew); correct the Child's clock or run the update again.` } : {}), + ...verdictVersions }; + if (verdict.state === "up-to-date") return { state: "up-to-date", reason: null, ...verdictVersions }; + if (verdict.state === "owner-action" && verdict.reason === "launcher-node-missing") { + return { state: "owner-action", reason: verdict.reason, hint: launcherHint(record.alias), ...verdictVersions }; + } + if (verdict.state === "uncertain") env.writeObservation(record.id, { pendingUpdateRecovery: { target, + since: iso(env.now()), until: iso(env.now() + UNCERTAIN_RECOVERY_WINDOW_MS) } }); + // 4529521944: a failed or rolled-back apply may already have stopped the proxy unsupervised, which removes routing. + // Resync without a restart; the verdict (and any suppressedTarget written above) stands, the sync is reported beside it. + if (verdict.state === "failed") return { state: "failed", reason: verdict.reason, ...verdictVersions, + sync: await pinnedSync(record, "posix", "none", env) }; + if (verdict.state !== "succeeded") return { state: verdict.state, reason: verdict.reason, ...verdictVersions }; + // Always resync: an unsupervised stop removed routing and start alone does not reapply it. The restart obeys A2. + let { sync, restart, restartUnknown } = await syncWithRestart(record, "posix", job.restart, env); + if (sync.state === "skipped" && sync.reason === "child-updating") { + await env.sleep(POST_UPDATE_SYNC_RETRY_MS); + ({ sync, restart, restartUnknown } = await syncWithRestart(record, "posix", job.restart, env)); + } + env.writeObservation(record.id, { installedVersion: target, targetVersion: target, updateRoute: "none", + updateReason: "up-to-date", observedAt: iso(env.now()), source: "plan" }); + const extra = { ...verdictVersions, sync, ...(restart ? { restart } : {}) }; + // The update stands in every branch below; only what follows it is reported. + if (restartUnknown) return { state: "succeeded", reason: RESTART_RESULT_UNKNOWN, ...extra }; + return sync.state === "synced" + ? { state: "succeeded", reason: restart?.outcome === "skipped" ? RESTART_SKIPPED : null, ...extra } + // Revision 4 (4529522006): the update stands, but routing may be gone; the CLI exits 6 for this reason (Section 3). + : { state: "succeeded", reason: "post-update-sync-failed", + hint: `${sync.state === "skipped" ? `${childSkipHint(sync.reason, record.alias)} ` : ""}Run ocx link sync --link-id ${record.id} to restore routing.`, ...extra }; +}; diff --git a/src/link/child-ops-types.ts b/src/link/child-ops-types.ts new file mode 100644 index 00000000000..6426ce809bd --- /dev/null +++ b/src/link/child-ops-types.ts @@ -0,0 +1,85 @@ +import type { LinkRecord, LinkStore } from "./store"; +import type { SshRunner } from "./ssh-runner"; +import type { LinkWireState } from "./status-projection"; +import type { ChildObservation, ChildOp } from "./child-observations"; +import type { ObserveOutcome } from "./child-observe"; + +/** + * The ocx.link.child-job/1 vocabulary (030 2.1). ChildOp is declared once, in child-observations.ts; the GUI keeps its + * own wire mirror. Item states and reasons are closed tokens produced by this Home, never remote text. + */ +export type { ChildOp } from "./child-observations"; +export const CHILD_JOB_SCHEMA = "ocx.link.child-job/1" as const; +export const CHILD_OPS = ["observe", "sync", "restart-codex", "update"] as const satisfies readonly ChildOp[]; +export type ChildRestart = "none" | "app-server" | "codex"; +export type ChildTrigger = "manual" | "auto"; +export const CHILD_ITEM_STATES = ["queued", "running", "succeeded", "up-to-date", "busy", "offline", + "refused", "owner-action", "unsupported", "failed", "uncertain"] as const; +export type ChildItemState = typeof CHILD_ITEM_STATES[number]; +/** + * requested: the restart went out (its effect is not confirmed). unsupported: Windows, ocx-child or an ephemeral Child. + * not-run: the sync before it did not reach synced, or the restarting call was refused before an ocx was selected. + * skipped: withheld because a relayed model request was in flight (A2, reason restart-skipped:link-active). + */ +export type ChildRestartOutcome = "requested" | "unsupported" | "not-run" | "skipped"; +export const RESTART_SKIPPED = "restart-skipped:link-active"; + +export interface ChildJobItem { + linkId: string; + alias: string; + state: ChildItemState; + /** Closed reason vocabulary (030 Section 4). Never remote output. */ + reason: string | null; + hint?: string; + fromVersion?: string; + toVersion?: string; + /** executable: set only when 010's selected-kind marker reported ocx-child (Revision 4). */ + sync?: { state: "synced" | "skipped" | "failed" | "timed-out"; reason: string | null; executable?: "ocx-child" }; + restart?: { requested: "app-server" | "codex"; outcome: ChildRestartOutcome }; + startedAt: string | null; + finishedAt: string | null; +} + +export interface ChildJob { + schema: typeof CHILD_JOB_SCHEMA; + /** "ljob_" + 16 lowercase hex. */ + jobId: string; + op: ChildOp; + trigger: ChildTrigger; + restart: ChildRestart; + state: "queued" | "running" | "done"; + createdAt: string; + finishedAt: string | null; + items: ChildJobItem[]; +} + +export type ItemOutcome = Omit; + +/** Everything an item needs from the Home process; built per submit by the route (030 2.4) or the wp5 host. */ +export interface ChildOpsEnv { + readStore(): LinkStore; + runner: Pick; + knownHostsFile: string; + wireState(linkId: string): LinkWireState | null; + /** idle = isLinkIdle(snapshot, now, idleAfterMs); null when the listener has no activity book (never idle). */ + activity(linkId: string): { idle: boolean; inFlight: number } | null; + homeVersion: string; + now(): number; + sleep(ms: number): Promise; + /** observeChild(record, ObserveDeps) bound to this env's runner, known hosts, Home version and peer reports. */ + observe(record: LinkRecord): Promise; + readObservation(linkId: string): ChildObservation | null; + /** + * updateChildObservations(file => patchChildObservation(file, linkId, patch, new Date(now()))). Never throws: the + * file is a cache and the job document already carries the result, so a failed write is logged by error name and + * dropped. Every runner relies on this contract. + */ + writeObservation(linkId: string, patch: Partial): void; + /** Revision 4: the Home catalog etag now (020 monitor); a synced item records it as catalogSyncedEtag. */ + catalogEtag(): string | null; + /** Revision 4: 050's host sets it for automatic jobs; a sync item re-asks it when the item starts. Absent ⇒ allowed. */ + autoSyncAllowed?(linkId: string): boolean; +} + +export type ItemRunner = (record: LinkRecord, job: Readonly>, + env: ChildOpsEnv) => Promise; diff --git a/src/link/sync-fanout-classify.ts b/src/link/sync-fanout-classify.ts index 5d676468aca..7c50360c4ab 100644 --- a/src/link/sync-fanout-classify.ts +++ b/src/link/sync-fanout-classify.ts @@ -17,6 +17,18 @@ const skipped = (reason: string): ChildVerdict => ({ state: "skipped", reason }) const minimum = parseStrictSemver("2.93.0")!; const SELECTED_KIND = "ocx-fanout:selected-kind:"; +/** Operator guidance for a skipped pinned sync (moved from src/cli/sync-fanout.ts so the Link job runners can use it). */ +export function childSkipHint(reason: string | null, alias: string): string { + if (reason === "child-ocx-lacks-expect-link") return `Update OpenCodex on ${alias} to >= 2.93.0 using the installer that owns its selected ocx. For Desktop on headless Windows, use Check for Updates in the signed-in Desktop tray.`; + if (reason === "child-updating") return "Wait for the Child update to finish, then retry sync."; + if (reason === "child-link-mismatch:pending_operation" || reason === "child-link-mismatch:disconnect_pending") return "Wait for the Child's link operation to finish, then check its link and retry sync."; + if (reason === "child-link-mismatch:disconnected" || reason === "child-link-mismatch:other_link") return "Please re-link this Child to this Home, then retry sync."; + if (reason?.startsWith("child-link-mismatch")) return "Check the Child's Remote Link connection and re-link it to this Home if needed, then retry sync."; + if (reason === "child-ocx-version-unknown") return "Check the Child's selected OpenCodex installation and version, then retry sync."; + if (reason === "child-platform-unsupported") return "Check the Child's platform and Remote Link setup before retrying sync."; + return "Check the Child's sync status and retry sync."; +} + export function parseOcxVersion(output: string): string | null { const version = output.trim().replace(/^opencodex /, ""); return parseStrictSemver(version) ? version : null; diff --git a/src/server/management/link-child-ops-routes.ts b/src/server/management/link-child-ops-routes.ts index f03540217df..43acfcd2300 100644 --- a/src/server/management/link-child-ops-routes.ts +++ b/src/server/management/link-child-ops-routes.ts @@ -1,22 +1,24 @@ -import { DEFAULT_IDLE_AFTER_MS } from "../../link/child-activity"; +import { DEFAULT_IDLE_AFTER_MS, isLinkIdle } from "../../link/child-activity"; import { createCatalogRevisionMonitor, type CatalogRevisionMonitor } from "../../link/child-catalog-freshness"; import { observeChild } from "../../link/child-observe"; import { patchChildObservation, readChildObservations, updateChildObservations } from "../../link/child-observations"; -import { createChildOpsEngine, type ChildOpsEngine } from "../../link/child-ops-engine"; +import { sharedChildOpsEngine, type ChildOpsEngine, type SubmitRefusal, type SubmitResult } from "../../link/child-ops-engine"; +import { CHILD_OPS, type ChildOp, type ChildOpsEnv, type ChildRestart } from "../../link/child-ops-types"; import { requireRemotePlatform } from "../../link/remote-platform"; import { inspectRemoteChildRuntime } from "../../link/remote-plant"; -import { attachLinkChildOps, type LinkStatusDto } from "../../link/status-projection"; +import { attachLinkChildOps, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; import type { LinkStore } from "../../link/store"; import { packageVersion } from "../../lib/package-version"; import { readCatalog, readCodexCatalogPath } from "../../codex/catalog"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; -import { auth, exactBody, fail, knownHostsFile, readStoreFor, runnerFor, type LinkRouteState } from "./link-route-state"; +import { auth, fail, isRecord, knownHostsFile, readStoreFor, runnerFor, type LinkRouteState } from "./link-route-state"; const LINK_ID = /^lnk_[0-9a-f]{16}$/; const JOB_ID = /^ljob_[0-9a-f]{16}$/; const MAX_LINK_IDS = 64; const JOB_PATH = /^\/api\/link\/children\/jobs\/([^/]+)$/; +const NO_STORE = { "cache-control": "no-store" }; let revisionMonitor: CatalogRevisionMonitor | undefined; @@ -36,35 +38,89 @@ export function catalogRevisionForRoutes(): CatalogRevisionMonitor { }); } -/** Exactly {op:"observe", linkIds: "all" | 1..64 distinct link ids}; wp2 admits no other op or key. */ -export function parseOperation(body: unknown): { op: "observe"; linkIds: readonly string[] | "all" } | null { - const value = exactBody(body, ["op", "linkIds"]); - if (!value || value.op !== "observe") return null; - if (value.linkIds === "all") return { op: "observe", linkIds: "all" }; - if (!Array.isArray(value.linkIds) || value.linkIds.length === 0 || value.linkIds.length > MAX_LINK_IDS) return null; - if (!value.linkIds.every(id => typeof id === "string" && LINK_ID.test(id))) return null; - if (new Set(value.linkIds).size !== value.linkIds.length) return null; - return { op: "observe", linkIds: value.linkIds as string[] }; +/** + * One engine per Home process (Revision 5). wp2 kept one per route state, which link-routes.ts keys by the config + * object, so a config reload started a second engine without the first one's locks, and the wp5 scheduler must submit + * to the engine the routes read. state.childOps stays the seam a direct test can set. + */ +export function childOpsEngineFor(state: LinkRouteState): ChildOpsEngine { + return state.childOps ??= sharedChildOpsEngine(); } -/** One engine per route state: the per-link lock and the job registry are this Home process's. */ -export function childOpsEngineFor(ctx: ManagementContext, state: LinkRouteState): ChildOpsEngine { - const now = () => new Date((ctx.deps.now ?? Date.now)()); - return state.childOps ??= createChildOpsEngine({ +const errorName = (error: unknown): string => error instanceof Error ? error.name : "unknown"; + +/** The env captures this request's seams for the job's lifetime; every read is live. wp5 passes the policy idleAfterMs. */ +export function childOpsEnvFor(ctx: ManagementContext, state: LinkRouteState, idleAfterMs = DEFAULT_IDLE_AFTER_MS): ChildOpsEnv { + const now = ctx.deps.now ?? Date.now; + const runner = runnerFor(ctx); + const hosts = knownHostsFile(ctx); + const homeVersion = packageVersion("0.0.0"); + return { readStore: () => readStoreFor(ctx), - observe: record => { - const runner = runnerFor(ctx); - const hosts = knownHostsFile(ctx); - return observeChild(record, { - runner, knownHostsFile: hosts, homeVersion: packageVersion("0.0.0"), - peer: linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null }, - platformOf: link => requireRemotePlatform(runner, link.alias, hosts), - inspect: inspectRemoteChildRuntime, now, - }); + runner, knownHostsFile: hosts, homeVersion, now, + // The wire state the status route reports, including a recorded compensation failure. + wireState: linkId => { + if (state.compensationFailures?.has(linkId)) return "failed"; + return projectLinkStatus(readStoreFor(ctx), state.supervisor.status(), state.listener.status(), ctx.config) + .links.find(link => link.id === linkId)?.state ?? null; }, - writeObservation: (linkId, patch) => { updateChildObservations(file => patchChildObservation(file, linkId, patch, now())); }, - now, - }); + activity: linkId => { + const snapshot = state.listener.linkActivity?.(linkId, now()); + return snapshot ? { inFlight: snapshot.inFlight, idle: isLinkIdle(snapshot, now(), idleAfterMs) } : null; + }, + sleep: ms => Bun.sleep(ms), + // wp2's observe deps, unchanged: the peer and activity listeners are optional on LinkRouteListener. + observe: record => observeChild(record, { runner, knownHostsFile: hosts, homeVersion, + peer: linkId => state.listener.peerObservation?.(linkId) ?? { observed: null, refused: null }, + platformOf: link => requireRemotePlatform(runner, link.alias, hosts), + inspect: inspectRemoteChildRuntime, now: () => new Date(now()) }), + readObservation: linkId => readChildObservations().children[linkId] ?? null, + // Never throws into a runner: a cache write failure is logged by error name (ChildOpsEnv contract). + writeObservation: (linkId, patch) => { + try { updateChildObservations(file => patchChildObservation(file, linkId, patch, new Date(now()))); } + catch (error) { console.warn(`[link] child observation write failed linkId=${linkId} error=${errorName(error)}`); } + }, + // Revision 4: wp2's monitor on LinkRouteState.catalogRevision (catalogEtag hashing); null when the catalog cannot be read. + catalogEtag: () => { + try { return (state.catalogRevision ??= catalogRevisionForRoutes()).current()?.etag ?? null; } catch { return null; } + }, + }; +} + +export interface ParsedOperation { op: ChildOp; linkIds: string[] | "all"; restart: ChildRestart } + +/** + * Exactly {op, linkIds, restart?}. A2 / M8: any request that carries a restart (sync with restart, or restart-codex, + * whose restart defaults to app-server) must name its links; "all" is accepted only when restart is "none". + * Scope (audit r2 R6): this rule binds this API route and the ocx link verbs only; ocx sync --restart-children (040) + * and the automatic restart policy (050) select links themselves and apply the same run-time activity check. + * No body can set trigger: the route always submits manual operations. + */ +export function parseOperation(body: unknown): ParsedOperation | null { + if (!isRecord(body) || Object.keys(body).some(key => key !== "op" && key !== "linkIds" && key !== "restart")) return null; + if (!(CHILD_OPS as readonly unknown[]).includes(body.op)) return null; + const op = body.op as ChildOp; + if (body.restart !== undefined && body.restart !== "app-server" && body.restart !== "codex") return null; + if (body.restart !== undefined && op === "observe") return null; + const restart: ChildRestart = op === "restart-codex" ? (body.restart as ChildRestart | undefined) ?? "app-server" + : (body.restart as ChildRestart | undefined) ?? "none"; + const ids = body.linkIds; + let linkIds: string[] | "all"; + if (ids === "all" && restart === "none") linkIds = "all"; + else if (Array.isArray(ids) && ids.length >= 1 && ids.length <= MAX_LINK_IDS + && ids.every(id => typeof id === "string" && LINK_ID.test(id)) && new Set(ids).size === ids.length) linkIds = [...ids as string[]]; + else return null; + return { op, linkIds, restart }; +} + +function refusal(code: SubmitRefusal): Response { + switch (code) { + case "link_not_found": return fail(code, "The link was not found.", 404); + case "link_not_home_initiated": return fail(code, "Only a Home-initiated link can be operated from this computer.", 409); + case "link_operation_in_progress": return fail(code, "Another operation is running on this link.", 409); + // Returned only when links.json has no link at all, so the message says exactly that. + case "no_eligible_links": return fail(code, "This Home has no links.", 409); + } } /** `?children=1` on a Home: attach every row's `child` block. Never fails on the cache or the revision save. */ @@ -81,7 +137,8 @@ export function attachLinkChildOpsForState(dto: LinkStatusDto, state: LinkRouteS observation: linkId => known.has(linkId) ? observations.children[linkId] ?? null : null, activity: linkId => state.listener.linkActivity?.(linkId, now) ?? null, catalogPull: linkId => state.listener.catalogPull?.(linkId) ?? null, - activeJob: linkId => state.childOps?.activeJob(linkId) ?? null, + // The shared engine: a job the wp5 scheduler started shows on a status read that never submitted one. + activeJob: linkId => childOpsEngineFor(state).activeJob(linkId), }); } @@ -93,24 +150,20 @@ export async function handleLinkChildOpsRoutes(ctx: ManagementContext, state: Li if (url.pathname === "/api/link/children/operations" && req.method === "POST") { if (ctx.config.runtimeRole === "client") return fail("runtime_role_client", "A Child runtime does not operate other Children.", 409); const operation = parseOperation(await readManagementJsonBodyOr(req, null)); - if (!operation) return fail("invalid_body", "The body must be exactly {op:\"observe\", linkIds}.", 400); - let result: ReturnType; - try { - result = childOpsEngineFor(ctx, state).submit({ ...operation, trigger: "manual" }); - } catch { return fail("link_unavailable", "The link store could not be read.", 503); } - if (result.ok) return Response.json({ job: result.job }, { status: 202, headers: { "cache-control": "no-store" } }); - if (result.code === "link_not_found") return fail("link_not_found", "The link was not found.", 404); - if (result.code === "link_operation_in_progress") return fail("link_operation_in_progress", "Another operation is running on this link.", 409); - return fail("no_eligible_links", "There is no link to operate on.", 409); + if (!operation) return fail("invalid_body", "The body must be exactly {op, linkIds, restart?}; restart is app-server or codex and needs explicit linkIds.", 400); + let result: SubmitResult; + try { result = childOpsEngineFor(state).submit({ ...operation, trigger: "manual" }, childOpsEnvFor(ctx, state)); } + catch { return fail("link_unavailable", "The link store could not be read.", 503); } + return result.ok ? Response.json({ job: result.job }, { status: 202, headers: NO_STORE }) : refusal(result.code); } const jobPath = req.method === "GET" ? JOB_PATH.exec(path) : null; if (jobPath) { let jobId: string; try { jobId = decodeURIComponent(jobPath[1]!); } catch { return fail("invalid_job_id", "The job id is invalid.", 400); } if (!JOB_ID.test(jobId)) return fail("invalid_job_id", "The job id is invalid.", 400); - const job = state.childOps?.job(jobId); + const job = childOpsEngineFor(state).job(jobId); if (!job) return fail("job_not_found", "The job was not found.", 404); - return Response.json({ job }, { headers: { "cache-control": "no-store" } }); + return Response.json({ job }, { headers: NO_STORE }); } return fail("not_found", "Not found.", 404); } diff --git a/src/server/management/link-route-state.ts b/src/server/management/link-route-state.ts index e1edb1a32da..436664efa37 100644 --- a/src/server/management/link-route-state.ts +++ b/src/server/management/link-route-state.ts @@ -50,7 +50,7 @@ export interface LinkRouteState { compensationFailures?: Map; supervisor: import("../../link/supervisor").LinkSupervisor; listener: LinkRouteListener; - /** Created on the first Child-operations request (link-child-ops-routes.ts). */ + /** The process-wide engine (sharedChildOpsEngine), cached on first use; a direct test may set its own. */ childOps?: import("../../link/child-ops-engine").ChildOpsEngine; /** The Home catalog revision read on `?children=1`; defaults to the shared production monitor. */ catalogRevision?: import("../../link/child-catalog-freshness").CatalogRevisionMonitor; diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 4e08370f288..22797ac2ef3 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -203,6 +203,13 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) const current = readStoreFor(ctx); const record = current.links.find(link => link.id === id); if (!record) return fail("link_not_found", "The link was not found.", 404); + // A queued or running job would keep opening SSH sessions to a Child this removal disconnects. + const { childOpsEngineFor } = await import("./link-child-ops-routes"); + const engine = childOpsEngineFor(state); + const held = (): Response | null => engine.activeJob(id) + ? fail("link_operation_in_progress", "A Child operation is running on this link; wait for it to finish, then remove the link.", 409) : null; + const heldAtStart = held(); + if (heldAtStart) return heldAtStart; const body = await readManagementJsonBodyOr(ctx.req, {}); const payload = body === null ? {} : body; if (!isRecord(payload) @@ -214,6 +221,9 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) } const force = payload.force === true; const purge = payload.purge === true; + // Revision 6 F10: a job may have taken the link while the body was read; check again before anything is stopped. + const heldAfterBody = held(); + if (heldAfterBody) return heldAfterBody; const release = state.supervisor.holdLink?.(id); try { await state.supervisor.stopLink(id); @@ -248,6 +258,9 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error)); } } + // F10: and once more after the remote cleanup awaited, immediately before the key and the record go. + const heldBeforeWrite = held(); + if (heldBeforeWrite) { await restartTunnel(); return heldBeforeWrite; } if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502); let next: LinkStore; try { From f5594cd96b0f66e6bb936b483fc690f5cc610306 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:17:16 +0900 Subject: [PATCH 04/18] test(link): child ops engine and runner coverage --- tests/clients/link-child-ops-engine.test.ts | 293 +++++++++++ tests/clients/link-child-ops-runners.test.ts | 502 +++++++++++++++++++ 2 files changed, 795 insertions(+) create mode 100644 tests/clients/link-child-ops-engine.test.ts create mode 100644 tests/clients/link-child-ops-runners.test.ts diff --git a/tests/clients/link-child-ops-engine.test.ts b/tests/clients/link-child-ops-engine.test.ts new file mode 100644 index 00000000000..912fcd88409 --- /dev/null +++ b/tests/clients/link-child-ops-engine.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { createChildOpsEngine, resetSharedChildOpsEngineForTests, sharedChildOpsEngine, type ChildOpsEngine } from "../../src/link/child-ops-engine"; +import type { ChildOp, ChildOpsEnv, ItemOutcome, ItemRunner } from "../../src/link/child-ops-types"; +import type { ChildObservation } from "../../src/link/child-observations"; +import type { LinkRecord, LinkStore } from "../../src/link/store"; + +const NOW = Date.parse("2026-11-11T00:00:00.000Z"); +const ids = Array.from({ length: 6 }, (_, index) => "lnk_" + index.toString(16).padStart(16, "0")); + +function record(id: string, overrides: Partial = {}): LinkRecord { + return { id, alias: "host-" + id.slice(-2), direction: "hub-initiated", hostKeyFingerprint: "SHA256:abcdefghijklmnopqrstuvwxyz012345", + tunnelPort: 41000, apiKeyId: "key-" + id.slice(-2), createdAt: "2026-11-01T00:00:00.000Z", remotePlatform: "posix", ...overrides }; +} +const store = (links: LinkRecord[]): LinkStore => ({ version: 1, listenerPort: 18181, links }); + +/** A fake env: in-memory store, a test clock and a recording cache writer that never throws (the env contract). */ +function fakeEnv(links: () => LinkRecord[], clock: { now: number } = { now: NOW }) { + const writes: Array<{ linkId: string; patch: Partial }> = []; + const env: ChildOpsEnv = { + readStore: () => store(links()), runner: { run: async () => { throw new Error("no SSH in engine tests"); } }, knownHostsFile: "/dev/null", + wireState: () => "connected", activity: () => ({ idle: true, inFlight: 0 }), homeVersion: "2.100.0", now: () => clock.now, + sleep: async () => {}, observe: async () => { throw new Error("unused"); }, readObservation: () => null, + writeObservation: (linkId, patch) => { writes.push({ linkId, patch }); }, catalogEtag: () => null, + }; + return { env, writes, clock }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +const succeeded: ItemOutcome = { state: "succeeded", reason: null }; + +/** Runners that wait on a per-link gate and record how many run at once, per op. */ +function gatedRunners() { + const gates = new Map>(); + const started: string[] = []; + let running = 0; + const peak: Partial> = {}; + const runner = (op: ChildOp): ItemRunner => async target => { + running += 1; peak[op] = Math.max(peak[op] ?? 0, running); + started.push(op + ":" + target.id); + const gate = deferred(); + gates.set(op + ":" + target.id, gate); + await gate.promise; + running -= 1; + return succeeded; + }; + const release = async (key: string) => { gates.get(key)!.resolve(); await flush(); }; + const releaseAll = async () => { while ([...gates.values()].length) { const pending = [...gates.entries()]; gates.clear(); for (const [, gate] of pending) gate.resolve(); await flush(); } }; + return { runners: { observe: runner("observe"), sync: runner("sync"), "restart-codex": runner("restart-codex"), update: runner("update") }, + started, peak, release, releaseAll, running: () => running }; +} + +const manual = (op: ChildOp, linkIds: readonly string[] | "all") => ({ op, linkIds, restart: "none" as const, trigger: "manual" as const }); +function submitted(engine: ChildOpsEngine, request: ReturnType, env: ChildOpsEnv) { + const result = engine.submit(request, env); + if (!result.ok) throw new Error("submit refused: " + result.code); + return result.job; +} + +describe("child ops engine (wp3)", () => { + test("a held single link refuses a second submit and activeJob reports it", async () => { + const gated = gatedRunners(); + const { env } = fakeEnv(() => [record(ids[0]!)]); + const engine = createChildOpsEngine({ runners: gated.runners }); + const job = submitted(engine, manual("sync", [ids[0]!]), env); + expect(engine.submit(manual("sync", [ids[0]!]), env)).toEqual({ ok: false, code: "link_operation_in_progress", linkId: ids[0]! }); + expect(engine.submit(manual("update", [ids[0]!]), env)).toEqual({ ok: false, code: "link_operation_in_progress", linkId: ids[0]! }); + expect(engine.activeJob(ids[0]!)).toEqual({ jobId: job.jobId, op: "sync", state: "running" }); + await gated.releaseAll(); + await engine.settled(); + expect(engine.activeJob(ids[0]!)).toBeNull(); + }); + + test("a bulk job skips the held link as busy and writes no lastOperation for it", async () => { + const gated = gatedRunners(); + const links = [record(ids[0]!), record(ids[1]!)]; + const { env, writes } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: gated.runners }); + const first = submitted(engine, manual("sync", [ids[0]!]), env); + const bulk = submitted(engine, manual("sync", "all"), env); + expect(bulk.items[0]).toMatchObject({ linkId: ids[0], state: "busy", reason: "link-operation-in-progress" }); + expect(bulk.items[0]!.finishedAt).not.toBeNull(); + await gated.releaseAll(); + await engine.settled(); + const lastOps = writes.filter(write => write.patch.lastOperation).map(write => [write.linkId, write.patch.lastOperation!.jobId]); + expect(lastOps.sort()).toEqual([[ids[0]!, first.jobId], [ids[1]!, bulk.jobId]].sort()); + }); + + test("all lists every link: Child-initiated items end unsupported at admission, except for observe", async () => { + const calls: string[] = []; + const runner: ItemRunner = async (target, job) => { calls.push(job.op + ":" + target.id); return succeeded; }; + const links = [record(ids[0]!), record(ids[1]!, { direction: "client-initiated", hostKeyFingerprint: null })]; + const { env, writes } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: { sync: runner, observe: runner } }); + const sync = submitted(engine, manual("sync", "all"), env); + expect(sync.items[1]).toMatchObject({ linkId: ids[1], state: "unsupported", reason: "child-initiated" }); + await engine.settled(); + expect(calls).toEqual(["sync:" + ids[0]]); + expect(writes.filter(write => write.linkId === ids[1])).toEqual([]); + submitted(engine, manual("observe", "all"), env); + await engine.settled(); + expect(calls.slice(1).sort()).toEqual(["observe:" + ids[0], "observe:" + ids[1]].sort()); + // An explicit Child-initiated id inside a list is an unsupported item too. + const listed = submitted(engine, manual("update", [ids[0]!, ids[1]!]), env); + expect(listed.items[1]).toMatchObject({ state: "unsupported", reason: "child-initiated" }); + await engine.settled(); + }); + + test("submit refusals", async () => { + const links = [record(ids[0]!), record(ids[1]!, { direction: "client-initiated", hostKeyFingerprint: null })]; + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: { observe: async () => succeeded } }); + for (const op of ["sync", "restart-codex", "update"] as const) { + expect(engine.submit(manual(op, [ids[1]!]), env)).toEqual({ ok: false, code: "link_not_home_initiated", linkId: ids[1]! }); + } + expect(engine.submit(manual("observe", [ids[1]!]), env).ok).toBe(true); + await engine.settled(); + expect(engine.submit(manual("sync", [ids[5]!]), env)).toEqual({ ok: false, code: "link_not_found", linkId: ids[5]! }); + const empty = fakeEnv(() => []); + expect(engine.submit(manual("sync", "all"), empty.env)).toEqual({ ok: false, code: "no_eligible_links", linkId: null }); + const clientsOnly = fakeEnv(() => [links[1]!]); + const job = submitted(engine, manual("sync", "all"), clientsOnly.env); + expect(job.state).toBe("done"); + expect(job.items.map(item => item.state)).toEqual(["unsupported"]); + await engine.settled(); + }); + + test("a job whose every link is held is done at once with every item busy", async () => { + const gated = gatedRunners(); + const links = [record(ids[0]!), record(ids[1]!)]; + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: gated.runners }); + submitted(engine, manual("sync", [ids[0]!, ids[1]!]), env); + const all = submitted(engine, manual("sync", "all"), env); + expect(all.state).toBe("done"); + expect(all.items.map(item => item.state)).toEqual(["busy", "busy"]); + await gated.releaseAll(); + await engine.settled(); + }); + + test("update items run one at a time, sync items three at a time", async () => { + const links = ids.slice(0, 5).map(id => record(id)); + for (const op of ["update", "sync"] as const) { + const gated = gatedRunners(); + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: gated.runners }); + submitted(engine, manual(op, "all"), env); + expect(gated.running()).toBe(op === "update" ? 1 : 3); + await gated.releaseAll(); + await engine.settled(); + expect(gated.peak[op]).toBe(op === "update" ? 1 : 3); + expect(gated.started).toHaveLength(5); + } + }); + + test("engine-wide slots: four single-link syncs run three at a time; a second update waits for the first", async () => { + const gated = gatedRunners(); + const links = ids.map(id => record(id)); + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: gated.runners }); + const jobs = ids.slice(0, 4).map(id => submitted(engine, manual("sync", [id]), env)); + expect(gated.running()).toBe(3); + expect(engine.job(jobs[3]!.jobId)!.items[0]!.state).toBe("queued"); + await gated.release("sync:" + ids[0]); + expect(engine.job(jobs[3]!.jobId)!.items[0]!.state).toBe("running"); + await gated.releaseAll(); + await engine.settled(); + const updates = [ids[4]!, ids[5]!].map(id => submitted(engine, manual("update", [id]), env)); + expect(gated.started.at(-1)).toBe("update:" + ids[4]); + expect(engine.job(updates[1]!.jobId)!.items[0]!.state).toBe("queued"); + await gated.release("update:" + ids[4]); + expect(gated.started.at(-1)).toBe("update:" + ids[5]); + await gated.releaseAll(); + await engine.settled(); + }); + + test("a waiting update does not block a sync queued behind it", async () => { + const gated = gatedRunners(); + const links = ids.slice(0, 3).map(id => record(id)); + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: gated.runners }); + submitted(engine, manual("update", [ids[0]!]), env); + const waiting = submitted(engine, manual("update", [ids[1]!]), env); + const sync = submitted(engine, manual("sync", [ids[2]!]), env); + expect(sync.items[0]!.state).toBe("running"); + expect(waiting.items[0]!.state).toBe("queued"); + expect(gated.started).toEqual(["update:" + ids[0], "sync:" + ids[2]]); + await gated.releaseAll(); + await engine.settled(); + }); + + test("a record removed before its turn ends refused/link-removed", async () => { + const gated = gatedRunners(); + let links = ids.slice(0, 4).map(id => record(id)); + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: gated.runners }); + const job = submitted(engine, manual("sync", "all"), env); + links = links.filter(link => link.id !== ids[3]); + await gated.releaseAll(); + await engine.settled(); + expect(engine.job(job.jobId)!.items[3]).toMatchObject({ state: "refused", reason: "link-removed" }); + }); + + test("a throwing runner or a non-terminal state ends failed/internal-error", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const links = [record(ids[0]!), record(ids[1]!)]; + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: { sync: async target => { + if (target.id === ids[0]) throw new TypeError("boom"); + return { state: "running" as never, reason: null }; + } } }); + const job = submitted(engine, manual("sync", "all"), env); + await engine.settled(); + expect(engine.job(job.jobId)!.items.map(item => [item.state, item.reason])).toEqual([["failed", "internal-error"], ["failed", "internal-error"]]); + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([`[link] child sync failed linkId=${ids[0]} error=TypeError`]); + } finally { warn.mockRestore(); } + }); + + test("lastOperation is written per item; a write failure does not fail the item; versions the cache refuses are left out", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const links = [record(ids[0]!), record(ids[1]!)]; + const { env, writes } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: { update: async target => target.id === ids[0] + ? { state: "succeeded", reason: null, fromVersion: "unknown", toVersion: "2.100.0" } : { state: "failed", reason: "rolled-back", fromVersion: "2.98.0", toVersion: "2.100.0" } } }); + const job = submitted(engine, manual("update", "all"), env); + await engine.settled(); + const summaries = writes.map(write => write.patch.lastOperation); + expect(summaries).toEqual([ + { op: "update", jobId: job.jobId, state: "succeeded", reason: null, toVersion: "2.100.0", finishedAt: new Date(NOW).toISOString() }, + { op: "update", jobId: job.jobId, state: "failed", reason: "rolled-back", fromVersion: "2.98.0", toVersion: "2.100.0", finishedAt: new Date(NOW).toISOString() }, + ]); + // An env that breaks its never-throws contract is still contained by the engine. + const broken = { ...env, writeObservation: () => { throw new Error("locked"); } }; + const second = submitted(engine, manual("update", [ids[0]!]), broken); + await engine.settled(); + expect(engine.job(second.jobId)!.items[0]).toMatchObject({ state: "succeeded", fromVersion: "unknown" }); + expect(warn).toHaveBeenCalledTimes(1); + } finally { warn.mockRestore(); } + }); + + test("a hint longer than the CLI's 256-character bound is cut to it", async () => { + const { env } = fakeEnv(() => [record(ids[0]!)]); + const engine = createChildOpsEngine({ runners: { sync: async () => ({ state: "refused", reason: "child-link-mismatch", hint: "x".repeat(400) }) } }); + const job = submitted(engine, manual("sync", [ids[0]!]), env); + await engine.settled(); + expect(engine.job(job.jobId)!.items[0]!.hint).toHaveLength(256); + }); + + test("retention keeps 32 finished jobs and one hour on each job's clock", async () => { + const clock = { now: NOW }; + const { env } = fakeEnv(() => [record(ids[0]!)], clock); + let counter = 0; + const engine = createChildOpsEngine({ runners: { sync: async () => succeeded }, jobId: () => "ljob_" + (counter++).toString(16).padStart(16, "0") }); + for (let index = 0; index < 34; index++) { + clock.now += 1_000; + submitted(engine, manual("sync", [ids[0]!]), env); + await engine.settled(); + } + // 34 finished jobs: the two oldest are dropped on the next read. + expect(engine.job("ljob_0000000000000000")).toBeUndefined(); + expect(engine.job("ljob_0000000000000001")).toBeUndefined(); + expect(engine.job("ljob_0000000000000002")).toBeDefined(); + clock.now += 61 * 60 * 1000; + expect(engine.job("ljob_0000000000000021")).toBeUndefined(); + }); + + test("one shared engine per process until the test reset", () => { + resetSharedChildOpsEngineForTests(); + const first = sharedChildOpsEngine(); + expect(sharedChildOpsEngine()).toBe(first); + resetSharedChildOpsEngineForTests(); + expect(sharedChildOpsEngine()).not.toBe(first); + resetSharedChildOpsEngineForTests(); + }); + + test("every lock is released once the engine settles", async () => { + const gated = gatedRunners(); + const links = ids.map(id => record(id)); + const { env } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: gated.runners }); + submitted(engine, manual("restart-codex", ids.slice(0, 2)), env); + submitted(engine, manual("update", ids.slice(2, 4)), env); + await gated.releaseAll(); + await engine.settled(); + for (const id of ids) expect(engine.activeJob(id)).toBeNull(); + }); +}); diff --git a/tests/clients/link-child-ops-runners.test.ts b/tests/clients/link-child-ops-runners.test.ts new file mode 100644 index 00000000000..4cfcfff3666 --- /dev/null +++ b/tests/clients/link-child-ops-runners.test.ts @@ -0,0 +1,502 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { APPLY_GRACE_MS, POST_UPDATE_SYNC_RETRY_MS, runObserveItem, runRestartCodexItem, runSyncItem, runUpdateItem, + UNCERTAIN_RECOVERY_WINDOW_MS } from "../../src/link/child-ops-runners"; +import type { ChildOpsEnv, ChildRestart, ChildTrigger, ItemRunner } from "../../src/link/child-ops-types"; +import type { ChildObservation } from "../../src/link/child-observations"; +import type { ObserveOutcome } from "../../src/link/child-observe"; +import type { PlanClassification } from "../../src/link/child-update-contract"; +import { SshRunnerError, type SshRunResult } from "../../src/link/ssh-runner"; +import type { LinkRecord } from "../../src/link/store"; + +const LINK = "lnk_0123456789abcdef"; +const TOKEN = "AbCdEfGhIjKlMnOpQrStUvWxYz012345_-"; +const NOW = Date.parse("2026-11-11T00:00:00.000Z"); +const CHILD_NOW = 1_900_000_000_000; +const posix = test.skipIf(process.platform === "win32"); + +type Kind = "winprobe" | "uname" | "sync" | "restart" | "plan" | "apply" | "win"; +interface Call { kind: Kind; remote: string; timeoutMs: number | undefined } +type Answer = SshRunResult | Error; + +const SYNCED: SshRunResult = { code: 0, stdout: "ocx-fanout:stdout-begin\nRemote Link Home catalog synchronized.\n", stderr: "ocx-fanout:selected:2.98.0\n" }; +const UPDATING: SshRunResult = { code: 7, stdout: "ocx-fanout:stdout-begin\n", stderr: "ocx-fanout:selected:2.98.0\n" }; +const MISMATCH: SshRunResult = { code: 5, stdout: "ocx-fanout:stdout-begin\n", stderr: "ocx-fanout:selected:2.98.0\n{\"phase\":\"child_link_mismatch\",\"reason\":\"other_link\"}\n" }; +const LACKS_EXPECT_LINK: SshRunResult = { code: 121, stdout: "", stderr: "ocx-fanout:child-ocx-lacks-expect-link\n" }; +const DROPPED: SshRunResult = { code: 255, stdout: "", stderr: "" }; +const OCX_CHILD_SELECTED: SshRunResult = { ...SYNCED, stderr: "ocx-fanout:selected:2.98.0\nocx-fanout:selected-kind:ocx-child\n" }; +const RESULT = { schema: "ocx.update.result/1", applied: true, fromVersion: "2.98.0", toVersion: "2.100.0", installer: "bun", restarted: true, healthy: true, rolledBack: false }; +const applied = (code: number, result: Record = RESULT): SshRunResult => + ({ code, stdout: "ocx-fanout:stdout-begin\n" + JSON.stringify(result) + "\n", stderr: "ocx-fanout:selected:2.98.0\nocx-fanout:entry:pair\n" }); +const timeout = () => new SshRunnerError("timeout", "ssh timed out"); + +function classify(remote: string): Kind { + if (remote.includes("%OS%")) return "winprobe"; + if (remote.includes("'ocx-platform'")) return "uname"; + if (remote.includes("powershell")) return "win"; + if (remote.includes("'--restart-app-server-only'") || remote.includes("'--restart-codex'")) return "restart"; + if (remote.includes("'sync'")) return "sync"; + if (remote.includes("'--plan'")) return "plan"; + if (remote.includes("'--yes'")) return "apply"; + throw new Error("unclassified remote command"); +} + +/** A fake SSH runner: each kind answers from its queue (the last answer repeats); every call is recorded. */ +function fakeSsh(script: Partial> = {}) { + const calls: Call[] = []; + const queues = new Map(Object.entries(script).map(([kind, answers]) => [kind, [...answers!]])); + return { + calls, kinds: () => calls.map(call => call.kind), + runner: { async run(argv: string[], options?: { timeoutMs?: number }) { + const remote = argv.at(-1)!; + const kind = classify(remote); + calls.push({ kind, remote, timeoutMs: options?.timeoutMs }); + const queue = queues.get(kind); + if (!queue || queue.length === 0) throw new Error("no scripted answer for " + kind); + const answer = queue.length > 1 ? queue.shift()! : queue[0]!; + if (answer instanceof Error) throw answer; + return answer; + } }, + }; +} + +function record(overrides: Partial = {}): LinkRecord { + return { id: LINK, alias: "child-01", direction: "hub-initiated", hostKeyFingerprint: "SHA256:abcdefghijklmnopqrstuvwxyz012345", + tunnelPort: 41000, apiKeyId: "key-01", createdAt: "2026-11-01T00:00:00.000Z", remotePlatform: "posix", ...overrides }; +} + +function plan(overrides: Partial = {}): PlanClassification { + return { installKind: "bun", updateRoute: "orchestrated", updateReason: null, installedVersion: "2.98.0", targetVersion: "2.100.0", + runtimeOwner: "service", planToken: TOKEN, updateInFlight: false, mutationBoundMs: 900_000, ...overrides }; +} +function observedPlan(classification: PlanClassification, probe: Partial> = {}): ObserveOutcome { + return { state: "succeeded", reason: null, observation: null, + probe: { classification, childNowMs: CHILD_NOW, startedAt: NOW, platform: "posix", ...probe } }; +} + +function makeEnv(ssh: ReturnType, overrides: Partial = {}) { + const writes: Array> = []; + const sleeps: number[] = []; + const observes: LinkRecord[] = []; + const env: ChildOpsEnv = { + readStore: () => ({ version: 1, listenerPort: 18181, links: [record()] }), runner: ssh.runner, knownHostsFile: "/tmp/ocx-test-known-hosts", + wireState: () => "connected", activity: () => ({ idle: true, inFlight: 0 }), homeVersion: "2.100.0", now: () => NOW, + sleep: async ms => { sleeps.push(ms); }, observe: async () => { throw new Error("observe not scripted"); }, + readObservation: () => null, writeObservation: (_linkId, patch) => { writes.push(patch); }, catalogEtag: () => null, ...overrides, + }; + const observe = overrides.observe; + if (observe) env.observe = async target => { observes.push(target); return observe(target); }; + return { env, writes, sleeps, observes }; +} +const job = (op: "sync" | "restart-codex" | "update" | "observe", restart: ChildRestart = "none", trigger: ChildTrigger = "manual") => ({ op, restart, trigger }); +const run = (runner: ItemRunner, target: LinkRecord, j: ReturnType, env: ChildOpsEnv) => runner(target, j, env); + +describe("observe runner", () => { + test("writes the observation without carried fields; a throwing observe is failed/observe-error", async () => { + const observation: ChildObservation = { observedAt: "2026-11-11T00:00:00.000Z", source: "plan", installedVersion: "2.98.0", installKind: "bun", + runtimeOwner: "service", updateRoute: "orchestrated", updateReason: null, targetVersion: "2.100.0", catalogPulledAt: 1, lastOperation: null, + suppressedTarget: null, catalogPulledEtag: null, catalogSyncedEtag: null, autoUpdateAttempts: null, updateInFlight: false, pendingUpdateRecovery: null }; + const { env, writes } = makeEnv(fakeSsh(), { observe: async () => ({ state: "succeeded", reason: null, observation, probe: null }) }); + expect(await run(runObserveItem, record(), job("observe"), env)).toEqual({ state: "succeeded", reason: null }); + expect(Object.keys(writes[0]!).sort()).toEqual(["installKind", "installedVersion", "observedAt", "runtimeOwner", "source", "targetVersion", "updateInFlight", "updateReason", "updateRoute"]); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const failing = makeEnv(fakeSsh(), { observe: async () => { throw new TypeError("x"); } }); + expect(await run(runObserveItem, record(), job("observe"), failing.env)).toEqual({ state: "failed", reason: "observe-error" }); + } finally { warn.mockRestore(); } + }); +}); + +describe("sync runner", () => { + test("a plain sync sends the pinned sync once and succeeds", async () => { + const ssh = fakeSsh({ sync: [SYNCED] }); + const { env } = makeEnv(ssh); + expect(await run(runSyncItem, record(), job("sync"), env)).toEqual({ state: "succeeded", reason: null, sync: { state: "synced", reason: null } }); + expect(ssh.kinds()).toEqual(["sync"]); + expect(ssh.calls[0]!.remote).toContain("'--expect-link' '" + LINK + "' '--json'"); + expect(ssh.calls[0]!.timeoutMs).toBe(180_000); + }); + + test("restart app-server: a plain sync first, then the restarting sync ending with --json --restart-app-server-only", async () => { + const ssh = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + const { env } = makeEnv(ssh); + const outcome = await run(runSyncItem, record(), job("sync", "app-server"), env); + expect(outcome).toMatchObject({ state: "succeeded", reason: null, restart: { requested: "app-server", outcome: "requested" } }); + expect(ssh.kinds()).toEqual(["sync", "restart"]); + expect(ssh.calls[0]!.remote).not.toContain("--restart"); + expect(ssh.calls[1]!.remote.endsWith("'--json' '--restart-app-server-only'")).toBe(true); + }); + + test("codex scope sends --restart-codex", async () => { + const ssh = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + const { env } = makeEnv(ssh); + await run(runSyncItem, record(), job("sync", "codex"), env); + expect(ssh.calls[1]!.remote.endsWith("'--json' '--restart-codex'")).toBe(true); + }); + + test("the restarting sync reads the selected-kind marker; a plain sync ignores it", async () => { + const restarting = fakeSsh({ sync: [SYNCED], restart: [OCX_CHILD_SELECTED] }); + expect(await run(runSyncItem, record(), job("sync", "app-server"), makeEnv(restarting).env)) + .toMatchObject({ state: "succeeded", restart: { requested: "app-server", outcome: "unsupported" } }); + const plain = fakeSsh({ sync: [OCX_CHILD_SELECTED] }); + expect(await run(runSyncItem, record(), job("sync"), makeEnv(plain).env)).toEqual({ state: "succeeded", reason: null, sync: { state: "synced", reason: null } }); + }); + + test("Windows and ephemeral Children get no restart flag and report restart unsupported", async () => { + const windows = fakeSsh({ win: [SYNCED] }); + expect(await run(runSyncItem, record({ remotePlatform: "windows" }), job("sync", "app-server"), makeEnv(windows).env)) + .toMatchObject({ state: "succeeded", restart: { requested: "app-server", outcome: "unsupported" } }); + expect(windows.kinds()).toEqual(["win"]); + const ephemeral = fakeSsh({ sync: [SYNCED] }); + expect(await run(runSyncItem, record({ childKind: "ephemeral" }), job("sync", "codex"), makeEnv(ephemeral).env)) + .toMatchObject({ state: "succeeded", restart: { requested: "codex", outcome: "unsupported" } }); + expect(ephemeral.kinds()).toEqual(["sync"]); + }); + + test.each([ + ["exit 7", UPDATING, { state: "busy", reason: "child-updating", hint: "Wait for the Child update to finish, then retry sync." }], + ["exit 5 mismatch", MISMATCH, { state: "refused", reason: "child-link-mismatch:other_link" }], + ["exit 121 lacks expect-link", LACKS_EXPECT_LINK, { state: "owner-action", reason: "child-ocx-lacks-expect-link" }], + ["exit 255", DROPPED, { state: "offline", reason: "ssh-failed" }], + ["runner timeout", timeout(), { state: "uncertain", reason: "deadline-exceeded" }], + ] as const)("%s", async (_name, answer, expected) => { + const ssh = fakeSsh({ sync: [answer] }); + expect(await run(runSyncItem, record(), job("sync"), makeEnv(ssh).env)).toMatchObject(expected); + }); + + test("a reconnecting tunnel or a link missing from the runtime is offline with no SSH", async () => { + const ssh = fakeSsh(); + expect(await run(runSyncItem, record(), job("sync"), makeEnv(ssh, { wireState: () => "reconnecting" }).env)).toEqual({ state: "offline", reason: "tunnel-reconnecting" }); + expect(await run(runSyncItem, record(), job("sync"), makeEnv(ssh, { wireState: () => null }).env)).toEqual({ state: "offline", reason: "link-not-in-runtime" }); + expect(ssh.calls).toEqual([]); + }); + + test("a record without remotePlatform probes the platform first", async () => { + const ssh = fakeSsh({ winprobe: [{ code: 1, stdout: "", stderr: "" }], uname: [{ code: 0, stdout: "Linux\n", stderr: "" }], sync: [SYNCED] }); + expect(await run(runSyncItem, record({ remotePlatform: undefined }), job("sync"), makeEnv(ssh).env)).toMatchObject({ state: "succeeded" }); + expect(ssh.kinds()).toEqual(["winprobe", "uname", "sync"]); + }); + + test("a synced item records the catalog etag read before the sync", async () => { + const etags = ["\"A\"", "\"B\""]; + const ssh = fakeSsh({ sync: [SYNCED] }); + const { env, writes } = makeEnv(ssh, { catalogEtag: () => etags.shift() ?? null }); + await run(runSyncItem, record(), job("sync"), env); + expect(writes).toEqual([{ catalogSyncedEtag: "\"A\"" }]); + const failed = makeEnv(fakeSsh({ sync: [DROPPED] }), { catalogEtag: () => "\"C\"" }); + await run(runSyncItem, record(), job("sync"), failed.env); + expect(failed.writes).toEqual([]); + }); + + test("an automatic sync re-asks eligibility when its item starts", async () => { + const ssh = fakeSsh({ sync: [SYNCED] }); + expect(await run(runSyncItem, record(), job("sync", "none", "auto"), makeEnv(ssh, { autoSyncAllowed: () => false }).env)).toEqual({ state: "busy", reason: "link-active" }); + expect(ssh.calls).toEqual([]); + // A manual sync never asks. + expect(await run(runSyncItem, record(), job("sync"), makeEnv(ssh, { autoSyncAllowed: () => false }).env)).toMatchObject({ state: "succeeded" }); + }); +}); + +describe("restart guard (A2, M8)", () => { + test("a request in flight withholds the restart, never the sync", async () => { + const ssh = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + expect(await run(runSyncItem, record(), job("sync", "app-server"), makeEnv(ssh, { activity: () => ({ idle: false, inFlight: 1 }) }).env)) + .toEqual({ state: "succeeded", reason: "restart-skipped:link-active", sync: { state: "synced", reason: null }, restart: { requested: "app-server", outcome: "skipped" } }); + expect(ssh.kinds()).toEqual(["sync"]); + }); + + test("unknown activity counts as in flight; an idle link gets the flag", async () => { + const unknown = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + expect(await run(runSyncItem, record(), job("sync", "app-server"), makeEnv(unknown, { activity: () => null }).env)).toMatchObject({ restart: { outcome: "skipped" } }); + expect(unknown.kinds()).toEqual(["sync"]); + const idle = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + expect(await run(runSyncItem, record(), job("sync", "app-server"), makeEnv(idle).env)).toMatchObject({ restart: { outcome: "requested" } }); + expect(idle.kinds()).toEqual(["sync", "restart"]); + }); + + test("activity is read after the plain sync: a request that starts during it withholds the restart", async () => { + let inFlight = 0; + const ssh = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + const original = ssh.runner.run; + ssh.runner.run = async (argv, options) => { const result = await original(argv, options); inFlight = 1; return result; }; + const outcome = await run(runSyncItem, record(), job("sync", "codex"), makeEnv(ssh, { activity: () => ({ idle: inFlight === 0, inFlight }) }).env); + expect(outcome).toMatchObject({ state: "succeeded", reason: "restart-skipped:link-active", restart: { requested: "codex", outcome: "skipped" } }); + expect(ssh.kinds()).toEqual(["sync"]); + }); + + test("restart-codex on a busy link sends nothing", async () => { + const ssh = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + expect(await run(runRestartCodexItem, record(), job("restart-codex", "app-server"), makeEnv(ssh, { activity: () => ({ idle: false, inFlight: 2 }) }).env)) + .toEqual({ state: "busy", reason: "restart-skipped:link-active", restart: { requested: "app-server", outcome: "skipped" } }); + expect(ssh.calls).toEqual([]); + }); + + test("restart-codex: the request starting between the two calls leaves the item busy after one sync", async () => { + let inFlight = 0; + const ssh = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + const original = ssh.runner.run; + ssh.runner.run = async (argv, options) => { const result = await original(argv, options); inFlight = 1; return result; }; + expect(await run(runRestartCodexItem, record(), job("restart-codex", "app-server"), makeEnv(ssh, { activity: () => ({ idle: inFlight === 0, inFlight }) }).env)) + .toMatchObject({ state: "busy", reason: "restart-skipped:link-active", restart: { outcome: "skipped" } }); + expect(ssh.kinds()).toEqual(["sync"]); + }); +}); + +describe("restart result (Revision 6 F2)", () => { + test.each([ + ["a dropped channel (255)", DROPPED], + ["a timeout", timeout()], + ["a failure after selection", { code: 1, stdout: "ocx-fanout:stdout-begin\n", stderr: "ocx-fanout:selected:2.98.0\n" }], + ] as const)("%s on the restarting sync is uncertain/restart-result-unknown with restart requested", async (_name, answer) => { + for (const runner of [runSyncItem, runRestartCodexItem]) { + const ssh = fakeSsh({ sync: [SYNCED], restart: [answer] }); + const outcome = await run(runner, record(), job(runner === runSyncItem ? "sync" : "restart-codex", "app-server"), makeEnv(ssh).env); + expect(outcome).toMatchObject({ state: "uncertain", reason: "restart-result-unknown", restart: { requested: "app-server", outcome: "requested" } }); + } + }); + + test.each([ + ["a gate refusal before selection (121)", { code: 121, stdout: "", stderr: "ocx-fanout:child-ocx-version-unknown\n" }], + ["no ocx found (127)", { code: 127, stdout: "", stderr: "" }], + ["a skipped sync (exit 7)", UPDATING], + ["an SSH spawn failure", new SshRunnerError("spawn", "no ssh")], + ] as const)("%s means the restart did not run", async (_name, answer) => { + const ssh = fakeSsh({ sync: [SYNCED], restart: [answer] }); + expect(await run(runSyncItem, record(), job("sync", "app-server"), makeEnv(ssh).env)) + .toMatchObject({ state: "succeeded", reason: null, sync: { state: "synced" }, restart: { requested: "app-server", outcome: "not-run" } }); + }); +}); + +describe("restart-codex runner", () => { + test("a recorded Windows Child is refused with no SSH; a probed one after the probe", async () => { + const none = fakeSsh(); + expect(await run(runRestartCodexItem, record({ remotePlatform: "windows" }), job("restart-codex", "app-server"), makeEnv(none).env)) + .toEqual({ state: "unsupported", reason: "windows-restart", restart: { requested: "app-server", outcome: "unsupported" } }); + expect(none.calls).toEqual([]); + const probed = fakeSsh({ winprobe: [{ code: 0, stdout: "Windows_NT\r\n", stderr: "" }] }); + expect(await run(runRestartCodexItem, record({ remotePlatform: undefined }), job("restart-codex", "codex"), makeEnv(probed).env)) + .toMatchObject({ state: "unsupported", reason: "windows-restart" }); + expect(probed.kinds()).toEqual(["winprobe"]); + }); + + test("a POSIX Child gets the plain sync, then the restart; a failed sync leaves the restart not-run", async () => { + const ssh = fakeSsh({ sync: [SYNCED], restart: [SYNCED] }); + expect(await run(runRestartCodexItem, record(), job("restart-codex", "app-server"), makeEnv(ssh).env)) + .toEqual({ state: "succeeded", reason: null, sync: { state: "synced", reason: null }, restart: { requested: "app-server", outcome: "requested" } }); + expect(ssh.kinds()).toEqual(["sync", "restart"]); + const failed = fakeSsh({ sync: [{ code: 1, stdout: "ocx-fanout:stdout-begin\n", stderr: "ocx-fanout:selected:2.98.0\n" }] }); + expect(await run(runRestartCodexItem, record(), job("restart-codex", "app-server"), makeEnv(failed).env)) + .toMatchObject({ state: "failed", reason: "child-sync-failed", restart: { requested: "app-server", outcome: "not-run" } }); + expect(failed.kinds()).toEqual(["sync"]); + }); + + test("ocx-child: an ephemeral record syncs without the flag; an unmarked one reports unsupported from the marker", async () => { + const ephemeral = fakeSsh({ sync: [SYNCED] }); + expect(await run(runRestartCodexItem, record({ childKind: "ephemeral" }), job("restart-codex", "app-server"), makeEnv(ephemeral).env)) + .toMatchObject({ state: "unsupported", reason: "ephemeral-restart", restart: { outcome: "unsupported" } }); + expect(ephemeral.kinds()).toEqual(["sync"]); + const unmarked = fakeSsh({ sync: [SYNCED], restart: [OCX_CHILD_SELECTED] }); + expect(await run(runRestartCodexItem, record(), job("restart-codex", "app-server"), makeEnv(unmarked).env)) + .toMatchObject({ state: "unsupported", reason: "ephemeral-restart", restart: { outcome: "unsupported" } }); + }); +}); + +describe("update runner", () => { + const updateEnv = (ssh: ReturnType, outcome: ObserveOutcome, overrides: Partial = {}) => + makeEnv(ssh, { observe: async () => outcome, ...overrides }); + const ok = () => fakeSsh({ sync: [SYNCED], apply: [applied(0)] }); + + test("an ephemeral record is unsupported with no SSH", async () => { + const ssh = fakeSsh(); + expect(await run(runUpdateItem, record({ childKind: "ephemeral" }), job("update"), makeEnv(ssh).env)).toEqual({ state: "unsupported", reason: "ephemeral" }); + expect(ssh.calls).toEqual([]); + }); + + test("the identity sync runs first; a mismatch refuses before the plan and the apply", async () => { + const ssh = fakeSsh({ sync: [MISMATCH] }); + const { env, observes } = updateEnv(ssh, observedPlan(plan())); + expect(await run(runUpdateItem, record(), job("update"), env)).toMatchObject({ state: "refused", reason: "child-link-mismatch:other_link" }); + expect(ssh.kinds()).toEqual(["sync"]); + expect(observes).toEqual([]); + }); + + test("an observe outcome other than a plan passes through", async () => { + const ssh = fakeSsh({ sync: [SYNCED] }); + expect(await run(runUpdateItem, record(), job("update"), updateEnv(ssh, { state: "offline", reason: "ssh-failed", observation: null, probe: null }).env)) + .toEqual({ state: "offline", reason: "ssh-failed" }); + expect(await run(runUpdateItem, record(), job("update"), updateEnv(ssh, { state: "owner-action", reason: "legacy-ocx", observation: null, probe: null }).env)) + .toEqual({ state: "owner-action", reason: "legacy-ocx" }); + // 4529522000: an ocx-child selected without childKind observes succeeded with no probe. + expect(await run(runUpdateItem, record(), job("update"), updateEnv(ssh, { state: "succeeded", reason: null, observation: null, probe: null }).env)) + .toEqual({ state: "unsupported", reason: "ephemeral" }); + expect(ssh.kinds()).toEqual(["sync", "sync", "sync"]); + }); + + test.each([ + ["up-to-date", plan({ updateRoute: "none", updateReason: "up-to-date", installedVersion: "2.100.0" }), { state: "up-to-date", reason: null, fromVersion: "2.100.0", toVersion: "2.100.0" }], + ["a refused plan", plan({ updateRoute: "none", updateReason: "refused:downgrade-refused" }), { state: "refused", reason: "refused:downgrade-refused" }], + ["GOAT", plan({ installKind: "goat", updateRoute: "owner-action", updateReason: "goat" }), { state: "owner-action", reason: "goat" }], + ["launcher-node-missing", plan({ installKind: "npm", updateRoute: "owner-action", updateReason: "launcher-node-missing" }), + { state: "owner-action", reason: "launcher-node-missing", hint: "Install Node.js where the npm or pnpm that installed OpenCodex on child-01 can find it, or update there with ocx update." }], + ["desktop request", plan({ installKind: "desktop-app", updateRoute: "desktop-request", updateReason: "desktop-app-owned" }), { state: "owner-action", reason: "desktop-app" }], + ["update in flight", plan({ updateInFlight: true }), { state: "busy", reason: "update-in-flight" }], + ] as const)("%s sends no apply", async (_name, classification, expected) => { + const ssh = ok(); + expect(await run(runUpdateItem, record(), job("update"), updateEnv(ssh, observedPlan(classification)).env)).toMatchObject(expected); + expect(ssh.kinds()).toEqual(["sync"]); + }); + + test("automatic: a suppressed target is refused and a busy link waits; manual clears the suppression", async () => { + const suppressed = { suppressedTarget: "2.100.0" } as ChildObservation; + const auto = ok(); + expect(await run(runUpdateItem, record(), job("update", "none", "auto"), updateEnv(auto, observedPlan(plan()), { readObservation: () => suppressed }).env)) + .toMatchObject({ state: "refused", reason: "target-failed-before" }); + expect(await run(runUpdateItem, record(), job("update", "none", "auto"), updateEnv(auto, observedPlan(plan()), { activity: () => ({ idle: false, inFlight: 0 }) }).env)) + .toMatchObject({ state: "busy", reason: "link-active" }); + expect(auto.kinds()).toEqual(["sync", "sync"]); + const manual = ok(); + const { env, writes } = updateEnv(manual, observedPlan(plan()), { readObservation: () => suppressed, activity: () => ({ idle: false, inFlight: 3 }) }); + expect(await run(runUpdateItem, record(), job("update"), env)).toMatchObject({ state: "succeeded" }); + expect(writes[0]).toEqual({ suppressedTarget: null }); + expect(manual.kinds()).toEqual(["sync", "apply", "sync"]); + }); + + test("a success: apply bounded by mutationBoundMs + 120 s, deadline on the Child's clock, then the pinned sync", async () => { + const ssh = ok(); + const { env, writes } = updateEnv(ssh, observedPlan(plan({ mutationBoundMs: 600_000 })), { now: () => NOW + 5_000 }); + const outcome = await run(runUpdateItem, record(), job("update"), env); + expect(outcome).toEqual({ state: "succeeded", reason: null, fromVersion: "2.98.0", toVersion: "2.100.0", sync: { state: "synced", reason: null } }); + expect(ssh.kinds()).toEqual(["sync", "apply", "sync"]); + const apply = ssh.calls[1]!; + expect(apply.timeoutMs).toBe(600_000 + APPLY_GRACE_MS); + expect(apply.remote).toContain("'update' '--yes' '--json' '--version' '2.100.0' '--expect-plan' '" + TOKEN + "' '--not-after' '" + (CHILD_NOW + 120_000) + "'"); + expect(apply.remote).toContain("launcher=none"); + expect(writes.at(-1)).toMatchObject({ installedVersion: "2.100.0", targetVersion: "2.100.0", updateRoute: "none", updateReason: "up-to-date", source: "plan" }); + }); + + test("A4: npm and pnpm apply through the launcher, bun does not", async () => { + for (const [installKind, launcher] of [["npm", "launcher=apply"], ["pnpm", "launcher=apply"], ["bun", "launcher=none"]] as const) { + const ssh = ok(); + await run(runUpdateItem, record(), job("update"), updateEnv(ssh, observedPlan(plan({ installKind }))).env); + expect(ssh.calls[1]!.remote).toContain(launcher); + } + }); + + test("Child clock (M9): no marker falls back to the Home clock; a late plan is not applied", async () => { + const homeClock = ok(); + await run(runUpdateItem, record(), job("update"), updateEnv(homeClock, observedPlan(plan(), { childNowMs: null }), { now: () => NOW + 5_000 }).env); + expect(homeClock.calls[1]!.remote).toContain("'--not-after' '" + (NOW + 120_000) + "'"); + const late = ok(); + expect(await run(runUpdateItem, record(), job("update"), updateEnv(late, observedPlan(plan()), { now: () => NOW + 110_000 }).env)) + .toMatchObject({ state: "busy", reason: "plan-expired" }); + expect(late.kinds()).toEqual(["sync"]); + }); + + test("deadline-passed carries the clock-skew hint only on the Home-clock fallback", async () => { + const answer = applied(10, { ...RESULT, applied: false, healthy: false, restarted: false, toVersion: null, error: "deadline-passed" }); + const homeClock = fakeSsh({ sync: [SYNCED], apply: [answer] }); + expect(await run(runUpdateItem, record(), job("update"), updateEnv(homeClock, observedPlan(plan(), { childNowMs: null })).env)) + .toMatchObject({ state: "refused", reason: "deadline-passed", hint: "The clocks of this Home and child-01 differ (clock-skew); correct the Child's clock or run the update again." }); + const childClock = fakeSsh({ sync: [SYNCED], apply: [answer] }); + const outcome = await run(runUpdateItem, record(), job("update"), updateEnv(childClock, observedPlan(plan())).env); + expect(outcome).toMatchObject({ state: "refused", reason: "deadline-passed" }); + expect(outcome.hint).toBeUndefined(); + }); + + test("the post-update sync retries once after 30 s when the Child is still updating; a failed one keeps the update", async () => { + const retry = fakeSsh({ sync: [SYNCED, UPDATING, SYNCED], apply: [applied(0)] }); + const first = updateEnv(retry, observedPlan(plan())); + expect(await run(runUpdateItem, record(), job("update"), first.env)).toMatchObject({ state: "succeeded", reason: null, sync: { state: "synced" } }); + expect(first.sleeps).toEqual([POST_UPDATE_SYNC_RETRY_MS]); + expect(retry.kinds()).toEqual(["sync", "apply", "sync", "sync"]); + const failing = fakeSsh({ sync: [SYNCED, DROPPED], apply: [applied(0)] }); + expect(await run(runUpdateItem, record(), job("update"), updateEnv(failing, observedPlan(plan())).env)) + .toMatchObject({ state: "succeeded", reason: "post-update-sync-failed", hint: "Run ocx link sync --link-id " + LINK + " to restore routing.", sync: { state: "failed", reason: "ssh-failed" } }); + }); + + test.each([ + ["exit 0 not applied", applied(0, { ...RESULT, applied: false, toVersion: "2.100.0" }), { state: "up-to-date", reason: null }, ["sync", "apply"]], + ["exit 10 update-in-flight", applied(10, { ...RESULT, applied: false, error: "update-in-flight" }), { state: "busy", reason: "update-in-flight" }, ["sync", "apply"]], + ["exit 10 another refusal", applied(10, { ...RESULT, applied: false, error: "plan-mismatch" }), { state: "refused", reason: "plan-mismatch" }, ["sync", "apply"]], + ["exit 12", applied(12, { ...RESULT, healthy: false }), { state: "uncertain", reason: "outcome-unverified" }, ["sync", "apply"]], + ["exit 255 mid-apply", DROPPED, { state: "uncertain", reason: "ssh-failed" }, ["sync", "apply"]], + ["exit 121 legacy", { code: 121, stdout: "", stderr: "ocx-fanout:child-ocx-lacks-orchestrated-update\n" }, { state: "owner-action", reason: "legacy-ocx" }, ["sync", "apply"]], + ["exit 121 child-node-unavailable", { code: 121, stdout: "", stderr: "ocx-fanout:selected:2.98.0\nocx-fanout:entry:pair\nocx-fanout:child-node-unavailable\n" }, + { state: "owner-action", reason: "launcher-node-missing", hint: "Install Node.js where the npm or pnpm that installed OpenCodex on child-01 can find it, or update there with ocx update." }, ["sync", "apply"]], + ["exit 127", { code: 127, stdout: "", stderr: "" }, { state: "failed", reason: "child-ocx-not-found" }, ["sync", "apply", "sync"]], + ] as const)("%s", async (_name, answer, expected, kinds) => { + const ssh = fakeSsh({ sync: [SYNCED], apply: [answer] }); + expect(await run(runUpdateItem, record(), job("update"), updateEnv(ssh, observedPlan(plan())).env)).toMatchObject(expected); + expect(ssh.kinds()).toEqual([...kinds]); + }); + + test("a rolled-back apply suppresses the target and resyncs without a restart", async () => { + const ssh = fakeSsh({ sync: [SYNCED], apply: [applied(11, { ...RESULT, healthy: false, rolledBack: true })] }); + const { env, writes } = updateEnv(ssh, observedPlan(plan())); + expect(await run(runUpdateItem, record(), job("update", "app-server"), env)) + .toEqual({ state: "failed", reason: "rolled-back", fromVersion: "2.98.0", toVersion: "2.100.0", sync: { state: "synced", reason: null } }); + expect(writes).toContainEqual({ suppressedTarget: "2.100.0" }); + expect(ssh.kinds()).toEqual(["sync", "apply", "sync"]); + expect(ssh.calls[2]!.remote).not.toContain("--restart"); + }); + + test("uncertain applies are recorded for recovery and never applied twice", async () => { + for (const answer of [timeout(), new SshRunnerError("output_limit", "x"), DROPPED, applied(12, { ...RESULT, healthy: false })]) { + const ssh = fakeSsh({ sync: [SYNCED], apply: [answer] }); + const { env, writes } = updateEnv(ssh, observedPlan(plan())); + expect((await run(runUpdateItem, record(), job("update"), env)).state).toBe("uncertain"); + expect(writes).toContainEqual({ pendingUpdateRecovery: { target: "2.100.0", since: new Date(NOW).toISOString(), + until: new Date(NOW + UNCERTAIN_RECOVERY_WINDOW_MS).toISOString() } }); + expect(ssh.kinds().filter(kind => kind === "apply")).toHaveLength(1); + } + }); + + test("F8: an apply argv the builder refuses fails before anything is sent and records no recovery", async () => { + const ssh = ok(); + const { env, writes } = updateEnv(ssh, observedPlan(plan({ planToken: "short" }))); + expect(await run(runUpdateItem, record(), job("update"), env)).toMatchObject({ state: "failed", reason: "ssh-argument-invalid" }); + expect(ssh.kinds()).toEqual(["sync"]); + expect(writes.some(write => "pendingUpdateRecovery" in write)).toBe(false); + }); + + test("Windows (H4): a recorded Windows Child is observed; orchestrated is refused, desktop-request is not windows-apply", async () => { + const orchestrated = fakeSsh({ win: [SYNCED] }); + const first = updateEnv(orchestrated, observedPlan(plan({ installKind: "npm" }), { platform: "windows" })); + expect(await run(runUpdateItem, record({ remotePlatform: "windows" }), job("update"), first.env)).toMatchObject({ state: "unsupported", reason: "windows-apply" }); + expect(first.observes).toHaveLength(1); + expect(orchestrated.kinds()).toEqual(["win"]); + const desktop = fakeSsh({ win: [SYNCED] }); + expect(await run(runUpdateItem, record({ remotePlatform: "windows" }), job("update"), updateEnv(desktop, + observedPlan(plan({ installKind: "desktop-app", updateRoute: "desktop-request", updateReason: "desktop-app-owned" }), { platform: "windows" })).env)) + .toMatchObject({ state: "owner-action", reason: "desktop-app" }); + }); +}); + +describe("a fake 2.96 Child on the real gated script", () => { + const dirs: string[] = []; + afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + + posix("never receives update --yes, even when the Home believes it can apply", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-runner-296-")); dirs.push(home); + const bin = join(home, "bin"); mkdirSync(bin); + for (const command of ["sh", "uname", "mktemp", "sleep", "cat", "awk", "dd", "wc", "rm", "date"]) symlinkSync(Bun.which(command)!, join(bin, command)); + const ocx = join(home, ".opencodex-desktop", "bin", "ocx"); + mkdirSync(join(ocx, ".."), { recursive: true }); + writeFileSync(ocx, "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'opencodex 2.96.0'; exit 0; fi\nprintf '%s\\n' \"$*\" >> \"$HOME/calls\"\n" + + "if [ \"$1\" = sync ]; then echo 'Remote Link Home catalog synchronized.'; fi\n", { mode: 0o755 }); + const runner = { async run(argv: string[]) { + const remote = argv.at(-1)!.replace('export PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"', 'export PATH="$PATH"') + .replace('"/Applications/OpenCodex.app/Contents/MacOS/ocx"', '"$HOME/missing-app"').replace('"/usr/bin/ocx"', '"$HOME/missing-system"'); + const proc = Bun.spawnSync(["sh", "-c", remote], { env: { HOME: home, OPENCODEX_HOME: join(home, "ocx-home"), TMPDIR: home, PATH: bin }, stdout: "pipe", stderr: "pipe" }); + return { code: proc.exitCode ?? -1, stdout: proc.stdout.toString(), stderr: proc.stderr.toString() }; + } }; + const { env } = makeEnv(fakeSsh(), { runner, observe: async () => observedPlan(plan()) }); + const outcome = await run(runUpdateItem, record(), job("update"), env); + expect(outcome).toMatchObject({ state: "owner-action", reason: "legacy-ocx" }); + const calls = existsSync(join(home, "calls")) ? readFileSync(join(home, "calls"), "utf8") : ""; + expect(calls).toBe("sync --expect-link " + LINK + " --json\n"); + expect(calls).not.toContain("update"); + }); +}); From d39e96407bb7238bb4cb47f9d6876e4b186676e4 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:18:41 +0900 Subject: [PATCH 05/18] test(link): migrate observe and route tests to the shared child ops engine --- tests/clients/link-child-observe.test.ts | 42 ++++++++--- tests/server/link-child-ops-routes.test.ts | 85 +++++++++++++++++++++- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/tests/clients/link-child-observe.test.ts b/tests/clients/link-child-observe.test.ts index 5e2572b501f..338b9b08c4d 100644 --- a/tests/clients/link-child-observe.test.ts +++ b/tests/clients/link-child-observe.test.ts @@ -2,14 +2,15 @@ import { describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import * as gated from "../../src/link/child-gated-ocx"; import { observeChild, type ObserveDeps } from "../../src/link/child-observe"; -import { emptyChildObservations, patchChildObservation, withoutCarriedFields } from "../../src/link/child-observations"; +import { emptyChildObservations, patchChildObservation, withoutCarriedFields, type ChildObservation } from "../../src/link/child-observations"; import { createChildOpsEngine } from "../../src/link/child-ops-engine"; +import type { ChildOpsEnv } from "../../src/link/child-ops-types"; import type { ObserveOutcome } from "../../src/link/child-observe"; import { ConfigMutationLockError } from "../../src/config/mutation-lock"; import type { LinkPeerEntry } from "../../src/link/peer-report"; import type { ParsedRemoteRuntimeInspection } from "../../src/link/remote-plant"; import { SshRunnerError, type SshRunResult } from "../../src/link/ssh-runner"; -import type { LinkRecord } from "../../src/link/store"; +import type { LinkRecord, LinkStore } from "../../src/link/store"; const NOW = new Date("2026-11-11T00:00:00.000Z"); const TOKEN = "AbCdEfGhIjKlMnOpQrStUvWxYz012345_-"; @@ -162,12 +163,31 @@ describe("child ops engine", () => { observedAt: now.toISOString(), source: "peer", installedVersion: null, installKind: "ocx-child", runtimeOwner: null, updateRoute: "replant", updateReason: "ocx-child", targetVersion: null, updateInFlight: null, catalogPulledAt: null, catalogPulledEtag: null, catalogSyncedEtag: null, lastOperation: null, suppressedTarget: null, autoUpdateAttempts: null, pendingUpdateRecovery: null } }); + /** + * wp2's observe-only engine options on wp3's engine (Revision 6 F1): one env per submit whose cache writer never throws, + * logging a failure by error name exactly as the route env does. + */ + function observeEngine(deps: { readStore: () => LinkStore; observe: (target: LinkRecord) => Promise; + writeObservation: (linkId: string, patch: Partial) => void; now?: () => Date; jobId?: () => string }) { + const engine = createChildOpsEngine({ jobId: deps.jobId }); + const now = deps.now ?? (() => NOW); + const env: ChildOpsEnv = { readStore: deps.readStore, runner: { run: async () => { throw new Error("no SSH in observe engine tests"); } }, + knownHostsFile: "/tmp/known_hosts", wireState: () => "connected", activity: () => null, homeVersion: "2.100.0", now: () => now().getTime(), + sleep: async () => {}, observe: deps.observe, readObservation: () => null, catalogEtag: () => null, + writeObservation: (linkId, patch) => { + try { deps.writeObservation(linkId, patch); } catch (error) { + console.warn(`[link] child observation write failed linkId=${linkId} error=${error instanceof Error ? error.name : "unknown"}`); + } + } }; + return { ...engine, submit: (request: { op: "observe"; linkIds: readonly string[] | "all"; trigger: "manual" | "auto" }) => + engine.submit({ ...request, restart: "none" }, env) }; + } test("runs three items at a time and ends refused for a link removed before its turn", async () => { let links = ids.map(id => record({ id })); let running = 0, peak = 0; const releases: Array<() => void> = []; - const engine = createChildOpsEngine({ readStore: () => store(links), now: () => NOW, writeObservation: () => {}, observe: async () => { + const engine = observeEngine({ readStore: () => store(links), now: () => NOW, writeObservation: () => {}, observe: async () => { running += 1; peak = Math.max(peak, running); await new Promise(resolve => releases.push(resolve)); running -= 1; @@ -194,7 +214,7 @@ describe("child ops engine", () => { runtimeOwner: "service", updateRoute: "orchestrated", updateReason: null, targetVersion: "2.100.0", catalogPulledAt: 5, lastOperation: null, suppressedTarget: null, catalogPulledEtag: "\"e\"", catalogSyncedEtag: null, autoUpdateAttempts: null, updateInFlight: true, pendingUpdateRecovery: null }; let clock = NOW.getTime(); - const engine = createChildOpsEngine({ readStore: () => store([record({ id: linkId, childKind: "ephemeral" })]), now: () => new Date(clock), + const engine = observeEngine({ readStore: () => store([record({ id: linkId, childKind: "ephemeral" })]), now: () => new Date(clock), writeObservation: (id, patch) => { clock += 1_000; patchChildObservation(file, id, patch, new Date(clock)); }, observe: async target => observeChild(target, deps(ran(plan())).value) }); engine.submit({ op: "observe", linkIds: [linkId], trigger: "manual" }); @@ -206,7 +226,7 @@ describe("child ops engine", () => { test("a failed cache write is logged and leaves the item's result", async () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); try { - const engine = createChildOpsEngine({ readStore: () => store([record({ id: ids[0]! })]), now: () => NOW, + const engine = observeEngine({ readStore: () => store([record({ id: ids[0]! })]), now: () => NOW, writeObservation: () => { throw new ConfigMutationLockError("busy"); }, observe: async () => succeeded(NOW) }); const submitted = engine.submit({ op: "observe", linkIds: [ids[0]!], trigger: "manual" }); await engine.settled(); @@ -222,7 +242,7 @@ describe("child ops engine", () => { const links = ids.map(id => record({ id })); let running = 0, peak = 0; const releases: Array<() => void> = []; - const engine = createChildOpsEngine({ readStore: () => store(links), now: () => NOW, writeObservation: () => {}, observe: async () => { + const engine = observeEngine({ readStore: () => store(links), now: () => NOW, writeObservation: () => {}, observe: async () => { running += 1; peak = Math.max(peak, running); await new Promise(resolve => releases.push(resolve)); running -= 1; @@ -246,7 +266,7 @@ describe("child ops engine", () => { const writes: Array<{ linkId: string; jobId: string; state: string }> = []; let release!: () => void; const held = new Promise(resolve => { release = resolve; }); - const engine = createChildOpsEngine({ readStore: () => store(links), now: () => NOW, observe: async target => { + const engine = observeEngine({ readStore: () => store(links), now: () => NOW, observe: async target => { if (target.id === ids[0]) await held; return succeeded(NOW); }, writeObservation: (linkId, patch) => { if (patch.lastOperation) writes.push({ linkId, jobId: patch.lastOperation.jobId, state: patch.lastOperation.state }); } }); @@ -263,11 +283,11 @@ describe("child ops engine", () => { test("submit refusals and retention of at most 32 finished jobs for one hour", async () => { let clock = NOW.getTime(); let counter = 0; - const engine = createChildOpsEngine({ readStore: () => store([record({ id: ids[0]! })]), now: () => new Date(clock), writeObservation: () => {}, + const engine = observeEngine({ readStore: () => store([record({ id: ids[0]! })]), now: () => new Date(clock), writeObservation: () => {}, observe: async () => succeeded(NOW), jobId: () => "ljob_" + (counter++).toString(16).padStart(16, "0") }); - expect(engine.submit({ op: "observe", linkIds: [ids[1]!], trigger: "manual" })).toEqual({ ok: false, code: "link_not_found" }); - const empty = createChildOpsEngine({ readStore: () => store([]), writeObservation: () => {}, observe: async () => succeeded(NOW) }); - expect(empty.submit({ op: "observe", linkIds: "all", trigger: "manual" })).toEqual({ ok: false, code: "no_eligible_links" }); + expect(engine.submit({ op: "observe", linkIds: [ids[1]!], trigger: "manual" })).toEqual({ ok: false, code: "link_not_found", linkId: ids[1]! }); + const empty = observeEngine({ readStore: () => store([]), writeObservation: () => {}, observe: async () => succeeded(NOW) }); + expect(empty.submit({ op: "observe", linkIds: "all", trigger: "manual" })).toEqual({ ok: false, code: "no_eligible_links", linkId: null }); for (let index = 0; index < 33; index++) { clock += 1_000; expect(engine.submit({ op: "observe", linkIds: [ids[0]!], trigger: "manual" }).ok).toBe(true); diff --git a/tests/server/link-child-ops-routes.test.ts b/tests/server/link-child-ops-routes.test.ts index 96ba968ebb8..ca8697cfe7d 100644 --- a/tests/server/link-child-ops-routes.test.ts +++ b/tests/server/link-child-ops-routes.test.ts @@ -11,6 +11,7 @@ import type { LinkRecord, LinkStore } from "../../src/link/store"; import type { LinkSupervisor } from "../../src/link/supervisor"; import type { SshRunResult, SshRunner } from "../../src/link/ssh-runner"; import { readChildObservations } from "../../src/link/child-observations"; +import { resetSharedChildOpsEngineForTests, sharedChildOpsEngine } from "../../src/link/child-ops-engine"; import { catalogEtag } from "../../src/server/catalog-download"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -22,6 +23,7 @@ const PLAN = { schema: "ocx.update.plan/1", currentVersion: "2.97.0", targetVers mutationBoundMs: 900_000 }; const PLAN_ANSWER: SshRunResult = { code: 0, stdout: "ocx-fanout:stdout-begin\n" + JSON.stringify(PLAN) + "\n", stderr: "ocx-fanout:selected:2.97.0\nocx-fanout:entry:binary\nocx-fanout:child-now:1762819200000\n" }; +const SYNC_ANSWER: SshRunResult = { code: 0, stdout: "ocx-fanout:stdout-begin\nRemote Link Home catalog synchronized.\n", stderr: "ocx-fanout:selected:2.97.0\n" }; let temp = ""; const saved: Record = {}; @@ -34,7 +36,10 @@ beforeEach(() => { mkdirSync(join(temp, "codex"), { recursive: true }); writeFileSync(join(temp, "codex", "opencodex-catalog.json"), JSON.stringify({ models: [] })); }); -afterEach(() => { +afterEach(async () => { + // The routes reach the process-wide engine through handleManagementAPI; every job ends before the next test. + await sharedChildOpsEngine().settled(); + resetSharedChildOpsEngineForTests(); for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } removeTreeWithRetry(temp); }); @@ -45,7 +50,9 @@ function link(id: string, overrides: Partial = {}): LinkRecord { } function supervisor(): LinkSupervisor { - return { start() {}, ensureStarted: async () => {}, reload: async () => {}, stopLink: async () => {}, status: () => [], stop: async () => {} }; + // HUB's tunnel is connected, so the sync, restart and update runners reach the fake SSH runner. + return { start() {}, ensureStarted: async () => {}, reload: async () => {}, stopLink: async () => {}, stop: async () => {}, + status: () => [{ linkId: HUB, direction: "hub-initiated", state: { kind: "connected", since: 1_762_000_000_000 }, pid: 1 }] }; } function harness(options: { links?: LinkRecord[]; runtimeRole?: OcxConfig["runtimeRole"]; gate?: Promise; full?: boolean; onFlush?: () => void } = {}) { @@ -58,6 +65,9 @@ function harness(options: { links?: LinkRecord[]; runtimeRole?: OcxConfig["runti async run(argv) { sshCalls.push(argv.at(-1) ?? ""); if (options.gate) await options.gate; + const remote = argv.at(-1) ?? ""; + if (remote.includes("'sync'")) return SYNC_ANSWER; + if (remote.includes("'disconnect'")) return { code: 0, stdout: "", stderr: "" }; return PLAN_ANSWER; }, spawnTunnel: () => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill() {} }), @@ -125,8 +135,13 @@ describe("link child operations routes", () => { test("body validation", async () => { const h = harness(); const many = Array.from({ length: 65 }, (_, index) => "lnk_" + index.toString(16).padStart(16, "0")); - for (const body of [{ op: "observe", linkIds: [HUB], extra: 1 }, { op: "sync", linkIds: [HUB] }, { op: "observe", linkIds: many }, - { op: "observe", linkIds: ["lnk_bad"] }, { op: "observe", linkIds: [] }, { op: "observe", linkIds: [HUB, HUB] }, { op: "observe" }, "observe"]) { + for (const body of [{ op: "observe", linkIds: [HUB], extra: 1 }, { op: "observe", linkIds: many }, { op: "restart", linkIds: [HUB] }, + { op: "observe", linkIds: ["lnk_bad"] }, { op: "observe", linkIds: [] }, { op: "observe", linkIds: [HUB, HUB] }, { op: "observe" }, "observe", + { op: "observe", linkIds: [HUB], restart: "app-server" }, { op: "sync", linkIds: [HUB], restart: "bogus" }, + // No body can claim an automatic trigger: trigger is an unknown key. + { op: "update", linkIds: [HUB], trigger: "auto" }, + // A2 / M8: any request that carries a restart names its links. + { op: "restart-codex", linkIds: "all" }, { op: "sync", restart: "app-server", linkIds: "all" }, { op: "update", restart: "codex", linkIds: "all" }]) { const response = await h.call("/api/link/children/operations", "POST", body); expect(response?.status).toBe(400); expect(((await response!.json()) as { error: { code: string } }).error.code).toBe("invalid_body"); @@ -134,6 +149,68 @@ describe("link child operations routes", () => { expect(h.sshCalls).toEqual([]); }); + test("sync runs the pinned sync to a succeeded item; restart-codex defaults to app-server", async () => { + const h = harness({ full: true }); + const response = await h.call("/api/link/children/operations", "POST", { op: "sync", linkIds: [HUB] }); + expect(response?.status).toBe(202); + const { job } = await response!.json() as { job: { jobId: string; op: string; trigger: string; restart: string } }; + expect(job).toMatchObject({ op: "sync", trigger: "manual", restart: "none" }); + const done = await pollDone(h, job.jobId) as unknown as { items: Array> }; + expect(done.items[0]).toMatchObject({ linkId: HUB, state: "succeeded", reason: null, sync: { state: "synced", reason: null } }); + expect(h.sshCalls.map(call => call.includes("'--expect-link' '" + HUB + "' '--json'"))).toEqual([true]); + expect(readChildObservations().children[HUB]?.lastOperation).toMatchObject({ op: "sync", state: "succeeded" }); + const restart = await h.call("/api/link/children/operations", "POST", { op: "restart-codex", linkIds: [HUB] }); + const restartJob = ((await restart!.json()) as { job: { jobId: string; restart: string } }).job; + expect(restartJob.restart).toBe("app-server"); + const restarted = await pollDone(h, restartJob.jobId) as unknown as { items: Array> }; + expect(restarted.items[0]).toMatchObject({ state: "succeeded", restart: { requested: "app-server", outcome: "requested" } }); + expect(h.sshCalls.at(-1)!.endsWith("'--json' '--restart-app-server-only'")).toBe(true); + expect((await h.call("/api/link/children/operations", "POST", { op: "sync", linkIds: "all" }))?.status).toBe(202); + }); + + test("Child-initiated, empty-store and Child-runtime refusals", async () => { + const h = harness(); + for (const op of ["sync", "restart-codex", "update"]) { + const refused = await h.call("/api/link/children/operations", "POST", { op, linkIds: [CLIENT] }); + expect(refused?.status).toBe(409); + expect(((await refused!.json()) as { error: { code: string } }).error.code).toBe("link_not_home_initiated"); + } + const observed = await h.call("/api/link/children/operations", "POST", { op: "observe", linkIds: [CLIENT] }); + expect(observed?.status).toBe(202); + await pollDone(h, ((await observed!.json()) as { job: { jobId: string } }).job.jobId); + const empty = harness({ links: [] }); + const none = await empty.call("/api/link/children/operations", "POST", { op: "sync", linkIds: "all" }); + expect(none?.status).toBe(409); + expect(await none!.json()).toEqual({ error: { code: "no_eligible_links", message: "This Home has no links." } }); + const child = harness({ runtimeRole: "client" }); + for (const op of ["observe", "sync", "restart-codex", "update"]) { + const refused = await child.call("/api/link/children/operations", "POST", { op, linkIds: [HUB] }); + expect(refused?.status).toBe(409); + expect(((await refused!.json()) as { error: { code: string } }).error.code).toBe("runtime_role_client"); + } + expect(h.sshCalls).toEqual([]); + }); + + test("DELETE answers 409 while a job holds the link and removes it once the job is done", async () => { + let open!: () => void; + const gate = new Promise(resolve => { open = resolve; }); + const h = harness({ gate }); + const started = await h.call("/api/link/children/operations", "POST", { op: "sync", linkIds: [HUB] }); + const jobId = ((await started!.json()) as { job: { jobId: string } }).job.jobId; + const held = await h.call("/api/link/" + HUB, "DELETE"); + expect(held?.status).toBe(409); + expect(((await held!.json()) as { error: { code: string } }).error.code).toBe("link_operation_in_progress"); + expect(h.events).toEqual([]); + expect(h.sshCalls.filter(call => call.includes("'disconnect'"))).toEqual([]); + open(); + await pollDone(h, jobId); + const removed = await h.call("/api/link/" + HUB, "DELETE"); + expect(removed?.status).toBe(200); + expect(h.sshCalls.filter(call => call.includes("'disconnect'"))).toHaveLength(1); + expect(h.events).toEqual(["revoke", "store"]); + expect(h.store.links.map(record => record.id)).toEqual([CLIENT]); + }); + test("unknown link → 404; a Child runtime → 409", async () => { const h = harness(); const missing = await h.call("/api/link/children/operations", "POST", { op: "observe", linkIds: ["lnk_1111111111111111"] }); From 31caa214ac898d8e460ff14622ca71f2eb5d9022 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:18:53 +0900 Subject: [PATCH 06/18] fix(link): keep a Child observation whose last operation reason carries an underscore --- src/link/child-observations.ts | 3 ++- tests/clients/link-child-observations.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/link/child-observations.ts b/src/link/child-observations.ts index 9a11df32e9e..6bcb95bc136 100644 --- a/src/link/child-observations.ts +++ b/src/link/child-observations.ts @@ -52,7 +52,8 @@ export interface ChildObservationsFile { } const LINK_ID = /^lnk_[0-9a-f]{16}$/; -const REASON = /^[a-z][a-z0-9:-]{0,79}$/; +// Underscores: the sync classifier's reasons carry the Child's own mismatch token (child-link-mismatch:other_link). +const REASON = /^[a-z][a-z0-9:_-]{0,79}$/; const STATE = /^[a-z][a-z0-9-]{0,63}$/; const OPAQUE_ID = /^[A-Za-z0-9_.:-]{1,128}$/; const SOURCES: ReadonlySet = new Set(["plan", "inspect", "peer"]); diff --git a/tests/clients/link-child-observations.test.ts b/tests/clients/link-child-observations.test.ts index f91ac3f6643..a8a7b6751fd 100644 --- a/tests/clients/link-child-observations.test.ts +++ b/tests/clients/link-child-observations.test.ts @@ -90,6 +90,16 @@ describe("tolerant reader", () => { patchChildObservation(state, B, { lastOperation: summary }, new Date("2026-11-15T00:00:00.000Z")); expect(state.children[B]).toMatchObject({ source: "inspect", updateRoute: "none", lastOperation: summary, observedAt: "2026-11-15T00:00:00.000Z" }); }); + + test("an operation reason from the sync classifier survives the round trip (wp3: child-link-mismatch:other_link)", () => { + const state = emptyChildObservations(); + state.children[A] = observation(); + for (const reason of ["child-link-mismatch:other_link", "child-link-mismatch:disconnect_pending", "restart-skipped:link-active"]) { + const summary = { op: "sync" as const, jobId: "ljob_0123456789abcdef", state: "refused", reason, finishedAt: "2026-11-13T00:00:00.000Z" }; + patchChildObservation(state, A, { lastOperation: summary }, new Date("2026-11-13T00:00:00.000Z")); + expect(parseChildObservations(JSON.stringify(state)).children[A]).toEqual(observation({ lastOperation: summary })); + } + }); }); describe("writer", () => { From 58fb2f8171e5424e3f7790a3920c1bf25a5a55b5 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:20:17 +0900 Subject: [PATCH 07/18] test(cli): ocx link update, sync and restart-codex --- tests/cli/cli-link-check.test.ts | 21 ++- tests/cli/cli-link-child-ops.test.ts | 224 +++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 tests/cli/cli-link-child-ops.test.ts diff --git a/tests/cli/cli-link-check.test.ts b/tests/cli/cli-link-check.test.ts index 2e324724a07..1335f4bff30 100644 --- a/tests/cli/cli-link-check.test.ts +++ b/tests/cli/cli-link-check.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { runLinkCommand } from "../../src/cli/link"; -import { NO_ELIGIBLE_LINKS_MESSAGE, OLD_HOME_MESSAGE, RUNTIME_ROLE_CLIENT_MESSAGE, validateChildJob } from "../../src/cli/link-child-ops"; +import { LOOPBACK_REQUIRED_MESSAGE, NO_ELIGIBLE_LINKS_MESSAGE, OLD_HOME_MESSAGE, RUNTIME_ROLE_CLIENT_MESSAGE, validateChildJob } from "../../src/cli/link-child-ops"; import type { LinkCheckDeps } from "../../src/cli/link-child-ops"; const LINK = "lnk_0123456789abcdef"; @@ -106,6 +106,25 @@ describe("ocx link check", () => { expect(d.calls).toHaveLength(4); }); + test("a check queued behind another job past wp2's 150 s budget still waits for its item", async () => { + let clock = 0; + let polls = 0; + const d = deps(call => { + if (call.method === "POST") return { status: 202, body: { job: job("queued", [{ state: "queued" }]) } }; + polls += 1; + return { body: { job: polls < 6 ? job("running", [{ state: "queued" }]) : polls < 7 ? job("running", [{ state: "running" }]) : job("done", [{ state: "succeeded", finishedAt: "2026-11-11T00:10:00.000Z" }]) } }; + }, { now: () => clock, sleep: async () => { clock += 60_000; } }); + const result = await run(["--link-id", LINK], d); + expect(result.code).toBe(0); + expect(clock).toBeGreaterThan(150_000); + }); + + test("a Home not bound to loopback: 403 names the dashboard and exits 1", async () => { + const result = await run(["--all"], deps(() => ({ status: 403, body: { error: { code: "admin_loopback_required", message: "x" } } }))); + expect(result.code).toBe(1); + expect(result.stderr[0]).toBe(`Error: ${LOOPBACK_REQUIRED_MESSAGE}`); + }); + test("old Home 404 message", async () => { const d = deps(() => ({ status: 404, body: { error: "not found" } })); const result = await run(["--all"], d); diff --git a/tests/cli/cli-link-child-ops.test.ts b/tests/cli/cli-link-child-ops.test.ts new file mode 100644 index 00000000000..f9c4ec5dc9c --- /dev/null +++ b/tests/cli/cli-link-child-ops.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { runLinkCommand } from "../../src/cli/link"; +import { CHILD_ITEM_WAIT_MS, LOOPBACK_REQUIRED_MESSAGE, OLD_HOME_MESSAGE, linkCheckExitCode, validateChildJob, + type LinkCheckDeps } from "../../src/cli/link-child-ops"; +import { runCapabilities } from "../../src/cli/capabilities-command"; + +const LINK = "lnk_0123456789abcdef"; +const OTHER = "lnk_fedcba9876543210"; +const JOB = "ljob_0123456789abcdef"; + +type Call = { url: string; method: string; body: string | null }; + +function capture() { + const stdout: string[] = []; + const stderr: string[] = []; + const log = console.log, error = console.error; + console.log = (...args: unknown[]) => { stdout.push(args.map(String).join(" ")); }; + console.error = (...args: unknown[]) => { stderr.push(args.map(String).join(" ")); }; + return { stdout, stderr, restore() { console.log = log; console.error = error; } }; +} + +type Item = { state: string; reason?: string | null } & Record; +function job(op: string, state: "queued" | "running" | "done", items: Item[], extra: Record = {}) { + return { schema: "ocx.link.child-job/1", jobId: JOB, op, trigger: "manual", restart: "none", state, createdAt: "2026-11-11T00:00:00.000Z", + finishedAt: state === "done" ? "2026-11-11T00:00:05.000Z" : null, ...extra, + items: items.map(({ state: itemState, reason, ...rest }) => ({ linkId: LINK, alias: "child-a", state: itemState, reason: reason ?? null, + startedAt: null, finishedAt: itemState === "queued" || itemState === "running" ? null : "2026-11-11T00:00:05.000Z", ...rest })) }; +} + +/** POST answers post(); every job GET answers the next of polls (the last repeats). */ +function deps(post: () => { status?: number; body: unknown }, polls: unknown[] = [], extra: Partial = {}) { + const calls: Call[] = []; + const sleeps: number[] = []; + const queue = [...polls]; + const value: LinkCheckDeps = { + baseUrl: "http://127.0.0.1:19101", + readAdminToken: () => "ocx_admin_test-token", + fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => { + const call = { url: String(input), method: init?.method ?? "GET", body: typeof init?.body === "string" ? init.body : null }; + calls.push(call); + const answer = call.method === "POST" ? post() : { status: 200, body: { job: queue.length > 1 ? queue.shift() : queue[0] } }; + return new Response(JSON.stringify(answer.body), { status: answer.status ?? 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch, + sleep: async ms => { sleeps.push(ms); }, + ...extra, + }; + return { value, calls, sleeps }; +} +const accepted = (body: unknown) => () => ({ status: 202, body: { job: body } }); + +async function run(args: string[], d: ReturnType) { + const output = capture(); + try { + const code = await runLinkCommand(args, d.value); + return { code, stdout: output.stdout, stderr: output.stderr }; + } finally { output.restore(); } +} + +describe("ocx link update|sync|restart-codex arguments", () => { + test.each([ + [["update", "--yes"]], + [["update", "--all", "--link-id", LINK, "--yes"]], + [["update", "--link-id", "lnk_bad", "--yes"]], + [["update", "--link-id", LINK]], + [["update", "--all"]], + [["sync", "--link-id", LINK, "--restart-app-server-only", "--restart-codex", "--yes"]], + [["sync", "--all", "--restart-app-server-only"]], + [["sync", "--link-id", LINK, "--link-id", OTHER, "--restart-app-server-only"]], + [["sync", "--link-id", LINK, "--restart-codex"]], + [["sync", "--link-id", LINK, "--yes"]], + [["restart-codex", "--link-id", LINK]], + [["restart-codex", "--all", "--yes"]], + [["restart-codex", "--link-id", LINK, "--scope", "foo", "--yes"]], + [["restart-codex", "--link-id", LINK, "--link-id", OTHER, "--yes"]], + [["sync", "--link-id", LINK, "stray"]], + ])("%j is a usage error with no request", async args => { + const d = deps(accepted(job("sync", "done", [{ state: "succeeded" }]))); + expect((await run(args, d)).code).toBe(2); + expect(d.calls).toEqual([]); + }); + + test("bodies", async () => { + const cases: Array<[string[], unknown]> = [ + [["update", "--link-id", LINK, "--link-id", OTHER, "--yes"], { op: "update", linkIds: [LINK, OTHER] }], + [["update", "--all", "--yes"], { op: "update", linkIds: "all" }], + [["sync", "--all"], { op: "sync", linkIds: "all" }], + [["sync", "--link-id", LINK, "--link-id", OTHER], { op: "sync", linkIds: [LINK, OTHER] }], + [["sync", "--link-id", LINK, "--restart-app-server-only"], { op: "sync", linkIds: [LINK], restart: "app-server" }], + [["sync", "--link-id", LINK, "--restart-codex", "--yes"], { op: "sync", linkIds: [LINK], restart: "codex" }], + [["restart-codex", "--link-id", LINK, "--yes"], { op: "restart-codex", linkIds: [LINK], restart: "app-server" }], + [["restart-codex", "--link-id", LINK, "--scope", "codex", "--yes", "--json"], { op: "restart-codex", linkIds: [LINK], restart: "codex" }], + ]; + for (const [args, body] of cases) { + const d = deps(accepted(job("sync", "done", [{ state: "succeeded" }]))); + expect((await run([...args, "--no-wait"], d)).code).toBe(0); + expect(JSON.parse(d.calls[0]!.body!)).toEqual(body); + } + }); +}); + +describe("ocx link child operation results", () => { + test("--no-wait prints the queued job and does not poll; the default polls until done", async () => { + const queued = job("sync", "queued", [{ state: "queued" }]); + const quick = deps(accepted(queued)); + const noWait = await run(["sync", "--link-id", LINK, "--no-wait"], quick); + expect(noWait.code).toBe(0); + expect(quick.calls).toHaveLength(1); + expect(JSON.parse(noWait.stdout[0]!)).toMatchObject({ state: "queued" }); + const waiting = deps(accepted(queued), [job("sync", "running", [{ state: "running" }]), job("sync", "done", [{ state: "succeeded" }])]); + const waited = await run(["sync", "--link-id", LINK], waiting); + expect(waited.code).toBe(0); + expect(waiting.calls.map(call => call.method + " " + new URL(call.url).pathname)).toEqual(["POST /api/link/children/operations", + "GET /api/link/children/jobs/" + JOB, "GET /api/link/children/jobs/" + JOB]); + expect(waiting.sleeps).toEqual([2_000, 2_000]); + }); + + test.each([ + ["every item succeeded", [{ state: "succeeded" }, { state: "up-to-date" }], 0], + ["a restart that went out", [{ state: "succeeded", restart: { requested: "app-server", outcome: "requested" } }], 0], + ["owner-action", [{ state: "succeeded" }, { state: "owner-action", reason: "legacy-ocx" }], 6], + ["a withheld restart", [{ state: "succeeded", reason: "restart-skipped:link-active", restart: { requested: "app-server", outcome: "skipped" } }], 6], + ["a kept update whose sync failed", [{ state: "succeeded", reason: "post-update-sync-failed" }], 6], + ["a restart reported unsupported", [{ state: "succeeded", restart: { requested: "app-server", outcome: "unsupported" } }], 6], + ["a busy restart", [{ state: "busy", reason: "restart-skipped:link-active" }], 6], + ["an uncertain item", [{ state: "succeeded" }, { state: "uncertain", reason: "restart-result-unknown" }], 1], + ["a failed item", [{ state: "failed", reason: "rolled-back" }], 1], + ["an unknown item state", [{ state: "deferred" }], 1], + ] as const)("%s", async (_name, items, exit) => { + const d = deps(accepted(job("update", "done", items as unknown as Item[]))); + expect((await run(["update", "--all", "--yes"], d)).code).toBe(exit); + }); + + test("a document from a later Home validates: unknown keys dropped, restart.requested and an unknown outcome kept", async () => { + const later = job("sync", "done", [{ state: "succeeded", restart: { requested: "app-server", outcome: "deferred", future: 1 }, unknownItemKey: true }], { policy: {} }); + const d = deps(accepted(later)); + const result = await run(["sync", "--link-id", LINK, "--restart-app-server-only"], d); + expect(result.code).toBe(6); + expect(JSON.parse(result.stdout[0]!).items[0].restart).toEqual({ requested: "app-server", outcome: "deferred" }); + expect(validateChildJob(later).items[0]).not.toHaveProperty("unknownItemKey"); + for (const broken of [{ ...later, jobId: 7 }, { ...later, items: {} }, { ...later, schema: "ocx.link.child-job/2" }]) { + expect((await run(["sync", "--link-id", LINK], deps(accepted(broken)))).code).toBe(1); + } + }); + + test("refusals: 409 exits 5, an older Home exits 4 with the update message, link_not_found keeps the route's", async () => { + expect((await run(["sync", "--link-id", LINK], deps(() => ({ status: 409, body: { error: { code: "link_operation_in_progress", message: "x" } } })))).code).toBe(5); + const noRoute = await run(["update", "--all", "--yes"], deps(() => ({ status: 404, body: { error: "not found" } }))); + expect(noRoute.code).toBe(4); + expect(noRoute.stderr[0]).toBe("Error: " + OLD_HOME_MESSAGE); + const missing = await run(["sync", "--link-id", LINK], deps(() => ({ status: 404, body: { error: { code: "link_not_found", message: "x" } } }))); + expect(missing.code).toBe(4); + expect(missing.stderr[0]).not.toContain(OLD_HOME_MESSAGE); + const lost = await run(["sync", "--link-id", LINK], deps(accepted(job("sync", "running", [{ state: "running" }])), + [{ error: { code: "job_not_found", message: "The job was not found." } }], { + fetchImpl: (async (_input: RequestInfo | URL, init?: RequestInit) => init?.method === "POST" + ? new Response(JSON.stringify({ job: job("sync", "running", [{ state: "running" }]) }), { status: 202 }) + : new Response(JSON.stringify({ error: { code: "job_not_found", message: "The job was not found." } }), { status: 404 })) as typeof fetch, + })); + expect(lost.code).toBe(4); + }); + + test("Revision 6 F3: a Home that only observes answers 400 invalid_body; update and sync name the older Home", async () => { + for (const args of [["update", "--link-id", LINK, "--yes"], ["sync", "--all"]]) { + const old = await run(args, deps(() => ({ status: 400, body: { error: { code: "invalid_body", message: "The body must be exactly {op:\"observe\", linkIds}." } } }))); + expect(old.code).toBe(4); + expect(old.stderr[0]).toBe("Error: " + OLD_HOME_MESSAGE); + } + }); + + test("a Home not bound to loopback answers 403: the CLI names the dashboard and exits 1", async () => { + for (const args of [["sync", "--link-id", LINK], ["check", "--all"]]) { + const result = await run(args, deps(() => ({ status: 403, body: { error: { code: "admin_loopback_required", message: "x" } } }))); + expect(result.code).toBe(1); + expect(result.stderr[0]).toBe("Error: " + LOOPBACK_REQUIRED_MESSAGE); + } + }); +}); + +describe("wait deadline (Revision 5, carried item b)", () => { + test("time spent queued spends no budget", async () => { + let clock = 0; + const polls = [...Array.from({ length: 20 }, () => job("sync", "running", [{ state: "queued" }])), job("sync", "running", [{ state: "running" }]), + job("sync", "done", [{ state: "succeeded" }])]; + const d = deps(accepted(job("sync", "queued", [{ state: "queued" }])), polls, { now: () => clock, sleep: async () => { clock += 60_000; } }); + expect((await run(["sync", "--link-id", LINK], d)).code).toBe(0); + }); + + test("a running sync item gets its 420 s budget plus 30 s from the last queued poll, an update 2 100 s", async () => { + for (const [verb, extra, budget] of [["sync", [], CHILD_ITEM_WAIT_MS.sync], ["update", ["--yes"], CHILD_ITEM_WAIT_MS.update]] as const) { + let clock = 0; + const d = deps(accepted(job(verb, "running", [{ state: "running" }])), [job(verb, "running", [{ state: "running" }])], + { now: () => clock, sleep: async () => { clock += 10_000; } }); + const result = await run([verb, "--link-id", LINK, ...extra], d); + expect(result.code).toBe(1); + expect(result.stderr.join("\n")).toContain("job_still_running"); + expect(clock).toBe(budget + 30_000); + } + }); + + test("a job queued forever stops at the 3 h cap", async () => { + let clock = 0; + const d = deps(accepted(job("sync", "queued", [{ state: "queued" }])), [job("sync", "queued", [{ state: "queued" }])], + { now: () => clock, sleep: async () => { clock += 600_000; } }); + const result = await run(["sync", "--link-id", LINK], d); + expect(result.code).toBe(1); + expect(clock).toBe(10_800_000); + }); +}); + +describe("contract", () => { + test("linkCheckExitCode reads the restart outcome, not only the state", () => { + expect(linkCheckExitCode(validateChildJob(job("sync", "done", [{ state: "succeeded", restart: { requested: "codex", outcome: "not-run" } }])))).toBe(6); + }); + + test("ocx capabilities --json lists link.child-ops/1", async () => { + const out = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runCapabilities(["--json"])).toBe(0); + const document = JSON.parse(String(out.mock.calls[0]![0])) as { contracts: string[]; commands: Array<{ command: string[] }> }; + expect(document.contracts).toContain("link.child-ops/1"); + for (const verb of ["update", "sync", "restart-codex"]) expect(document.commands.some(entry => entry.command.join(" ") === "link " + verb)).toBe(true); + } finally { out.mockRestore(); } + }); +}); From bf2edfb9bf466f0b1f069e2ab164ac0350852ea3 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:21:42 +0900 Subject: [PATCH 08/18] feat(cli): link update, sync and restart-codex capabilities and contract link.child-ops/1 --- src/cli/capabilities-access-remote.ts | 32 ++++++++++++++++++++++++++- src/cli/capability-contracts.ts | 2 +- src/cli/registry.ts | 7 ++++-- tests/cli/cli-link-child-ops.test.ts | 4 ++-- tests/cli/cli-link.test.ts | 4 ++-- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/cli/capabilities-access-remote.ts b/src/cli/capabilities-access-remote.ts index c298963f93a..b76affdd57a 100644 --- a/src/cli/capabilities-access-remote.ts +++ b/src/cli/capabilities-access-remote.ts @@ -173,6 +173,36 @@ export const ACCESS_REMOTE_CAPABILITIES: readonly Capability[] = [ flags: [{"name":"--link-id","value":"string","summary":"Link id to check; repeatable, up to 64."},{"name":"--all","value":"boolean","summary":"Check every link in the Home's store."},{"name":"--no-wait","value":"boolean","summary":"Print the queued job and return without polling it."},{"name":"--json","value":"boolean","summary":"Emit the ocx.link.child-job/1 document as JSON (always JSON)."}], mutates: true, json: "payload", - details: ["Requires loopback admin authority on the Home. Starts one observe job; the CLI polls it every 2 s and prints the final ocx.link.child-job/1 document. Exit 0 when every item succeeded, 6 when an item is busy, offline, refused, owner-action or unsupported, 1 on a failure, 4 when the link is unknown or the Home predates Child operations, 5 on a conflict: the link already has an operation running, --all found no Home-initiated link, or the command ran on a Child.","Changes no install on the Child: it runs the gated update --plan, which only computes. An npm Child without a census pair runs it through its ocx shim, so the launcher's ordinary bootstrap (sync-guard bookkeeping, Bun resolution) runs first, as for any pinned sync; the gated script disables the Desktop handoff. Writes the Home's Child observation cache.","One SSH call per Child, up to 60 s, three at a time; a POSIX Child older than 2.97.0 costs one more read-only call for its version. Home-initiated links only: client-initiated and ocx-child links are reported without SSH. One operation per link per running Home."], + details: ["Requires loopback admin authority on the Home. Starts one observe job; the CLI polls it every 2 s and prints the final ocx.link.child-job/1 document. Exit 0 when every item succeeded, 6 when an item is busy, offline, refused, owner-action or unsupported, 1 on a failure, 4 when the link is unknown or the Home predates Child operations, 5 on a conflict: the link already has an operation running, the Home has no links, or the command ran on a Child.","Changes no install on the Child: it runs the gated update --plan, which only computes. An npm Child without a census pair runs it through its ocx shim, so the launcher's ordinary bootstrap (sync-guard bookkeeping, Bun resolution) runs first, as for any pinned sync; the gated script disables the Desktop handoff. On an npm or pnpm Child served through its census pair it also runs node --version (bounded to 10 s, read-only) to report whether the package launcher can apply an update. Writes the Home's Child observation cache.","One SSH call per Child, up to 60 s, three at a time; a POSIX Child older than 2.97.0 costs one more read-only call for its version. Home-initiated links only: client-initiated and ocx-child links are reported without SSH. One operation per link per running Home."], + }, + { + command: ["link", "update"], summary: "Update a Home-initiated Child's OpenCodex to this Home's version.", + usage: "ocx link update (--link-id | --all) --yes [--no-wait] [--json]", + routes: [{ method: "POST", path: "/api/link/children/operations" }, { method: "GET", path: "/api/link/children/jobs/{jobId}" }], + flags: [{ name: "--link-id", value: "string", summary: "Home-initiated link to update; repeatable, up to 64." }, { name: "--all", value: "boolean", summary: "Every link in the Home's store, one Child at a time; Child-initiated links report unsupported." }, + { name: "--yes", value: "boolean", required: true, summary: "Confirms that each updated Child's OpenCodex restarts and its relayed turns end." }, + { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document (always JSON)." }], + mutates: true, json: "payload", + details: ["Runs a pinned sync --expect-link first to confirm the Child still belongs to this link, then the Child's own ocx update --plan through the served entry, then --yes with --expect-plan and a --not-after 120 s after the plan on the Child's own clock, through the link's pinned SSH trust. npm and pnpm installs apply through their Node launcher; Bun installs plan and apply on their served Bun entry. The target is always this Home's version; the Child refuses a downgrade.", "Children below 2.97.0 run nothing and report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply). An update restarts the Child's OpenCodex and ends any turn currently relayed for it, so --yes is required. A pinned sync runs after every update, also after a failed or rolled-back one.", "Exit 0 all succeeded or up-to-date, 6 partial (including a kept update whose follow-up sync failed), 1 failed, uncertain or an unknown state, 5 another operation holds the link, the Home has no links or the command ran on a Child, 4 the link is unknown or the Home lacks Child operations. On a Home not bound to loopback it exits 1 and names the dashboard."], + }, + { + command: ["link", "sync"], summary: "Run the pinned catalog and settings sync on Home-initiated Children.", + usage: "ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex --yes] [--no-wait] [--json]", + routes: [{ method: "POST", path: "/api/link/children/operations" }, { method: "GET", path: "/api/link/children/jobs/{jobId}" }], + flags: [{ name: "--link-id", value: "string", summary: "Home-initiated link to sync; repeatable, up to 64, exactly one with a restart." }, { name: "--all", value: "boolean", summary: "Every link in the Home's store, three at a time; refused with a restart." }, + { name: "--restart-app-server-only", value: "boolean", summary: "Also restart the Child's Codex app-servers after the sync writes." }, { name: "--restart-codex", value: "boolean", summary: "Also quit and relaunch the Child's Codex desktop app; ends live turns." }, + { name: "--yes", value: "boolean", summary: "Confirms --restart-codex." }, { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document (always JSON)." }], + mutates: true, json: "payload", + details: ["The same sync --expect-link the ocx sync fan-out runs, gated at 2.93.0. Restart flags are refused on Windows and ignored by ocx-child; the item reports restart unsupported.", "A restart needs one --link-id (--all is refused) and --restart-codex needs --yes. The sync runs first; the restart then goes out as a second pinned sync and is withheld (restart skipped, reason restart-skipped:link-active) while a relayed model request for that Child is in flight. A restarting call whose result is unknown ends uncertain (restart-result-unknown).", "Exit codes as for ocx link update; a withheld or unconfirmed restart exits 6 or 1."], + }, + { + command: ["link", "restart-codex"], summary: "Restart Codex on one Home-initiated Child so it reloads the catalog.", + usage: "ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json]", + routes: [{ method: "POST", path: "/api/link/children/operations" }, { method: "GET", path: "/api/link/children/jobs/{jobId}" }], + flags: [{ name: "--link-id", value: "string", required: true, summary: "Home-initiated link." }, { name: "--scope", value: "string", summary: "app-server (default) or codex (also relaunches the desktop app)." }, + { name: "--yes", value: "boolean", required: true, summary: "Confirms that running Codex turns on the Child end." }, + { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document (always JSON)." }], + mutates: true, json: "payload", + details: ["A pinned sync, then a second pinned sync with the restart flag; works on full Children from 2.93.0. app-server scope leaves the Codex desktop app's own app-server running. While a relayed model request for that Child is in flight nothing is sent and the item is busy (restart-skipped:link-active).", "Refused on Windows Children (unsupported windows-restart) and reported unsupported for ocx-child. The codex scope needs a logged-in GUI session on the Child; its outcome is requested, not confirmed. Exit codes as for ocx link update."], }, ]; diff --git a/src/cli/capability-contracts.ts b/src/cli/capability-contracts.ts index d4f9cba73c7..ee22f443de9 100644 --- a/src/cli/capability-contracts.ts +++ b/src/cli/capability-contracts.ts @@ -1,2 +1,2 @@ /** Versioned machine contracts implemented by this CLI. */ -export const CAPABILITY_CONTRACTS = ["sync.home-fanout/1", "update.orchestrated/1"] as const; +export const CAPABILITY_CONTRACTS = ["sync.home-fanout/1", "update.orchestrated/1", "link.child-ops/1"] as const; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index fcb8cbdab2b..372564489eb 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -152,7 +152,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "link", - usage: "ocx link ", + usage: "ocx link ", summary: "Allocate and manage a loopback remote home link.", details: [ "Port: ocx link port [--json]", @@ -162,7 +162,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Reconnect: ocx link reconnect --link-id [--json]", "Cleanup: ocx link cleanup [--link-id ] [--json]", "Check: ocx link check (--link-id | --all) [--no-wait] [--json]", - "Issue, status, revoke, reconnect, and check use the running proxy's loopback management API and admin token.", + "Update: ocx link update (--link-id | --all) --yes [--no-wait] [--json]", + "Sync: ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex --yes] [--no-wait] [--json]", + "Restart Codex: ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json]", + "Issue, status, revoke, reconnect, check, update, sync and restart-codex use the running proxy's loopback management API and admin token; update, sync and restart-codex act only on Home-initiated links.", ], }, { diff --git a/tests/cli/cli-link-child-ops.test.ts b/tests/cli/cli-link-child-ops.test.ts index f9c4ec5dc9c..b6241417afc 100644 --- a/tests/cli/cli-link-child-ops.test.ts +++ b/tests/cli/cli-link-child-ops.test.ts @@ -216,9 +216,9 @@ describe("contract", () => { const out = spyOn(console, "log").mockImplementation(() => {}); try { expect(await runCapabilities(["--json"])).toBe(0); - const document = JSON.parse(String(out.mock.calls[0]![0])) as { contracts: string[]; commands: Array<{ command: string[] }> }; + const document = JSON.parse(String(out.mock.calls[0]![0])) as { contracts: string[]; capabilities: Array<{ command: string[] }> }; expect(document.contracts).toContain("link.child-ops/1"); - for (const verb of ["update", "sync", "restart-codex"]) expect(document.commands.some(entry => entry.command.join(" ") === "link " + verb)).toBe(true); + for (const verb of ["update", "sync", "restart-codex"]) expect(document.capabilities.some(entry => entry.command.join(" ") === "link " + verb)).toBe(true); } finally { out.mockRestore(); } }); }); diff --git a/tests/cli/cli-link.test.ts b/tests/cli/cli-link.test.ts index eddd4e8b2e5..09a35622b6b 100644 --- a/tests/cli/cli-link.test.ts +++ b/tests/cli/cli-link.test.ts @@ -280,7 +280,7 @@ describe("ocx link", () => { } }); - test("registers the canonical command and all nine capability leaves", () => { + test("registers the canonical command and all twelve capability leaves", () => { expect(findCommand("link")?.name).toBe("link"); expect(DISPATCH_COMMANDS).toContain("link"); expect(CAPABILITIES.filter(cap => cap.command[0] === "link").map(cap => cap.command.join(" "))).toEqual([ @@ -290,7 +290,7 @@ describe("ocx link", () => { "link issue", "link status", "link revoke", - "link reconnect", "link cleanup", "link check", + "link reconnect", "link cleanup", "link check", "link update", "link sync", "link restart-codex", ]); }); }); From dbde10b0b2884a47aaa11ccabaa09c478ce24170 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:21:51 +0900 Subject: [PATCH 09/18] test: register the wp3 test files --- scripts/test-layout/layout-clients.json | 6 +++++- tests/fixtures/test-layout-expected.json | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/test-layout/layout-clients.json b/scripts/test-layout/layout-clients.json index 5c0409fb1ba..08c03509129 100644 --- a/scripts/test-layout/layout-clients.json +++ b/scripts/test-layout/layout-clients.json @@ -188,5 +188,9 @@ "link-child-catalog-freshness.test.ts": "clients", "link-child-observe.test.ts": "clients", "link-child-ops-routes.test.ts": "server", - "cli-link-check.test.ts": "cli" + "cli-link-check.test.ts": "cli", + "link-child-ops-engine.test.ts": "clients", + "link-child-ops-runners.test.ts": "clients", + "link-child-update-launcher.test.ts": "clients", + "cli-link-child-ops.test.ts": "cli" } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f8ab4139a4f..401f6991833 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1659,5 +1659,9 @@ "link-child-catalog-freshness.test.ts": "clients", "link-child-observe.test.ts": "clients", "link-child-ops-routes.test.ts": "server", - "cli-link-check.test.ts": "cli" + "cli-link-check.test.ts": "cli", + "link-child-ops-engine.test.ts": "clients", + "link-child-ops-runners.test.ts": "clients", + "link-child-update-launcher.test.ts": "clients", + "cli-link-child-ops.test.ts": "cli" } From 31dc06b0c43f66cb7fa62d9b5884a1a6e66d8c87 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:22:42 +0900 Subject: [PATCH 10/18] docs(skills): Child operations from the Home --- .../ocx/references/01_management_surface.md | 18 +++- .../references/01_surface_access-remote.md | 82 ++++++++++++++++++- skills/ocx/references/02_json_shapes.md | 19 +++-- skills/ocx/references/04_failure_semantics.md | 21 +++-- skills/ocx/references/05_remote_hub.md | 28 +++++++ 5 files changed, 150 insertions(+), 18 deletions(-) diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 12bfbaa636c..2bb7901612a 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -31,7 +31,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [agents-routing](01_surface_agents-routing.md) | 68 | | [integrations](01_surface_integrations.md) | 41 | | [observe-system](01_surface_observe-system.md) | 105 | -| [access-remote](01_surface_access-remote.md) | 35 | +| [access-remote](01_surface_access-remote.md) | 38 | | [lab](01_surface_lab.md) | 21 | ## Read-only capabilities @@ -1471,6 +1471,18 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_access-remote.md#ocx-link-check) +### `ocx link update` + +[State-changing task](01_surface_access-remote.md#ocx-link-update) + +### `ocx link sync` + +[State-changing task](01_surface_access-remote.md#ocx-link-sync) + +### `ocx link restart-codex` + +[State-changing task](01_surface_access-remote.md#ocx-link-restart-codex) + ### `ocx lab status` [Read-oriented task](01_surface_lab.md#ocx-lab-status) @@ -1557,6 +1569,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 377 -- of those, state-changing: 233 +- declared capabilities: 380 +- of those, state-changing: 236 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_access-remote.md b/skills/ocx/references/01_surface_access-remote.md index 5e1dc03778d..7e22025a2f8 100644 --- a/skills/ocx/references/01_surface_access-remote.md +++ b/skills/ocx/references/01_surface_access-remote.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 35. +Declared capabilities: 38. ### `ocx link probe` @@ -697,6 +697,82 @@ State-changing: yes. JSON mode: `payload`. | `--no-wait` | boolean | Print the queued job and return without polling it. | | `--json` | boolean | Emit the ocx.link.child-job/1 document as JSON (always JSON). | -- Requires loopback admin authority on the Home. Starts one observe job; the CLI polls it every 2 s and prints the final ocx.link.child-job/1 document. Exit 0 when every item succeeded, 6 when an item is busy, offline, refused, owner-action or unsupported, 1 on a failure, 4 when the link is unknown or the Home predates Child operations, 5 on a conflict: the link already has an operation running, --all found no Home-initiated link, or the command ran on a Child. -- Changes no install on the Child: it runs the gated update --plan, which only computes. An npm Child without a census pair runs it through its ocx shim, so the launcher's ordinary bootstrap (sync-guard bookkeeping, Bun resolution) runs first, as for any pinned sync; the gated script disables the Desktop handoff. Writes the Home's Child observation cache. +- Requires loopback admin authority on the Home. Starts one observe job; the CLI polls it every 2 s and prints the final ocx.link.child-job/1 document. Exit 0 when every item succeeded, 6 when an item is busy, offline, refused, owner-action or unsupported, 1 on a failure, 4 when the link is unknown or the Home predates Child operations, 5 on a conflict: the link already has an operation running, the Home has no links, or the command ran on a Child. +- Changes no install on the Child: it runs the gated update --plan, which only computes. An npm Child without a census pair runs it through its ocx shim, so the launcher's ordinary bootstrap (sync-guard bookkeeping, Bun resolution) runs first, as for any pinned sync; the gated script disables the Desktop handoff. On an npm or pnpm Child served through its census pair it also runs node --version (bounded to 10 s, read-only) to report whether the package launcher can apply an update. Writes the Home's Child observation cache. - One SSH call per Child, up to 60 s, three at a time; a POSIX Child older than 2.97.0 costs one more read-only call for its version. Home-initiated links only: client-initiated and ocx-child links are reported without SSH. One operation per link per running Home. + +### `ocx link update` + +Usage: `ocx link update (--link-id | --all) --yes [--no-wait] [--json]` + +Update a Home-initiated Child's OpenCodex to this Home's version. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| POST | `/api/link/children/operations` | +| GET | `/api/link/children/jobs/{jobId}` | + +| Flag | Value | Meaning | +|---|---|---| +| `--link-id` | string | Home-initiated link to update; repeatable, up to 64. | +| `--all` | boolean | Every link in the Home's store, one Child at a time; Child-initiated links report unsupported. | +| `--yes` | boolean | Confirms that each updated Child's OpenCodex restarts and its relayed turns end. | +| `--no-wait` | boolean | Print the queued job and return. | +| `--json` | boolean | Emit the ocx.link.child-job/1 document (always JSON). | + +- Runs a pinned sync --expect-link first to confirm the Child still belongs to this link, then the Child's own ocx update --plan through the served entry, then --yes with --expect-plan and a --not-after 120 s after the plan on the Child's own clock, through the link's pinned SSH trust. npm and pnpm installs apply through their Node launcher; Bun installs plan and apply on their served Bun entry. The target is always this Home's version; the Child refuses a downgrade. +- Children below 2.97.0 run nothing and report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply). An update restarts the Child's OpenCodex and ends any turn currently relayed for it, so --yes is required. A pinned sync runs after every update, also after a failed or rolled-back one. +- Exit 0 all succeeded or up-to-date, 6 partial (including a kept update whose follow-up sync failed), 1 failed, uncertain or an unknown state, 5 another operation holds the link, the Home has no links or the command ran on a Child, 4 the link is unknown or the Home lacks Child operations. On a Home not bound to loopback it exits 1 and names the dashboard. + +### `ocx link sync` + +Usage: `ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex --yes] [--no-wait] [--json]` + +Run the pinned catalog and settings sync on Home-initiated Children. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| POST | `/api/link/children/operations` | +| GET | `/api/link/children/jobs/{jobId}` | + +| Flag | Value | Meaning | +|---|---|---| +| `--link-id` | string | Home-initiated link to sync; repeatable, up to 64, exactly one with a restart. | +| `--all` | boolean | Every link in the Home's store, three at a time; refused with a restart. | +| `--restart-app-server-only` | boolean | Also restart the Child's Codex app-servers after the sync writes. | +| `--restart-codex` | boolean | Also quit and relaunch the Child's Codex desktop app; ends live turns. | +| `--yes` | boolean | Confirms --restart-codex. | +| `--no-wait` | boolean | Print the queued job and return. | +| `--json` | boolean | Emit the ocx.link.child-job/1 document (always JSON). | + +- The same sync --expect-link the ocx sync fan-out runs, gated at 2.93.0. Restart flags are refused on Windows and ignored by ocx-child; the item reports restart unsupported. +- A restart needs one --link-id (--all is refused) and --restart-codex needs --yes. The sync runs first; the restart then goes out as a second pinned sync and is withheld (restart skipped, reason restart-skipped:link-active) while a relayed model request for that Child is in flight. A restarting call whose result is unknown ends uncertain (restart-result-unknown). +- Exit codes as for ocx link update; a withheld or unconfirmed restart exits 6 or 1. + +### `ocx link restart-codex` + +Usage: `ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json]` + +Restart Codex on one Home-initiated Child so it reloads the catalog. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| POST | `/api/link/children/operations` | +| GET | `/api/link/children/jobs/{jobId}` | + +| Flag | Value | Meaning | +|---|---|---| +| `--link-id` | string | Home-initiated link. | +| `--scope` | string | app-server (default) or codex (also relaunches the desktop app). | +| `--yes` | boolean | Confirms that running Codex turns on the Child end. | +| `--no-wait` | boolean | Print the queued job and return. | +| `--json` | boolean | Emit the ocx.link.child-job/1 document (always JSON). | + +- A pinned sync, then a second pinned sync with the restart flag; works on full Children from 2.93.0. app-server scope leaves the Codex desktop app's own app-server running. While a relayed model request for that Child is in flight nothing is sent and the item is busy (restart-skipped:link-active). +- Refused on Windows Children (unsupported windows-restart) and reported unsupported for ocx-child. The codex scope needs a logged-in GUI session on the Child; its outcome is requested, not confirmed. Exit codes as for ocx link update. diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index 8c3519b1bd7..c79eddae2d1 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -411,12 +411,19 @@ not that the Child applied it. `activity.idle` means no relayed model turn for 1 Home cannot tell. `codex` is null for now. Times are epoch milliseconds except the ISO `observedAt`. The CLI drops keys it does not know and reads an unknown enum value as `unknown`. -`ocx link check` prints an `ocx.link.child-job/1` document: -`{schema, jobId, op:"observe", trigger, restart:"none", state:"queued"|"running"|"done", createdAt, finishedAt, items}`, -each item `{linkId, alias, state, reason, startedAt, finishedAt}`. Item states are `succeeded`, `busy`, -`offline`, `refused`, `owner-action`, `unsupported`, `failed` (and, for later operations, `up-to-date` and -`uncertain`). Reasons are closed tokens such as `legacy-ocx`, `child-initiated`, `ssh-failed`, -`link-removed` or `link-operation-in-progress`; no remote output is included. +`ocx link check|update|sync|restart-codex` print an `ocx.link.child-job/1` document: +`{schema, jobId, op:"observe"|"sync"|"restart-codex"|"update", trigger:"manual"|"auto", restart:"none"|"app-server"|"codex", +state:"queued"|"running"|"done", createdAt, finishedAt, items}`, each item +`{linkId, alias, state, reason, hint?, fromVersion?, toVersion?, sync?, restart?, startedAt, finishedAt}`. +`sync` is `{state:"synced"|"skipped"|"failed"|"timed-out", reason}` for the pinned sync the item ran; +`restart` is `{requested:"app-server"|"codex", outcome}` with `outcome` `requested` (sent, not confirmed), +`unsupported` (Windows or `ocx-child`), `not-run` (the sync before it failed) or `skipped` (a relayed request was in +flight). Item states are `succeeded`, `up-to-date`, `busy`, `offline`, `refused`, `owner-action`, +`unsupported`, `failed` and `uncertain`. Reasons are closed tokens such as `legacy-ocx`, `child-initiated`, +`ssh-failed`, `link-removed`, `link-operation-in-progress`, `restart-skipped:link-active`, +`restart-result-unknown`, `post-update-sync-failed`, `rolled-back`, `windows-apply` or `launcher-node-missing`; +`hint` is operator guidance written by the Home. No remote output is included. The CLI drops keys it does not +know and keeps an enum value it does not know for display. ## Maintenance receipts diff --git a/skills/ocx/references/04_failure_semantics.md b/skills/ocx/references/04_failure_semantics.md index 661f9f4615e..9a951da4873 100644 --- a/skills/ocx/references/04_failure_semantics.md +++ b/skills/ocx/references/04_failure_semantics.md @@ -14,7 +14,7 @@ exit contract. Its common mappings and the main exceptions are: | 64 | usage validation in `capabilities`, `ready`, `resolve` | fix arguments; no discovery/request for invalid input | | 4 | HTTP 404 — the named account, provider, key, or route does not exist | no | | 5 | HTTP 409 — conflict; a lock is held or state moved under you | inspect first; stale baselines require review | -| 6 | `ocx sync` completed the Home step but a Child failed or timed out, or the link store was unreadable; `ocx link check` finished with an item `busy`, `offline`, `refused`, `owner-action` or `unsupported` | inspect the fan-out result or the job's items | +| 6 | `ocx sync` completed the Home step but a Child failed or timed out, or the link store was unreadable; `ocx link check`, `update`, `sync` or `restart-codex` finished with an item `busy`, `offline`, `refused`, `owner-action` or `unsupported`, or with a success that carries a reason (`restart-skipped:link-active`, `post-update-sync-failed`) or a restart that did not go out | inspect the fan-out result or the job's items | | 7 | reserved for an update in flight during sync | wait for the update, then retry | | 1 | everything else: transport failure, 5xx, unexpected errors | depends on `reason` | @@ -29,12 +29,16 @@ fields and warnings as well; a successful save can leave live convergence pendin retrying unchanged. Unknown root commands and unavailable explicit help topics use exit 1; a capability route miss uses 4 even when an undeclared handler exists. -**`ocx link check` reports per Child.** Exit 6 is a partial result, not a failed command: read each -item's `state` and `reason`. `failed`, `uncertain` or an item state this CLI does not know exits 1; a -job still running past its deadline prints the job and exits 1 (`job_still_running`). Exit 4 means an +**`ocx link check|update|sync|restart-codex` report per Child.** Exit 6 is a partial result, not a failed +command: read each item's `state` and `reason`. A `succeeded` item can still be partial: an update that +was kept but whose follow-up sync failed (`post-update-sync-failed`; run `ocx link sync` for that link) or a +restart withheld because a relayed request was in flight (`restart-skipped:link-active`). `failed`, +`uncertain` or an item state this CLI does not know exits 1; an `uncertain` update must be re-checked with +`ocx link check`, never re-sent blindly. A job still running past its deadline prints the job and exits 1 +(`job_still_running`); time an item spends queued behind another job does not count. Exit 4 means an unknown link id, or a Home too old for Child operations (the message says to update the Home); exit 5 -means a conflict: another operation holds the link, `--all` found no Home-initiated link, or the command ran on a -Child (each has its own message). +means a conflict: another operation holds the link, the Home has no links, or the command ran on a +Child (each has its own message). On a Home not bound to loopback the verbs exit 1 and name the dashboard. ## Distinguishing "not running" from "failing" @@ -153,6 +157,11 @@ the `phase`, not the exit code alone. `reason` is `disconnected`, `invalid_state malformed, repeated or doubled flag exits 2 before anything is read. Older versions ignore the flags, so check `ocx --version` before relying on them. +The Home's own `ocx link sync|restart-codex|update` runs this pinned sync on the Child and reports the +refusal as an item (`refused`, reason `child-link-mismatch:`) instead of an exit 5. Their exit 5 +is `link_operation_in_progress`: another job of the Home holds that link, and `ocx link revoke` on it +answers the same 409 until the job ends. + ## Destructive verbs fail closed `storage trash restore` and `storage policy run` exit 2 without `--yes` and send no mutating diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 9eb56df9f0e..8b893e6dded 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -344,6 +344,34 @@ holds answers 409, and in a multi-link job that link's item ends `busy` (`link-o update route from the last check, whether the Child last pulled the Home's current catalog, and whether a relayed model request is running. An older Home omits the block. +### Operating Children from the Home (`ocx link sync|restart-codex|update`) + +Three more verbs run on Home-initiated Children over the same pinned SSH trust and print the same +`ocx.link.child-job/1` document as `check`. Pick the smallest one that fixes what the operator sees: + +- `ocx link sync --link-id ` (repeatable, or `--all`) runs the pinned `sync --expect-link` the + `ocx sync` fan-out runs, from 2.93.0. Use it when a Child's catalog or settings are behind. +- `ocx link restart-codex --link-id --yes` restarts the Child's Codex app-servers so a running + Codex reloads the catalog (the about-258k context symptom); `--scope codex` also quits and relaunches the + desktop app. `ocx link sync --link-id --restart-app-server-only` does the sync and the restart together; + `--restart-codex` there needs `--yes`. A restart names exactly one link; `--all` is refused. +- `ocx link update --link-id --yes` (or `--all --yes`) updates the Child's OpenCodex to the Home's + version through the Child's own updater, one Child at a time, then resyncs. It needs a Child at + 2.97.0 or later; older Children report `owner-action` `legacy-ocx` and receive no update command. + +`--yes` confirms that running work on the Child ends: a restart ends the Codex turns there, and an update +restarts the Child's OpenCodex and ends the turns relayed for it. Never pass `--yes` or run +`ocx link update --all` on your own initiative; ask the operator first, because an update cannot be +taken back from the Home. A restart is withheld while the Home is relaying a model request for that +Child (`restart-skipped:link-active`, exit 6); try again when the Child is idle. A Child older than 2.69 +is seen as busy only once its response starts for a non-streaming request, so a restart sent while such +a request is still running can end it. + +Windows Children take `sync` only: restarts are `unsupported` (`windows-restart`) and orchestrated updates +`unsupported` (`windows-apply`). `ocx-child` Children report restart and update `unsupported`. The CLI +verbs need a Home bound to loopback; on any other Home they exit 1 and point at the dashboard's Remote +Link page. While a job holds a link, `ocx link revoke` on it answers 409 `link_operation_in_progress`. + ## Key rotation is a two-step commit Starting a rotation issues the new key while **the old key stays valid**. The dashboard From eceea3f3b6433baa1bfd1ca39bf7af78d66c2a60 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:23:15 +0900 Subject: [PATCH 11/18] docs(structure): Child operations from the Home --- structure/remote-link.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/structure/remote-link.md b/structure/remote-link.md index 5135fc917a0..b00c12993cb 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -68,9 +68,11 @@ A connected Child answers `GET` and `HEAD /api/link/status` on its own listener `src/link/ssh-runner.ts` runs every OpenSSH and `ssh-keygen` argv without a shell and caps captured output. On POSIX it spawns them with the inherited PATH plus `/opt/homebrew/bin`, `/usr/local/bin`, `$HOME/.bun/bin` and `$HOME/.local/bin` appended once, so a ProxyCommand helper resolves inside a desktop sidecar that inherited a minimal PATH; Windows keeps the inherited environment. `src/link/supervisor.ts` keeps one `ssh -R` child per hub-initiated link, drives it with the tunnel reducer, coalesces reloads without dropping a later request, reconciles unowned `link:` API keys at startup, and stops children before the hub-link listener on shutdown. Automatic startup begins the supervisor only after the hub-link listener owns its socket; a bind failure must never leave a reverse forward targeting the persisted port. It reaps a leftover tunnel only when Linux `/proc//cmdline` matches the recorded argv exactly; on other platforms a leftover is reported, never killed. `src/link/status-projection.ts` builds the status document the dashboard and `ocx link status` read, including persisted compensation failures, and Home enrollment verifies a fresh post-restart key-authenticated catalog as described below. -Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link probe|add|port|issue|revoke|status|reconnect|cleanup|check`; `cleanup` is local and read-only (see `src/cli/link-cleanup.ts` above). `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. +Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link probe|add|port|issue|revoke|status|reconnect|cleanup|check|update|sync|restart-codex`; `cleanup` is local and read-only (see `src/cli/link-cleanup.ts` above). `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. -Child operations run as jobs. `src/server/management/link-child-ops-routes.ts` serves `POST /api/link/children/operations` (body exactly `{op:"observe", linkIds}`, where `linkIds` is `"all"` or 1 to 64 distinct link ids; `202 {job}`, `400 invalid_body` for any other op or key, `404 link_not_found`, `409 link_operation_in_progress`, `409 no_eligible_links`, `409 runtime_role_client` on a Child) and `GET /api/link/children/jobs/{jobId}` (`ljob_` plus 16 hex; `400 invalid_job_id`, `404 job_not_found`). `handleLinkRoutes` delegates every `/api/link/children/` path before its reconnect and DELETE matchers, so `DELETE /api/link/children/x` answers 404 and never removes a link. Both routes take the same principals as probe and apply; the local-control socket still admits only `GET /api/link/status`, so on a non-loopback Home `ocx link check` needs the TCP loopback path like revoke. `src/link/child-ops-engine.ts` keeps the `ocx.link.child-job/1` jobs in memory: one lock per link per Home process (a held link makes a single-link request 409 and a multi-link item `busy`, which leaves that link's `lastOperation` to its holder), three items at a time across every job, the record re-read before SSH (`refused`/`link-removed` when it vanished), finished jobs kept one hour and at most 32, no timer. Items carry closed reason tokens, never remote text. `src/link/child-observe.ts` is the only op in this phase: client-initiated and `ocx-child` links answer without SSH; otherwise one gated `update --plan` (60 s, 64 KiB) classifies the Child, a pre-2.97 POSIX Child costs one more read-only inspection for its version and a pre-2.97 Windows Child reports the peer version; an SSH output-limit or decode error is `failed`, other runner errors `offline`. An item that ran writes the observation's non-carried fields and then `lastOperation` to `child-observations.json`; a lock failure is logged and changes no item. `src/cli/link-child-ops.ts` (`runLinkCheck`, the tolerant `validateChildJob`) polls every 2 s up to `ceil(items/3) × 120 s + 30 s` and exits 0, 6 for a partial result, 1 for a failure or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route, 5 for a held link. Coverage: `tests/server/link-child-ops-routes.test.ts`, `tests/clients/link-child-observe.test.ts`, `tests/cli/cli-link-check.test.ts`. +Child operations run as jobs. `src/server/management/link-child-ops-routes.ts` serves `POST /api/link/children/operations` (body exactly `{op, linkIds, restart?}` with `op` one of `observe`, `sync`, `restart-codex`, `update`; `linkIds` is `"all"` or 1 to 64 distinct link ids; `restart` is `app-server` or `codex`, refused on `observe`, defaulting to `app-server` for `restart-codex`, and any request that carries a restart must name its links, so `"all"` with a restart is `400 invalid_body`; `202 {job}`, `400 invalid_body` for any other op or key including `trigger`, `404 link_not_found`, `409 link_not_home_initiated` for a single Child-initiated link outside `observe`, `409 link_operation_in_progress`, `409 no_eligible_links` only when links.json has no link, `409 runtime_role_client` on a Child) and `GET /api/link/children/jobs/{jobId}` (`ljob_` plus 16 hex; `400 invalid_job_id`, `404 job_not_found`). The route always submits `trigger: "manual"`; only the in-process scheduler can submit `auto`. `handleLinkRoutes` delegates every `/api/link/children/` path before its reconnect and DELETE matchers, so `DELETE /api/link/children/x` answers 404 and never removes a link, and `DELETE /api/link/{id}` answers `409 link_operation_in_progress` while a job holds that link, checked on entry, after the body read and again after the remote cleanup just before the key and record go. Both routes take the same principals as probe and apply; the local-control socket still admits only `GET /api/link/status`, so on a non-loopback Home the CLI verbs exit 1 and name the dashboard. `src/link/child-ops-engine.ts` keeps the `ocx.link.child-job/1` jobs (types in `src/link/child-ops-types.ts`) in one engine per Home process (`sharedChildOpsEngine`; the routes cache it on `LinkRouteState.childOps` and status reads its `activeJob`): one lock per link (a held link makes a single-link request 409 and a multi-link item `busy`, which leaves that link's `lastOperation` to its holder), three items at a time across every job of which at most one is an update (a waiting update does not hold up the items behind it), Child-initiated items of `sync`, `restart-codex` and `update` ended `unsupported`/`child-initiated` at admission, the record re-read before SSH (`refused`/`link-removed` when it vanished), finished jobs kept one hour and at most 32 on each job's own clock, no timer. Each submit carries a `ChildOpsEnv` (store, SSH runner, wire state, activity, Home version, observe, a cache writer that never throws, the Home catalog etag). Items carry closed reason tokens and Home-written hints, never remote text. + +`src/link/child-ops-runners.ts` holds the runners. `observe` is `src/link/child-observe.ts`: client-initiated and `ocx-child` links answer without SSH; otherwise one gated `update --plan` (60 s, 64 KiB) classifies the Child, and on an npm or pnpm Child served through its census pair the script also reports whether a Node answers `--version` within 10 s (`ocx-fanout:launcher:available|unavailable`). `sync` sends the pinned `sync --expect-link` (gate 2.93.0, 180 s) and, when the sync reports synced, records the catalog etag read before it as `catalogSyncedEtag`; with a restart it reads the link's activity after that sync and sends the restart as a second pinned sync carrying `--restart-app-server-only` or `--restart-codex`, withheld (`restart.outcome: "skipped"`, reason `restart-skipped:link-active`) while a relayed request is in flight or the activity is unknown. A second sync refused before an ocx was selected, or skipped by the Child, reports `not-run`; one that may have run but failed or timed out ends `uncertain`/`restart-result-unknown`. `restart-codex` is that path with the restart as its purpose: a busy link sends nothing (`busy`), Windows is refused without SSH (`windows-restart`), `ocx-child` reports `unsupported` (the record's `childKind` or the `selected-kind` marker). `update` sends an identity pinned sync first, then a fresh observe (Windows Children too, because `desktop-request` is decided before the `windows-apply` refusal), then the gated apply `update --yes --json --version --expect-plan --not-after ` (gate 2.97.0, bounded by `mutationBoundMs + 120 s`, not sent with under 15 s of the window left), through ` /bin/ocx.mjs` with `OCX_UPDATE_ORCHESTRATED_CHILD` unset only when the plan's installer is npm or pnpm (a missing Node exits 121 `child-node-unavailable`, owner action `launcher-node-missing`), and a pinned sync after every outcome that may have stopped the Child's proxy (success, failure, roll-back); the target is always the Home's version, exit 11 records `suppressedTarget` and a manual update clears it, an uncertain apply records `pendingUpdateRecovery` for an hour and is never re-sent. The Windows gated builder builds only `update --plan`. A legacy key admission, which is how a Child older than 2.69 reaches the Home, counts as activity only from its response headers, so a non-streaming request from such a Child is invisible to the restart guard while it runs. An item that ran writes its observation fields and then `lastOperation` to `child-observations.json`. `src/cli/link-child-ops.ts` (`runLinkCheck`, `runLinkChildOp`, the tolerant `validateChildJob`) polls every 2 s with a per-op item budget (observe 120 s, sync and restart-codex 420 s, update 2 100 s) that does not run while an item is queued, under a 3-hour cap, and exits 0, 6 for a partial result (including a success with a reason or a restart that did not go out), 1 for a failure, an uncertain item or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route or, for a non-observe op, one that answers `400 invalid_body`, 5 for a conflict. `ocx link update` needs `--yes`, as does any restart of scope `codex` and `restart-codex` itself. Coverage: `tests/server/link-child-ops-routes.test.ts`, `tests/clients/link-child-observe.test.ts`, `tests/clients/link-child-ops-engine.test.ts`, `tests/clients/link-child-ops-runners.test.ts`, `tests/clients/link-child-update-launcher.test.ts`, `tests/cli/cli-link-check.test.ts`, `tests/cli/cli-link-child-ops.test.ts`. ## Client link transport From ef06746d4a2a786eaee34f3cb0e49ea0547491da Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:27:19 +0900 Subject: [PATCH 12/18] docs: operate Home-initiated Children from the Home (8 languages) --- .../src/content/docs/fr/guides/remote-link.md | 10 +++++++++ .../docs/fr/reference/cli/lifecycle.md | 2 +- .../src/content/docs/guides/remote-link.md | 20 ++++++++++++++++- .../src/content/docs/ja/guides/remote-link.md | 10 +++++++++ .../docs/ja/reference/cli/lifecycle.md | 2 +- .../src/content/docs/ko/guides/remote-link.md | 10 +++++++++ .../docs/ko/reference/cli/lifecycle.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 22 +++++++++++++++++++ .../src/content/docs/ru/guides/remote-link.md | 10 +++++++++ .../docs/ru/reference/cli/lifecycle.md | 2 +- .../src/content/docs/tr/guides/remote-link.md | 10 +++++++++ .../docs/tr/reference/cli/lifecycle.md | 2 +- .../content/docs/zh-cn/guides/remote-link.md | 10 +++++++++ .../docs/zh-cn/reference/cli/lifecycle.md | 2 +- .../content/docs/zh-tw/guides/remote-link.md | 10 +++++++++ .../docs/zh-tw/reference/cli/lifecycle.md | 2 +- 16 files changed, 118 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 2f5b2b964ae..c0be03c6579 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -86,12 +86,22 @@ ocx link status [--json] ocx link revoke --link-id [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` Sans terminal interactif (TTY), `--expect-fingerprint` est obligatoire. Dans un terminal interactif, vous pouvez l’omettre si vous confirmez explicitement l’empreinte proposée. Sur le Home, `ocx link check (--link-id | --all)` lit pour chaque enfant lancé par le Home sa version installée, son type d’installation et sa voie de mise à jour : il exécute `ocx update --plan` de l’enfant par SSH (60 secondes par enfant, trois à la fois), qui calcule un plan sans rien modifier. Un enfant antérieur à 2.97.0 renvoie `owner-action` (`legacy-ocx`) ; sa version vient d’un appel SSH en lecture seule de plus sur macOS et Linux, ou de la version qu’il a annoncée par le lien sous Windows. Les liens ouverts depuis l’enfant et les enfants `ocx-child` sont signalés sans SSH. `ocx link status --json` ajoute alors un bloc `child` à chaque ligne (versions, écart de version, fraîcheur du catalogue, activité, dernière opération). Détails et codes de sortie : [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (en anglais). +### Opérer les enfants depuis le Home + +`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien et exige `--yes` ; il est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise ; il interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sinon, utilisez la page Remote Link du tableau de bord. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). + +Ne liez pas une machine qui ne vous appartient pas : le Home y exécute des commandes avec votre clé SSH. + ## Guides associés - [Déploiement Remote Hub](/fr/guides/remote-hub/) diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index dce969c0e9b..60b9764d1b9 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -169,7 +169,7 @@ Sur un Home Remote Link, après la validation et l’enregistrement d’un catal La propagation ne demande jamais le redémarrage de l’application Codex ni d’un app-server sur un Enfant. Pour cela, utilisez GOAT, exécutez `ocx sync --restart-codex` sur un Enfant `ocx` complet, ou relancez séparément Codex Desktop et les processus `app-server` sur un hôte léger `ocx-child`. La synchronisation épinglée de l’Enfant peut néanmoins redémarrer son propre runtime client OpenCodex après l’application de réglages Home modifiés. Si le Home interrompt son processus SSH local après un délai dépassé, une synchronisation distante déjà lancée peut continuer sur l’Enfant. -> **Enfant Remote Link :** sur un enfant `ocx` complet, `ocx sync` applique aussi les réglages Codex du Home et redémarre le runtime client s'il a démarré avant cette application ; si ce redémarrage échoue, l'ancien runtime continue de servir et la commande quitte avec un code non nul. Sur un hôte léger `ocx-child`, `ocx-child sync` démarre `ocx-child serve` s'il est absent, le relance lorsque `runtimeStartedAt < lastApplyAt` et publie la parité au mieux hors mode `stale` (un échec de relance arrête l'ancien relais et quitte avec un code non nul sans redémarrer Codex Desktop ni `app-server`, qu'il faut relancer séparément). Les champs de `ocx status --json` (ou `ocx-child status --json`) sur l'enfant et `ocx link status --json` sur le Home sont décrits dans [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (en anglais). Sur le Home, `ocx link check (--link-id | --all)` lit la version installée et la voie de mise à jour de chaque enfant lancé par le Home via `ocx update --plan` par SSH (60 secondes par enfant, sans rien installer), et `ocx link status --json` ajoute un bloc `child` à chaque ligne ; voir [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (en anglais). +> **Enfant Remote Link :** sur un enfant `ocx` complet, `ocx sync` applique aussi les réglages Codex du Home et redémarre le runtime client s'il a démarré avant cette application ; si ce redémarrage échoue, l'ancien runtime continue de servir et la commande quitte avec un code non nul. Sur un hôte léger `ocx-child`, `ocx-child sync` démarre `ocx-child serve` s'il est absent, le relance lorsque `runtimeStartedAt < lastApplyAt` et publie la parité au mieux hors mode `stale` (un échec de relance arrête l'ancien relais et quitte avec un code non nul sans redémarrer Codex Desktop ni `app-server`, qu'il faut relancer séparément). Les champs de `ocx status --json` (ou `ocx-child status --json`) sur l'enfant et `ocx link status --json` sur le Home sont décrits dans [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (en anglais). Sur le Home, `ocx link check (--link-id | --all)` lit la version installée et la voie de mise à jour de chaque enfant lancé par le Home via `ocx update --plan` par SSH (60 secondes par enfant, sans rien installer), et `ocx link status --json` ajoute un bloc `child` à chaque ligne ; voir [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (en anglais). `ocx link sync`, `ocx link restart-codex --yes` et `ocx link update --yes` lancent sur ces enfants des tâches de synchronisation, de redémarrage de Codex et de mise à jour, et affichent le document `ocx.link.child-job/1` ; voir [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). Un redémarrage explicitement demandé a également lieu après une modification de `config.toml` validée et enregistrée, même si les octets du catalogue et du cache restent identiques. Une opération sans changement de configuration, un refus, une validation préalable ou une écriture annulée ne le déclenche pas. Sans option de redémarrage, sync ne redémarre jamais Codex automatiquement. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 9d168a889f0..c63863adc47 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -147,6 +147,10 @@ ocx link status [--json] ocx link revoke --link-id [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` `ocx link status` needs the local admin token on a listener that only this machine can reach. When the public listener is bound to a non-loopback address, for example a Hub on `0.0.0.0`, no TCP listener qualifies: the hub management listener is reached through Tailscale Serve, so it refuses the raw admin token for link routes. On macOS and Linux the proxy therefore also opens a private Unix socket, `/run/control-.sock`, in a directory only your user can enter, and `ocx link status` uses it automatically. The socket answers only `GET /api/link/status` and still requires the admin token. Windows and runtimes older than this change keep the TCP path, where the read returns `403` on such a Hub. @@ -190,9 +194,23 @@ The version a Child reports is its own claim, bound to its link key. OpenCodex u - `activity.idle` is `true` when no relayed model request has run on that link for 10 minutes. `inFlight` counts the requests running now. Token counting, catalog and readiness reads do not count, and a request from a Child older than connection-bound relay authentication counts against every link. - `activeJob` names an operation running on that link, and `lastOperation` the last one that finished. `codex` is `null` for now. -`ocx link check --link-id ` (repeatable) or `ocx link check --all` asks Children for their install facts. For each Home-initiated Child the Home runs the Child's `ocx update --plan` over the link's SSH connection, which computes an update plan and changes nothing; each call has a 60-second budget and three run at a time. A Child older than 2.97.0 does not have that command: the item reads `owner-action` with reason `legacy-ocx`, and the Home learns the version from one more read-only SSH call on macOS and Linux, or from the version the Child reported over the link on Windows. On an npm or pnpm Child without a recorded serving install, the command runs through the `ocx` launcher, so the launcher's usual startup bookkeeping runs first, as it does for every `ocx sync` the Home sends there. Links a Child started from its own dashboard and `ocx-child` Children are reported without SSH. The command waits for the job and prints it as JSON (`--no-wait` prints it at once); it exits 0 when every Child answered, 6 when a Child is busy, offline, too old or not checkable, 1 on a failure, 4 for an unknown link or a Home too old for this command, and 5 when another operation is running on that link. +`ocx link check --link-id ` (repeatable) or `ocx link check --all` asks Children for their install facts. For each Home-initiated Child the Home runs the Child's `ocx update --plan` over the link's SSH connection, which computes an update plan and changes nothing; each call has a 60-second budget and three run at a time. A Child older than 2.97.0 does not have that command: the item reads `owner-action` with reason `legacy-ocx`, and the Home learns the version from one more read-only SSH call on macOS and Linux, or from the version the Child reported over the link on Windows. On an npm or pnpm Child without a recorded serving install, the command runs through the `ocx` launcher, so the launcher's usual startup bookkeeping runs first, as it does for every `ocx sync` the Home sends there. Links a Child started from its own dashboard and `ocx-child` Children are reported without SSH. The command waits for the job and prints it as JSON (`--no-wait` prints it at once); it exits 0 when every Child answered, 6 when a Child is busy, offline, too old or not checkable, 1 on a failure, 4 for an unknown link or a Home too old for this command, and 5 when another operation is running on that link or the Home has no links. +### Operating Children from the Home + +From the Home you can also sync a Home-initiated Child, restart Codex on it, and update its OpenCodex. Each command starts one job on the Home, runs over the link's SSH connection, waits for the job and prints it as JSON (`--no-wait` prints it at once). + +- `ocx link sync` runs the same pinned `ocx sync --expect-link` that `ocx sync` sends to Children. It needs OpenCodex 2.93.0 or later on the Child. +- `ocx link restart-codex --link-id --yes` syncs first and then restarts the Child's Codex app-servers, so a running Codex loads the current catalog. `--scope codex` also quits and relaunches the Codex desktop app, which needs a signed-in desktop session on the Child. `ocx link sync --link-id ` with `--restart-app-server-only` or `--restart-codex --yes` does the same. A restart ends the Codex turns running on that Child, so it names exactly one link. It needs 2.93.0 or later. +- `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again afterwards, also after a failed update. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. + +The Home does not restart Codex on a Child while it is relaying a model request for that Child: the restart is skipped (`restart-skipped:link-active`) and the sync still runs. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time, and removing a link waits until it finishes (`409 link_operation_in_progress`). + +The commands exit 0 when every Child succeeded or was already up to date, 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the Home has no links, or the command ran on a Child. The CLI needs a Home bound to loopback; on any other Home, use the Remote Link page of the dashboard. + +Do not link a machine you do not own: the Home runs commands there with your SSH key. + ### Standalone pairing authorization The existing `ocx gui pair --origin ` command works while OpenCodex runs as a background service. Run it under the account that owns the service's private OpenCodex home, then enter the returned one-use code in the local dashboard. No server terminal, additional password, Windows Hello or hardware key is required. The CLI and server must use the same current version and configuration home; restart an older server after updating. Mixed-version Hub invitations also require updating the CLI and server together. Pairing refuses a symlinked configuration home or intent directory; use the actual protected directory as `OPENCODEX_HOME`. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 72a8f2ab9b3..5fb2d86b391 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -86,12 +86,22 @@ ocx link status [--json] ocx link revoke --link-id [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` 対話型端末(TTY)でない場合、`--expect-fingerprint` は必須です。対話型端末では省略できますが、提示されたフィンガープリントを明示的に承認する必要があります。 Home で `ocx link check (--link-id | --all)` を実行すると、Home から追加した各 Child のインストール済みバージョン、インストール種別、更新経路を読み取ります。リンクの SSH 経由で Child の `ocx update --plan` を実行し(Child ごとに 60 秒、同時に 3 台まで)、計画を計算するだけで何も変更しません。2.97.0 より古い Child は `owner-action`(`legacy-ocx`)となり、バージョンは macOS と Linux では読み取り専用の SSH 呼び出しをもう 1 回行って、Windows ではリンク経由で報告されたバージョンから取得します。Child 側から接続したリンクと `ocx-child` の Child は SSH なしで報告されます。`ocx link status --json` は各行に `child` ブロック(バージョン、バージョン差、カタログの鮮度、アクティビティ、直近の操作)を追加します。詳細と終了コードは [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英語)を参照してください。 +### Home から Child を操作する + +`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定し、`--yes` が必要です。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理した後に再同期し、その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではダッシュボードの Remote Link ページを使ってください。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 + +自分が所有していないマシンをリンクしないでください。Home はあなたの SSH 鍵でそのマシン上のコマンドを実行します。 + ## 関連ガイド - [Remote Hub のデプロイ](/ja/guides/remote-hub/) diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 5cf94f691ab..f6d03a00734 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -176,7 +176,7 @@ Remote Link の Home では、最新のカタログが正常にコミットさ 伝播は Child の Codex アプリや app-server の再起動を要求しません。必要な場合は GOAT を使うか、フル `ocx` Child では `ocx sync --restart-codex` を実行してください(軽量 `ocx-child` Child では Codex デスクトップと `app-server` を別途再起動してください)。ただし、Child の固定先同期は、変更された Home 設定を適用した後、自身の OpenCodex クライアントランタイムを再起動することがあります。Home がタイムアウトしてローカルの SSH プロセスを停止しても、すでに開始されたリモート同期は Child 上で続くことがあります。 -> **Remote Link Child:** フル `ocx` Child では `ocx sync` が Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は再起動フラグなしでも再起動します。再起動に失敗しても以前のランタイムは動作し続け、コマンドは 0 以外で終了します。軽量 `ocx-child` ホストでは `ocx-child sync` が未起動時に `ocx-child serve` を起動し、`runtimeStartedAt < lastApplyAt` のときにローテーションして非 `stale` 同期時にベストエフォートで parity を報告します(ローテーション失敗時は旧リレー停止後に 0 以外で終了するため再実行で復旧し、Codex デスクトップや `app-server` の再起動は別途行ってください)。Child の `ocx status --json`(または `ocx-child status --json`)と Home の `ocx link status --json` の関連フィールドは [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英語)を参照してください。 Home では `ocx link check (--link-id | --all)` が SSH 経由の `ocx update --plan`(Child ごとに 60 秒、インストールは変更しません)で Home から追加した各 Child のインストール済みバージョンと更新経路を読み取り、`ocx link status --json` は各行に `child` ブロックを追加します。詳しくは [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英語)を参照してください。 +> **Remote Link Child:** フル `ocx` Child では `ocx sync` が Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は再起動フラグなしでも再起動します。再起動に失敗しても以前のランタイムは動作し続け、コマンドは 0 以外で終了します。軽量 `ocx-child` ホストでは `ocx-child sync` が未起動時に `ocx-child serve` を起動し、`runtimeStartedAt < lastApplyAt` のときにローテーションして非 `stale` 同期時にベストエフォートで parity を報告します(ローテーション失敗時は旧リレー停止後に 0 以外で終了するため再実行で復旧し、Codex デスクトップや `app-server` の再起動は別途行ってください)。Child の `ocx status --json`(または `ocx-child status --json`)と Home の `ocx link status --json` の関連フィールドは [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英語)を参照してください。 Home では `ocx link check (--link-id | --all)` が SSH 経由の `ocx update --plan`(Child ごとに 60 秒、インストールは変更しません)で Home から追加した各 Child のインストール済みバージョンと更新経路を読み取り、`ocx link status --json` は各行に `child` ブロックを追加します。詳しくは [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英語)を参照してください。`ocx link sync`、`ocx link restart-codex --yes`、`ocx link update --yes` はこれらの Child で同期、Codex 再起動、更新のジョブを実行し、`ocx.link.child-job/1` ドキュメントを出力します。詳しくは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 再起動フラグを明示した場合、カタログとキャッシュのバイト列が変わらなくても、`config.toml` の変更が正常にコミットされれば再起動します。設定だけを扱う操作が変更なし、拒否、事前検証、ロールバックとなった場合は再起動しません。再起動フラグがなければ、sync が Codex を自動的に再起動することはありません。 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 43a1f35793b..84fc1cd40bd 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -86,12 +86,22 @@ ocx link status [--json] ocx link revoke --link-id [--purge] [--force --yes] [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` 비대화형 실행(TTY 없음)에서는 `--expect-fingerprint`가 필수입니다. 대화형 터미널에서는 옵션을 생략할 수 있지만, 제시된 지문을 명시적으로 확인해야 합니다. Home에서 `ocx link check (--link-id | --all)`를 실행하면 Home이 추가한 각 Child의 설치 버전, 설치 종류, 업데이트 경로를 읽습니다. 링크의 SSH로 Child의 `ocx update --plan`을 실행하며(Child마다 60초, 동시에 세 대), 이 명령은 계획만 계산하고 아무것도 바꾸지 않습니다. 2.97.0보다 오래된 Child는 `owner-action`(`legacy-ocx`)으로 표시되고, 버전은 macOS와 Linux에서는 읽기 전용 SSH 호출을 한 번 더 해서, Windows에서는 링크로 보고된 버전에서 가져옵니다. Child 쪽에서 연결한 링크와 `ocx-child` Child는 SSH 없이 보고됩니다. `ocx link status --json`은 각 행에 `child` 블록(버전, 버전 차이, 카탈로그 최신 여부, 활동, 마지막 작업)을 추가합니다. 자세한 내용과 종료 코드는 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(영문)를 참고하세요. +### Home에서 Child 운영하기 + +`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정하고 `--yes`가 필요하며, Home이 그 Child의 모델 요청을 중계하는 동안에는 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리한 뒤 다시 동기화하며, 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요하며, 그렇지 않은 Home에서는 대시보드의 Remote Link 페이지를 사용하세요. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. + +소유하지 않은 컴퓨터는 링크하지 마세요. Home은 그 컴퓨터에서 사용자의 SSH 키로 명령을 실행합니다. + ## 관련 가이드 - [Remote Hub 배포](/ko/guides/remote-hub/) diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 9c29a2e8d42..383a0a8ee2a 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -264,7 +264,7 @@ Remote Link Home에서는 최신 카탈로그가 성공적으로 커밋된 뒤 ` 전파는 Child의 Codex 앱이나 app-server 재시작을 요청하지 않습니다. 재시작이 필요하면 GOAT를 사용하거나, 전체 `ocx` Child에서는 `ocx sync --restart-codex`를 실행하고 경량 `ocx-child` 호스트에서는 Codex 데스크톱 앱과 `app-server`를 별도로 재시작하세요. 다만 Child의 고정 대상 동기화는 변경된 Home 설정을 적용한 뒤 자체 OpenCodex 클라이언트 런타임을 재시작할 수 있습니다. Home이 시간 초과로 로컬 SSH 프로세스를 중지하더라도 이미 시작된 원격 동기화는 Child에서 계속될 수 있습니다. -> **Remote Link Child:** 전체 `ocx` Child에서는 `ocx sync`가 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 재시작하고 실패하더라도 이전 런타임이 계속 서비스한 채 0이 아닌 코드로 끝납니다. 경량 `ocx-child` 호스트에서는 `ocx-child sync`가 릴레이가 없을 때 `ocx-child serve`를 시작하고 `runtimeStartedAt < lastApplyAt`이면 교체하며 `stale`이 아닌 동기화에서 최선을 다해 parity를 보고합니다(교체 실패 시 이전 릴레이가 멈춘 상태로 0이 아닌 코드로 끝나며, Codex 데스크톱 앱과 장기 실행 `app-server` 프로세스는 별도로 재시작해야 합니다). Child의 `ocx status --json`(또는 `ocx-child status --json`)과 Home의 `ocx link status --json` 필드는 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(영문)를 참고하세요. Home에서는 `ocx link check (--link-id | --all)`가 SSH로 `ocx update --plan`을 실행해(Child마다 60초, 설치는 바꾸지 않음) Home이 추가한 각 Child의 설치 버전과 업데이트 경로를 읽고, `ocx link status --json`은 각 행에 `child` 블록을 추가합니다. 자세한 내용은 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(영문)를 참고하세요. +> **Remote Link Child:** 전체 `ocx` Child에서는 `ocx sync`가 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 재시작하고 실패하더라도 이전 런타임이 계속 서비스한 채 0이 아닌 코드로 끝납니다. 경량 `ocx-child` 호스트에서는 `ocx-child sync`가 릴레이가 없을 때 `ocx-child serve`를 시작하고 `runtimeStartedAt < lastApplyAt`이면 교체하며 `stale`이 아닌 동기화에서 최선을 다해 parity를 보고합니다(교체 실패 시 이전 릴레이가 멈춘 상태로 0이 아닌 코드로 끝나며, Codex 데스크톱 앱과 장기 실행 `app-server` 프로세스는 별도로 재시작해야 합니다). Child의 `ocx status --json`(또는 `ocx-child status --json`)과 Home의 `ocx link status --json` 필드는 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(영문)를 참고하세요. Home에서는 `ocx link check (--link-id | --all)`가 SSH로 `ocx update --plan`을 실행해(Child마다 60초, 설치는 바꾸지 않음) Home이 추가한 각 Child의 설치 버전과 업데이트 경로를 읽고, `ocx link status --json`은 각 행에 `child` 블록을 추가합니다. 자세한 내용은 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(영문)를 참고하세요. `ocx link sync`, `ocx link restart-codex --yes`, `ocx link update --yes`는 이 Child들에서 동기화, Codex 재시작, 업데이트 작업을 실행하고 `ocx.link.child-job/1` 문서를 출력합니다. 자세한 내용은 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. 재시작 플래그를 명시하면 카탈로그와 캐시 바이트가 같아도 `config.toml` 변경이 성공적으로 커밋된 뒤 재시작합니다. 설정만 다루는 작업이 변경 없음, 거부, 사전 검증 또는 롤백으로 끝나면 재시작하지 않습니다. 재시작 플래그가 없으면 sync는 Codex를 자동으로 재시작하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index b326a98df64..d1248325078 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1374,6 +1374,28 @@ version; a Windows Child that old reports the version it sent over the link. The Lightweight `ocx-child` Child hosts run `ocx-child sync [--expect-link ] [--json]`, `ocx-child status [--json]` (while `ocx link status --json` remains on the Home), and `ocx-child disconnect` / `ocx-child detach`: on an attached `ocx-child` host (which runs without a system service manager), a successful `ocx-child sync` starts `ocx-child serve` if no relay is running even when settings are unchanged, rolls it over when `runtimeStartedAt < lastApplyAt`, and posts a best-effort parity report on non-stale syncs. `ocx-child sync` accepts `--restart-codex` / `--restart-app-server-only` for flag compatibility without restarting Codex processes (restart the Codex desktop app and long-lived `app-server` processes separately if needed), and `ocx-child disconnect` / `ocx-child detach` restores `~/.codex` files offline and stops `ocx-child serve` with zero background daemon left running. Only one `ocx-child` install may manage a given `CODEX_HOME` at a time: lifecycle commands lock `CODEX_HOME/.ocx-child.lock` (kept permanently) and record the owning `OPENCODEX_HOME` in `CODEX_HOME/.ocx-child-owner.json` until `ocx-child detach` completes, so an `ocx-child attach` or `sync` from a different `OPENCODEX_HOME` sharing that `CODEX_HOME` exits nonzero without changing any file and names the owning home to detach first. +## Child operations from the Home + +```text +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] +``` + +These commands start one job on the Home for Home-initiated Children and print it as an `ocx.link.child-job/1` document: `{schema, jobId, op, trigger, restart, state, createdAt, finishedAt, items}`. Each item carries `linkId`, `alias`, `state`, `reason`, `startedAt` and `finishedAt`, and where they apply `hint`, `fromVersion`, `toVersion`, `sync` (`{state, reason}`) and `restart` (`{requested, outcome}`, where `outcome` is `requested`, `unsupported`, `not-run` or `skipped`). What each command runs on the Child, the version floors and the Windows and `ocx-child` limits are in [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home). A restart names exactly one link; `update`, `restart-codex` and `--restart-codex` need `--yes`. + +| Exit | Meaning | +|---|---| +| 0 | every item `succeeded` or is `up-to-date`, and every requested restart went out | +| 6 | an item is `busy`, `offline`, `refused`, `owner-action` or `unsupported`, or succeeded with a reason such as `restart-skipped:link-active` or `post-update-sync-failed` | +| 1 | an item `failed` or is `uncertain`, an item state this CLI does not know, the job did not finish within its wait, or a Home not bound to loopback | +| 2 | invalid arguments, for example a restart with `--all` or `update` without `--yes` | +| 4 | an unknown link, or a Home without Child operations | +| 5 | another operation holds the link, the Home has no links, or the command ran on a Child | + +The wait does not count time an item spends queued behind another job. Removing a link while a job holds it answers `409 link_operation_in_progress`. + ## Remote Link Child attestations A peer that sends work through a Remote Link Child can check two more facts in the Child's diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index d22d879baf8..a3888d026a7 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -86,12 +86,22 @@ ocx link status [--json] ocx link revoke --link-id [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` Без интерактивного терминала (TTY) параметр `--expect-fingerprint` обязателен. В интерактивном терминале его можно опустить, если явно подтвердить предложенный отпечаток. На Home команда `ocx link check (--link-id | --all)` считывает для каждого Child, добавленного с Home, установленную версию, тип установки и путь обновления: она запускает на Child `ocx update --plan` по SSH-соединению связи (60 секунд на Child, не более трёх одновременно), а эта команда только вычисляет план и ничего не меняет. Child старше 2.97.0 получает `owner-action` (`legacy-ocx`); его версия берётся из ещё одного SSH-вызова только для чтения на macOS и Linux или из версии, переданной по связи, на Windows. Связи, начатые со стороны Child, и Child `ocx-child` отображаются без SSH. `ocx link status --json` добавляет к каждой строке блок `child` (версии, расхождение версий, актуальность каталога, активность, последняя операция). Подробности и коды выхода: [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (на английском). +### Управление Child с Home + +`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку и требует `--yes`; он пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, после чего снова синхронизирует; запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; в остальных случаях используйте страницу Remote Link в панели управления. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). + +Не связывайте машину, которая вам не принадлежит: Home выполняет на ней команды с вашим SSH-ключом. + ## Связанные руководства - [Развёртывание Remote Hub](/ru/guides/remote-hub/) diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 9e19b55e4f2..bcfa0a55365 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -265,7 +265,7 @@ credential'ы и не выполняет repair. Рассылка никогда не запрашивает перезапуск приложения Codex или app-server на дочерней машине. Для этого используйте GOAT, выполните `ocx sync --restart-codex` на полном Child с `ocx` или перезапустите приложение Codex Desktop и процессы `app-server` отдельно на легковесном хосте `ocx-child`. При этом её синхронизация с закреплённым Home может перезапустить собственный клиентский рантайм OpenCodex после применения изменённых настроек Home. Если Home завершит локальный процесс SSH по тайм-ауту, уже начатая удалённая синхронизация может продолжиться на дочерней машине. -> **Дочерняя машина Remote Link:** на полном Child с `ocx` команда `ocx sync` также применяет настройки Codex с Home и перезапускает клиентский рантайм, если он запущен раньше применения; при сбое перезапуска прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. На легковесном хосте `ocx-child` команда `ocx-child sync` запускает `ocx-child serve`, если ретранслятор отсутствует, перезапускает его при `runtimeStartedAt < lastApplyAt` и отправляет отчёт о паритете по принципу best-effort вне режима `stale` (при сбое смены прежний ретранслятор останавливается и команда завершается с ненулевым кодом, а приложение Codex Desktop и долгоживущие процессы `app-server` при необходимости перезапускаются отдельно). Поля `ocx status --json` (или `ocx-child status --json`) на Child и `ocx link status --json` на Home описаны в разделе [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (на английском). На Home команда `ocx link check (--link-id | --all)` считывает установленную версию и путь обновления каждого Child, добавленного с Home, через `ocx update --plan` по SSH (60 секунд на Child, установка не меняется), а `ocx link status --json` добавляет к каждой строке блок `child`; см. [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (на английском). +> **Дочерняя машина Remote Link:** на полном Child с `ocx` команда `ocx sync` также применяет настройки Codex с Home и перезапускает клиентский рантайм, если он запущен раньше применения; при сбое перезапуска прежний рантайм продолжает работать, а команда завершается с ненулевым кодом. На легковесном хосте `ocx-child` команда `ocx-child sync` запускает `ocx-child serve`, если ретранслятор отсутствует, перезапускает его при `runtimeStartedAt < lastApplyAt` и отправляет отчёт о паритете по принципу best-effort вне режима `stale` (при сбое смены прежний ретранслятор останавливается и команда завершается с ненулевым кодом, а приложение Codex Desktop и долгоживущие процессы `app-server` при необходимости перезапускаются отдельно). Поля `ocx status --json` (или `ocx-child status --json`) на Child и `ocx link status --json` на Home описаны в разделе [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (на английском). На Home команда `ocx link check (--link-id | --all)` считывает установленную версию и путь обновления каждого Child, добавленного с Home, через `ocx update --plan` по SSH (60 секунд на Child, установка не меняется), а `ocx link status --json` добавляет к каждой строке блок `child`; см. [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (на английском). `ocx link sync`, `ocx link restart-codex --yes` и `ocx link update --yes` запускают на этих Child задания синхронизации, перезапуска Codex и обновления и выводят документ `ocx.link.child-job/1`; см. [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). Явно запрошенный перезапуск также выполняется после успешного сохранения изменения `config.toml`, даже если байты каталога и кэша не изменились. Операция только с конфигурацией без изменений, отказ, предварительная проверка или отменённая запись не вызывают перезапуск. Без флага перезапуска sync никогда не перезапускает Codex автоматически. diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index 101909a770e..a80d975037b 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -86,12 +86,22 @@ ocx link status [--json] ocx link revoke --link-id [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` TTY olmayan kullanımda `--expect-fingerprint` zorunludur. Etkileşimli bir terminalde sunulan parmak izini açıkça onaylayarak bu seçeneği atlayabilirsiniz. Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her Child için kurulu sürümü, kurulum türünü ve güncelleme yolunu okur: bağlantının SSH oturumu üzerinden Child'ın `ocx update --plan` komutunu çalıştırır (Child başına 60 saniye, aynı anda üç tane); bu komut yalnızca bir plan hesaplar, hiçbir şeyi değiştirmez. 2.97.0'dan eski bir Child `owner-action` (`legacy-ocx`) olarak görünür; sürümü macOS ve Linux'ta salt okunur bir SSH çağrısıyla daha, Windows'ta ise bağlantı üzerinden bildirdiği sürümden alınır. Child tarafından başlatılan bağlantılar ve `ocx-child` Child'ları SSH olmadan raporlanır. `ocx link status --json` her satıra bir `child` bloğu (sürümler, sürüm farkı, katalog güncelliği, etkinlik, son işlem) ekler. Ayrıntılar ve çıkış kodları: [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (İngilizce). +### Child'ları Home'dan yönetmek + +`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler ve `--yes` gerektirir; Home o Child için bir model isteği aktarırken atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir ve ardından yeniden eşitlenir; o Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; aksi halde panodaki Remote Link sayfasını kullanın. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). + +Size ait olmayan bir makineyi bağlamayın: Home orada sizin SSH anahtarınızla komut çalıştırır. + ## İlgili kılavuzlar - [Remote Hub Dağıtımı](/tr/guides/remote-hub/) diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 4dfd835cc6d..c6d6e2a3587 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -262,7 +262,7 @@ Remote Link Home üzerinde güncel katalog başarıyla kaydedildikten sonra `ocx Dağıtım, bir Child üzerinde Codex uygulamasının veya app-server'ın yeniden başlatılmasını asla istemez. Bunun için GOAT kullanın, tam `ocx` Child üzerinde `ocx sync --restart-codex` çalıştırın veya hafif `ocx-child` makinelerinde Codex masaüstü uygulamasını ve `app-server` süreçlerini ayrıca yeniden başlatın. Bununla birlikte Child'ın sabitlenmiş eşitlemesi, değişen Home ayarlarını uyguladıktan sonra kendi OpenCodex istemci çalışma zamanını yeniden başlatabilir. Home zaman aşımında yerel SSH işlemini durdursa bile başlamış bir uzak eşitleme Child üzerinde devam edebilir. -> **Remote Link Child:** Tam `ocx` Child makinelerinde `ocx sync` Home'un Codex ayarlarını da uygular ve istemci çalışma zamanı bu uygulamadan önce başlamışsa yeniden başlatır; yeniden başlatma başarısız olursa önceki çalışma zamanı hizmet vermeye devam eder ve komut sıfırdan farklı bir kodla çıkar. Hafif `ocx-child` makinelerinde `ocx-child sync`, röle çalışmıyorsa `ocx-child serve` sürecini başlatır, `runtimeStartedAt < lastApplyAt` olduğunda yeniler ve `stale` olmayan eşitlemelerde en iyi çabayla parite bildirir (yenileme başarısız olursa önceki röle durdurulmuş olarak sıfırdan farklı kodla çıkar; Codex masaüstü uygulaması ve uzun ömürlü `app-server` süreçleri gerekirse ayrıca yeniden başlatılmalıdır). Child üzerindeki `ocx status --json` (veya `ocx-child status --json`) ile Home üzerindeki `ocx link status --json` alanları için [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (İngilizce) bölümüne bakın. Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her Child'ın kurulu sürümünü ve güncelleme yolunu SSH üzerinden `ocx update --plan` ile okur (Child başına 60 saniye, kurulumu değiştirmez); `ocx link status --json` her satıra bir `child` bloğu ekler. Bkz. [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (İngilizce). +> **Remote Link Child:** Tam `ocx` Child makinelerinde `ocx sync` Home'un Codex ayarlarını da uygular ve istemci çalışma zamanı bu uygulamadan önce başlamışsa yeniden başlatır; yeniden başlatma başarısız olursa önceki çalışma zamanı hizmet vermeye devam eder ve komut sıfırdan farklı bir kodla çıkar. Hafif `ocx-child` makinelerinde `ocx-child sync`, röle çalışmıyorsa `ocx-child serve` sürecini başlatır, `runtimeStartedAt < lastApplyAt` olduğunda yeniler ve `stale` olmayan eşitlemelerde en iyi çabayla parite bildirir (yenileme başarısız olursa önceki röle durdurulmuş olarak sıfırdan farklı kodla çıkar; Codex masaüstü uygulaması ve uzun ömürlü `app-server` süreçleri gerekirse ayrıca yeniden başlatılmalıdır). Child üzerindeki `ocx status --json` (veya `ocx-child status --json`) ile Home üzerindeki `ocx link status --json` alanları için [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (İngilizce) bölümüne bakın. Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her Child'ın kurulu sürümünü ve güncelleme yolunu SSH üzerinden `ocx update --plan` ile okur (Child başına 60 saniye, kurulumu değiştirmez); `ocx link status --json` her satıra bir `child` bloğu ekler. Bkz. [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity) (İngilizce). `ocx link sync`, `ocx link restart-codex --yes` ve `ocx link update --yes` bu Child'larda eşitleme, Codex yeniden başlatma ve güncelleme işleri çalıştırır ve `ocx.link.child-job/1` belgesini yazdırır; bkz. [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). Açıkça istenen yeniden başlatma, katalog ve önbellek baytları değişmese bile `config.toml` değişikliği başarıyla kaydedildikten sonra da çalışır. Yalnızca yapılandırmayı işleyen bir işlem değişiklik yapmadığında, reddedildiğinde, ön kontrolde kaldığında veya yazma geri alındığında yeniden başlatma tetiklenmez. Yeniden başlatma bayrağı olmadan sync, Codex’i otomatik olarak yeniden başlatmaz. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 5c44cc79c34..349d358138a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -86,12 +86,22 @@ ocx link status [--json] ocx link revoke --link-id [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` 非交互式终端(非 TTY)必须提供 `--expect-fingerprint`。交互式终端中可省略该参数,但必须明确确认所提供的指纹。 在 Home 上,`ocx link check (--link-id | --all)` 会读取每个由 Home 添加的子机的已安装版本、安装类型和更新途径:它通过链接的 SSH 连接在子机上运行 `ocx update --plan`(每台子机 60 秒,同时最多三台),该命令只计算更新计划,不做任何修改。早于 2.97.0 的子机显示为 `owner-action`(`legacy-ocx`),其版本在 macOS 和 Linux 上通过再一次只读 SSH 调用获取,在 Windows 上取自子机通过链接上报的版本。由子机发起的链接和 `ocx-child` 子机不经 SSH 直接报告。`ocx link status --json` 会为每一行添加 `child` 块(版本、版本差异、目录是否最新、活动情况、最近一次操作)。详情和退出码见 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。 +### 从 Home 操作子机 + +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接,并且需要 `--yes`;当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,之后再次同步;正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;否则请使用仪表盘的 Remote Link 页面。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 + +不要链接不属于你的机器:Home 会用你的 SSH 密钥在那里运行命令。 + ## 相关指南 - [Remote Hub 部署](/zh-cn/guides/remote-hub/) diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 271c7ae50d9..8a49ae4e191 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -172,7 +172,7 @@ ocx status --json 传播不会请求重启 Child 上的 Codex 应用或 app-server。如需重启,请使用 GOAT,在完整 `ocx` Child 上运行 `ocx sync --restart-codex`,或在轻量级 `ocx-child` 主机上单独重启 Codex 桌面应用与 `app-server` 进程。不过,Child 的固定目标同步在应用已更改的 Home 设置后,仍可能重启它自己的 OpenCodex 客户端运行时。Home 超时并停止本地 SSH 进程后,已开始的远程同步仍可能在 Child 上继续。 -> **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 还会应用 Home 的 Codex 设置并在客户端运行时早于该次应用启动时重启它;重启失败时原有运行时继续提供服务并以非零状态退出。在轻量级 `ocx-child` 主机上,`ocx-child sync` 会在中继未运行时启动 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 时轮换进程,并在非 `stale` 同步时尽力上报 parity(若轮换失败,旧中继已停止且命令以非零状态退出;Codex 桌面应用与长期运行的 `app-server` 进程如需刷新请单独重启)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)与 Home 上的 `ocx link status --json` 相关字段见 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 在 Home 上,`ocx link check (--link-id | --all)` 通过 SSH 运行 `ocx update --plan`(每台子机 60 秒,不修改安装)读取每个由 Home 添加的子机的已安装版本和更新途径,`ocx link status --json` 会为每一行添加 `child` 块;详见 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。 +> **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 还会应用 Home 的 Codex 设置并在客户端运行时早于该次应用启动时重启它;重启失败时原有运行时继续提供服务并以非零状态退出。在轻量级 `ocx-child` 主机上,`ocx-child sync` 会在中继未运行时启动 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 时轮换进程,并在非 `stale` 同步时尽力上报 parity(若轮换失败,旧中继已停止且命令以非零状态退出;Codex 桌面应用与长期运行的 `app-server` 进程如需刷新请单独重启)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)与 Home 上的 `ocx link status --json` 相关字段见 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 在 Home 上,`ocx link check (--link-id | --all)` 通过 SSH 运行 `ocx update --plan`(每台子机 60 秒,不修改安装)读取每个由 Home 添加的子机的已安装版本和更新途径,`ocx link status --json` 会为每一行添加 `child` 块;详见 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。`ocx link sync`、`ocx link restart-codex --yes` 和 `ocx link update --yes` 会在这些子机上运行同步、Codex 重启和更新任务,并输出 `ocx.link.child-job/1` 文档;详见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 显式指定重启标志后,即使目录和缓存的字节未变,成功提交 `config.toml` 更改也会触发重启。仅处理配置的操作若没有更改、被拒绝、仅作预检或写入已回滚,则不会触发重启。未指定重启标志时,sync 不会自动重启 Codex。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index 66b8d30f28b..9e9202a9cfc 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -86,12 +86,22 @@ ocx link status [--json] ocx link revoke --link-id [--json] ocx link reconnect --link-id [--json] ocx link check (--link-id | --all) [--no-wait] [--json] +ocx link update (--link-id | --all) --yes [--no-wait] [--json] +ocx link sync (--link-id | --all) [--no-wait] [--json] +ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json] +ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json] ``` 非互動式終端(非 TTY)必須提供 `--expect-fingerprint`。互動式終端中可省略此參數,但必須明確確認提供的指紋。 在 Home 上,`ocx link check (--link-id | --all)` 會讀取每個由 Home 新增的 Child 的已安裝版本、安裝類型和更新途徑:它透過連結的 SSH 連線在 Child 上執行 `ocx update --plan`(每台 Child 60 秒,同時最多三台),該命令只計算更新計畫,不會做任何修改。早於 2.97.0 的 Child 會顯示為 `owner-action`(`legacy-ocx`),其版本在 macOS 和 Linux 上透過再一次唯讀 SSH 呼叫取得,在 Windows 上則取自 Child 透過連結回報的版本。由 Child 發起的連結和 `ocx-child` Child 不經 SSH 直接回報。`ocx link status --json` 會為每一列加上 `child` 區塊(版本、版本差異、目錄是否最新、活動狀態、最近一次操作)。詳情與結束代碼請見 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。 +### 從 Home 操作 Child + +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結,且需要 `--yes`;當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,之後再次同步;正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;否則請使用儀表板的 Remote Link 頁面。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 + +不要連結不屬於你的機器:Home 會用你的 SSH 金鑰在那裡執行指令。 + ## 相關指南 - [Remote Hub 部署](/zh-tw/guides/remote-hub/) diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index ca05f55fd0f..e7629d78ed9 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -164,7 +164,7 @@ ocx status --json 傳播不會要求重新啟動 Child 上的 Codex 應用程式或 app-server。若需要重新啟動,請使用 GOAT,在完整 `ocx` Child 執行 `ocx sync --restart-codex`,或在輕量級 `ocx-child` 主機上單獨重新啟動 Codex 桌面應用程式與 `app-server` 程序。不過,Child 的固定目標同步在套用變更後的 Home 設定時,仍可能重新啟動它自己的 OpenCodex 用戶端執行階段。Home 逾時並停止本機 SSH 程序後,已開始的遠端同步仍可能在 Child 上繼續。 -> **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 也會套用 Home 的 Codex 設定並在用戶端執行階段早於該次套用啟動時重新啟動它;重新啟動失敗時原有執行階段會繼續提供服務並以非零狀態結束。在輕量級 `ocx-child` 主機上,`ocx-child sync` 會在中繼未執行時啟動 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 時輪替程序,並在非 `stale` 同步時盡力回報 parity(若輪替失敗,舊中繼已停止且指令以非零狀態結束;Codex 桌面應用程式與長期執行的 `app-server` 程序如需重新載入請單獨重新啟動)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)與 Home 上的 `ocx link status --json` 相關欄位請見 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 在 Home 上,`ocx link check (--link-id | --all)` 透過 SSH 執行 `ocx update --plan`(每台 Child 60 秒,不修改安裝)讀取每個由 Home 新增的 Child 的已安裝版本和更新途徑,`ocx link status --json` 會為每一列加上 `child` 區塊;詳見 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。 +> **Remote Link Child:** 在完整 `ocx` Child 上,`ocx sync` 也會套用 Home 的 Codex 設定並在用戶端執行階段早於該次套用啟動時重新啟動它;重新啟動失敗時原有執行階段會繼續提供服務並以非零狀態結束。在輕量級 `ocx-child` 主機上,`ocx-child sync` 會在中繼未執行時啟動 `ocx-child serve`,在 `runtimeStartedAt < lastApplyAt` 時輪替程序,並在非 `stale` 同步時盡力回報 parity(若輪替失敗,舊中繼已停止且指令以非零狀態結束;Codex 桌面應用程式與長期執行的 `app-server` 程序如需重新載入請單獨重新啟動)。Child 上的 `ocx status --json`(或 `ocx-child status --json`)與 Home 上的 `ocx link status --json` 相關欄位請見 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英文)。 在 Home 上,`ocx link check (--link-id | --all)` 透過 SSH 執行 `ocx update --plan`(每台 Child 60 秒,不修改安裝)讀取每個由 Home 新增的 Child 的已安裝版本和更新途徑,`ocx link status --json` 會為每一列加上 `child` 區塊;詳見 [Child install, catalog and activity](/guides/remote-link/#child-install-catalog-and-activity)(英文)。`ocx link sync`、`ocx link restart-codex --yes` 和 `ocx link update --yes` 會在這些 Child 上執行同步、Codex 重新啟動和更新工作,並輸出 `ocx.link.child-job/1` 文件;詳見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 明確指定重新啟動旗標後,即使目錄和快取的位元組未變,成功提交 `config.toml` 變更也會觸發重新啟動。僅處理設定的操作若沒有變更、遭拒、只做預先檢查或寫入已回復,則不會觸發重新啟動。未指定重新啟動旗標時,sync 不會自動重新啟動 Codex。 From 99f9b7378be6751c5dda10c3dba7ae8421652ea5 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:29:57 +0900 Subject: [PATCH 13/18] fix(link): give the launcher Node probe its own watchdog variable; test a hanging Node --- src/link/ssh-argv.ts | 2 +- .../link-child-update-launcher.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/link/ssh-argv.ts b/src/link/ssh-argv.ts index aecfae92c35..3eec8dc5e93 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -389,7 +389,7 @@ trap 'rm -rf "$dir"' EXIT HUP INT TERM launcher=${cmd.launcher} node_ok() { ( ulimit -c 0; exec "$1" --version ) >/dev/null 2>&1 & np=$! - ( sleep 10 & timer=$!; trap 'kill "$timer" 2>/dev/null; wait "$timer" 2>/dev/null; exit' TERM HUP INT; : > "$ready"; wait "$timer" 2>/dev/null; kill -9 "$np" 2>/dev/null ) >/dev/null 2>&1 & nwd=$! + ( sleep 10 & ntimer=$!; trap 'kill "$ntimer" 2>/dev/null; wait "$ntimer" 2>/dev/null; exit' TERM HUP INT; : > "$ready"; wait "$ntimer" 2>/dev/null; kill -9 "$np" 2>/dev/null ) >/dev/null 2>&1 & nwd=$! while [ ! -f "$ready" ]; do kill -0 "$nwd" 2>/dev/null || break; sleep 0.01 >/dev/null 2>&1; done { wait "$np"; nstatus=$?; } 2>/dev/null kill "$nwd" 2>/dev/null; wait "$nwd" 2>/dev/null; rm -f "$ready" diff --git a/tests/clients/link-child-update-launcher.test.ts b/tests/clients/link-child-update-launcher.test.ts index a8affccce76..a04ecf8da64 100644 --- a/tests/clients/link-child-update-launcher.test.ts +++ b/tests/clients/link-child-update-launcher.test.ts @@ -110,6 +110,25 @@ describe("A4 launcher route on fake package installs", () => { expect(applied.node).toBe("--version\n--version\n"); }); + posix("F4: a Node that hangs on --version is killed by its own watchdog and the plan still runs", () => { + const fixture = install(NPM_ROOT); + const stuck = join(fixture.home, "prefix", "bin", "node"); + mkdirSync(join(stuck, ".."), { recursive: true }); + writeFileSync(stuck, "#!/bin/sh\nif [ \"$1\" = --version ]; then exec sleep 30; fi\nprintf '%s\\n' \"$*\" >> \"$HOME/node.log\"\n", { mode: 0o755 }); + // The kill path is the subject: the shipped 10 s Node budget is pinned, then shortened to 1 s for the run. + const argv = plan(); + expect(argv[2]!.split("( sleep 10 & ntimer=$!;").length).toBe(2); + argv[2] = argv[2]!.replace("( sleep 10 & ntimer=$!;", "( sleep 1 & ntimer=$!;"); + const started = Date.now(); + const result = run(fixture, argv); + expect(Date.now() - started).toBeGreaterThanOrEqual(1000); + expect(Date.now() - started).toBeLessThan(10_000); + expect(result.code).toBe(0); + expect(result.stderr).toContain("ocx-fanout:launcher:unavailable\n"); + expect(result.bun).toBe(fixture.entry + " update --plan --json --version 2.100.0\n"); + expect(result.node).toBe(""); + }); + posix("Bun global: plan and apply stay on the served Bun entry", () => { const fixture = install(join(".bun", "install", "global", "node_modules", "@bitkyc08", "opencodex")); const nodeDir = join(fixture.home, "path-node"); From ce5afe8d13b5ab41be548cccec341268f8182bbc Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:42:31 +0900 Subject: [PATCH 14/18] fix(link): reserve a link during removal; clamp the apply timeout; bounded hints; spawn and toVersion handling --- src/link/child-ops-engine.ts | 19 ++++++ src/link/child-ops-runners.ts | 53 ++++++++++----- src/server/management/link-routes.ts | 21 +++--- tests/clients/link-child-ops-engine.test.ts | 36 ++++++++++ tests/clients/link-child-ops-runners.test.ts | 64 ++++++++++++++++-- tests/server/link-child-ops-routes.test.ts | 69 +++++++++++++++++++- 6 files changed, 227 insertions(+), 35 deletions(-) diff --git a/src/link/child-ops-engine.ts b/src/link/child-ops-engine.ts index 9fa06acca51..f167af841ea 100644 --- a/src/link/child-ops-engine.ts +++ b/src/link/child-ops-engine.ts @@ -26,6 +26,12 @@ export interface ChildOpsEngine { /** A structuredClone snapshot. */ job(jobId: string): ChildJob | undefined; activeJob(linkId: string): ChildJobHold | null; + /** + * Take the link's admission lock for a non-job holder (link removal). Returns null when a job holds the link; + * otherwise a release function. While reserved, a single-link submit answers link_operation_in_progress and a + * multi-link item for the link ends busy, exactly as for a link a job holds. Synchronous, like submit. + */ + reserve(linkId: string): (() => void) | null; /** Resolves once no job of this engine is queued or running. Tests and shutdown only. */ settled(): Promise; } @@ -67,6 +73,7 @@ export function createChildOpsEngine(options: ChildOpsEngineOptions = {}): Child const pending: Array<{ job: ChildJob; item: ChildJobItem; env: ChildOpsEnv }> = []; let active = 0; let activeUpdates = 0; + let reservations = 0; const write = (env: ChildOpsEnv, linkId: string, patch: Partial): void => { try { env.writeObservation(linkId, patch); } catch (error) { @@ -174,6 +181,18 @@ export function createChildOpsEngine(options: ChildOpsEngineOptions = {}): Child if (!job || job.state === "done") return null; return { jobId: job.jobId, op: job.op, state: job.state }; }, + reserve(linkId) { + if (locks.has(linkId)) return null; + // Never a job id (those start ljob_), so activeJob reads null and no job finish releases it. + const token = `reservation:${++reservations}`; + locks.set(linkId, token); + let released = false; + return () => { + if (released) return; + released = true; + if (locks.get(linkId) === token) locks.delete(linkId); + }; + }, settled() { if (!running()) return Promise.resolve(); return new Promise(resolve => { idle.add(resolve); }); diff --git a/src/link/child-ops-runners.ts b/src/link/child-ops-runners.ts index d483a9a480b..8c148124e4a 100644 --- a/src/link/child-ops-runners.ts +++ b/src/link/child-ops-runners.ts @@ -3,6 +3,7 @@ import { classifyUpdateApply, remoteUpdateApplyArgv } from "./child-gated-ocx"; import { requireRemotePlatform } from "./remote-platform"; import { SshRunnerError, type SshRunResult } from "./ssh-runner"; import { classifySyncResult, childSkipHint } from "./sync-fanout-classify"; +import { parseStrictSemver } from "../lib/strict-semver"; import type { LinkRecord } from "./store"; import { RESTART_SKIPPED, type ChildJobItem, type ChildOpsEnv, type ChildRestart, type ItemOutcome, type ItemRunner } from "./child-ops-types"; @@ -14,6 +15,11 @@ export const APPLY_NOT_AFTER_MS = 120_000; /** Less than this left of the window when the apply would be sent ⇒ do not send; the next run re-plans. */ export const APPLY_MIN_REMAINING_MS = 15_000; export const APPLY_GRACE_MS = 120_000; +/** + * The documented bound of an orchestrated apply (ORCHESTRATED_MUTATION_BOUND_MS on the Child). A Child that reports a + * larger mutationBoundMs (its contract allows up to an hour) cannot hold the engine's single update place longer. + */ +export const APPLY_MUTATION_BOUND_MAX_MS = 900_000; export const POST_UPDATE_SYNC_RETRY_MS = 30_000; /** Revision 4: how long an uncertain update stays recorded for recovery (070 imports it from here). */ export const UNCERTAIN_RECOVERY_WINDOW_MS = 3_600_000; @@ -46,9 +52,20 @@ function linkBusy(record: LinkRecord, env: ChildOpsEnv): boolean { return (env.activity(record.id)?.inFlight ?? 1) > 0; } +/** + * Item hints never embed the SSH alias (up to 253 characters, and already on the item as alias), so each one is a + * bounded sentence under the job document's 256-character string limit and is never cut by the engine's cap. + */ +export const HINT_SUBJECT = "this Child"; /** A4 owner action: the npm or pnpm launcher route needs a Node the Child's SSH session can find. */ -function launcherHint(alias: string): string { - return `Install Node.js where the npm or pnpm that installed OpenCodex on ${alias} can find it, or update there with ocx update.`; +export const LAUNCHER_HINT = "Install Node.js where the npm or pnpm that installed OpenCodex on this Child can find it, or update there with ocx update."; +export const CLOCK_SKEW_HINT = "The clocks of this Home and this Child differ (clock-skew); correct the Child's clock or run the update again."; +export function syncSkipHint(reason: string | null): string { + return childSkipHint(reason, HINT_SUBJECT); +} +/** Revision 4 (4529522006): the update stands, but routing may be gone. linkId is always lnk_ plus 16 hex. */ +export function postUpdateSyncHint(linkId: string): string { + return `The update stands, but routing may be gone; run ocx link sync --link-id ${linkId} to restore it.`; } async function runPinnedSync(record: LinkRecord, platform: Platform, restart: ChildRestart, env: ChildOpsEnv): Promise { @@ -112,12 +129,12 @@ async function syncWithRestart(record: LinkRecord, platform: Platform, requested return { sync: second.sync, restart: { requested, outcome: "requested" }, restartUnknown: true }; } -export function syncOutcome(sync: SyncResult, alias: string): ItemOutcome { +export function syncOutcome(sync: SyncResult): ItemOutcome { if (sync.state === "synced") return { state: "succeeded", reason: null, sync }; if (sync.state === "timed-out") return { state: "uncertain", reason: "deadline-exceeded", sync }; if (sync.state === "skipped") { const reason = sync.reason ?? "child-sync-skipped"; - const hint = childSkipHint(reason, alias); + const hint = syncSkipHint(reason); if (reason === "child-updating") return { state: "busy", reason, hint, sync }; if (reason === "child-ocx-lacks-expect-link") return { state: "owner-action", reason, hint, sync }; if (reason === "child-platform-unsupported") return { state: "unsupported", reason, hint, sync }; @@ -150,7 +167,7 @@ export const runSyncItem: ItemRunner = async (record, job, env) => { if (typeof platform !== "string") return platform; const { sync, restart, restartUnknown } = await syncWithRestart(record, platform, job.restart, env); if (restartUnknown) return { state: "uncertain", reason: RESTART_RESULT_UNKNOWN, sync, restart }; - const outcome = syncOutcome(sync, record.alias); + const outcome = syncOutcome(sync); if (restart) outcome.restart = restart; // The sync is the job's purpose here; a withheld restart is reported, not a failure (A2). if (restart?.outcome === "skipped" && outcome.state === "succeeded") outcome.reason = RESTART_SKIPPED; @@ -170,7 +187,7 @@ export const runRestartCodexItem: ItemRunner = async (record, job, env) => { if (record.childKind === "ephemeral") { // ocx-child accepts and ignores the restart flags (F2): sync without them, report unsupported. const sync = await pinnedSync(record, platform, "none", env); - const outcome = syncOutcome(sync, record.alias); + const outcome = syncOutcome(sync); return outcome.state === "succeeded" ? { ...refused("ephemeral-restart"), sync } : { ...outcome, restart: { requested, outcome: "not-run" } }; } // A2 / M8: the restart is the purpose of this op, so a busy link runs nothing (no SSH) and reports partial (exit 6). @@ -178,10 +195,10 @@ export const runRestartCodexItem: ItemRunner = async (record, job, env) => { // Revision 4: same two-step path as sync with a restart, so activity is read again just before the restart goes out. const { sync, restart, restartUnknown } = await syncWithRestart(record, platform, requested, env); if (restartUnknown) return { state: "uncertain", reason: RESTART_RESULT_UNKNOWN, sync, restart }; - if (restart!.outcome === "unsupported") return syncOutcome(sync, record.alias).state === "succeeded" - ? { ...refused("ephemeral-restart"), sync } : { ...syncOutcome(sync, record.alias), restart: { requested, outcome: "not-run" } }; + if (restart!.outcome === "unsupported") return syncOutcome(sync).state === "succeeded" + ? { ...refused("ephemeral-restart"), sync } : { ...syncOutcome(sync), restart: { requested, outcome: "not-run" } }; if (restart!.outcome === "skipped") return { state: "busy", reason: RESTART_SKIPPED, sync, restart }; - return { ...syncOutcome(sync, record.alias), restart }; + return { ...syncOutcome(sync), restart }; }; export const runUpdateItem: ItemRunner = async (record, job, env) => { @@ -195,7 +212,7 @@ export const runUpdateItem: ItemRunner = async (record, job, env) => { const identityPlatform = await platformOf(record, env); if (typeof identityPlatform !== "string") return identityPlatform; const identity = await pinnedSync(record, identityPlatform, "none", env); - if (identity.state !== "synced") return syncOutcome(identity, record.alias); + if (identity.state !== "synced") return syncOutcome(identity); // D3: a fresh plan every time. Windows Children are observed too: desktop-request is the one route they can take (H4, A5). const observed = await env.observe(record); if (observed.observation) env.writeObservation(record.id, withoutCarriedFields(observed.observation)); @@ -211,7 +228,7 @@ export const runUpdateItem: ItemRunner = async (record, job, env) => { ? { state: "up-to-date", reason: null, ...(plan.installedVersion ? { fromVersion: plan.installedVersion, toVersion: plan.installedVersion } : {}) } : { state: "refused", reason: plan.updateReason ?? "plan-unavailable", ...versions }; if (plan.updateRoute === "owner-action" || plan.updateRoute === "replant") return { state: "owner-action", reason: plan.updateReason, - ...(plan.updateReason === "launcher-node-missing" ? { hint: launcherHint(record.alias) } : {}), ...versions }; + ...(plan.updateReason === "launcher-node-missing" ? { hint: LAUNCHER_HINT } : {}), ...versions }; if (plan.updateInFlight) return { state: "busy", reason: "update-in-flight", ...versions }; const suppressed = env.readObservation(record.id)?.suppressedTarget ?? null; if (job.trigger === "auto") { @@ -244,8 +261,10 @@ export const runUpdateItem: ItemRunner = async (record, job, env) => { } let result: SshRunResult; try { - result = await env.runner.run(argv, { timeoutMs: plan.mutationBoundMs + APPLY_GRACE_MS }); + result = await env.runner.run(argv, { timeoutMs: Math.min(plan.mutationBoundMs, APPLY_MUTATION_BOUND_MAX_MS) + APPLY_GRACE_MS }); } catch (error) { + // A spawn failure sent nothing to the Child (as for the sync path), so there is nothing to recover. + if (error instanceof SshRunnerError && error.code === "spawn") return { state: "failed", reason: "ssh-spawn-failed", ...versions }; // The Child's update keeps running or has finished; only a re-observe can tell (R2 hazard 4). // Revision 4 (4529522112, 4529521944): recorded apart from lastOperation, which the next observe replaces; 070 // re-observes it, never re-applies, and resyncs once the Child reports no update in flight. @@ -259,11 +278,11 @@ export const runUpdateItem: ItemRunner = async (record, job, env) => { const verdictVersions = { ...versions, ...(verdict.fromVersion ? { fromVersion: verdict.fromVersion } : {}), ...(verdict.toVersion ? { toVersion: verdict.toVersion } : {}) }; if (verdict.state === "refused" && verdict.reason === "deadline-passed") return { state: "refused", reason: "deadline-passed", - ...(homeClock ? { hint: `The clocks of this Home and ${record.alias} differ (clock-skew); correct the Child's clock or run the update again.` } : {}), + ...(homeClock ? { hint: CLOCK_SKEW_HINT } : {}), ...verdictVersions }; if (verdict.state === "up-to-date") return { state: "up-to-date", reason: null, ...verdictVersions }; if (verdict.state === "owner-action" && verdict.reason === "launcher-node-missing") { - return { state: "owner-action", reason: verdict.reason, hint: launcherHint(record.alias), ...verdictVersions }; + return { state: "owner-action", reason: verdict.reason, hint: LAUNCHER_HINT, ...verdictVersions }; } if (verdict.state === "uncertain") env.writeObservation(record.id, { pendingUpdateRecovery: { target, since: iso(env.now()), until: iso(env.now() + UNCERTAIN_RECOVERY_WINDOW_MS) } }); @@ -278,7 +297,9 @@ export const runUpdateItem: ItemRunner = async (record, job, env) => { await env.sleep(POST_UPDATE_SYNC_RETRY_MS); ({ sync, restart, restartUnknown } = await syncWithRestart(record, "posix", job.restart, env)); } - env.writeObservation(record.id, { installedVersion: target, targetVersion: target, updateRoute: "none", + // The Child's own result names the version it now runs; the Home target stands in when that is not a strict version. + const installed = verdict.toVersion && verdict.toVersion.length <= 128 && parseStrictSemver(verdict.toVersion) ? verdict.toVersion : target; + env.writeObservation(record.id, { installedVersion: installed, targetVersion: target, updateRoute: "none", updateReason: "up-to-date", observedAt: iso(env.now()), source: "plan" }); const extra = { ...verdictVersions, sync, ...(restart ? { restart } : {}) }; // The update stands in every branch below; only what follows it is reported. @@ -287,5 +308,5 @@ export const runUpdateItem: ItemRunner = async (record, job, env) => { ? { state: "succeeded", reason: restart?.outcome === "skipped" ? RESTART_SKIPPED : null, ...extra } // Revision 4 (4529522006): the update stands, but routing may be gone; the CLI exits 6 for this reason (Section 3). : { state: "succeeded", reason: "post-update-sync-failed", - hint: `${sync.state === "skipped" ? `${childSkipHint(sync.reason, record.alias)} ` : ""}Run ocx link sync --link-id ${record.id} to restore routing.`, ...extra }; + hint: postUpdateSyncHint(record.id), ...extra }; }; diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 22797ac2ef3..da7182ce117 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -203,13 +203,16 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) const current = readStoreFor(ctx); const record = current.links.find(link => link.id === id); if (!record) return fail("link_not_found", "The link was not found.", 404); - // A queued or running job would keep opening SSH sessions to a Child this removal disconnects. + // A queued or running job would keep opening SSH sessions to a Child this removal disconnects, so removal takes the + // link's job admission lock before its first teardown await and holds it to the end: a job submitted meanwhile gets + // 409 (single link) or a busy item, and a link a job already holds is refused here, before anything is stopped. const { childOpsEngineFor } = await import("./link-child-ops-routes"); - const engine = childOpsEngineFor(state); - const held = (): Response | null => engine.activeJob(id) - ? fail("link_operation_in_progress", "A Child operation is running on this link; wait for it to finish, then remove the link.", 409) : null; - const heldAtStart = held(); - if (heldAtStart) return heldAtStart; + const releaseLink = childOpsEngineFor(state).reserve(id); + if (!releaseLink) return fail("link_operation_in_progress", "A Child operation is running on this link; wait for it to finish, then remove the link.", 409); + try { return await removeReserved(ctx, state, id, record); } finally { releaseLink(); } +} + +async function removeReserved(ctx: ManagementContext, state: LinkRouteState, id: string, record: LinkStore["links"][number]): Promise { const body = await readManagementJsonBodyOr(ctx.req, {}); const payload = body === null ? {} : body; if (!isRecord(payload) @@ -221,9 +224,6 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) } const force = payload.force === true; const purge = payload.purge === true; - // Revision 6 F10: a job may have taken the link while the body was read; check again before anything is stopped. - const heldAfterBody = held(); - if (heldAfterBody) return heldAfterBody; const release = state.supervisor.holdLink?.(id); try { await state.supervisor.stopLink(id); @@ -258,9 +258,6 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error)); } } - // F10: and once more after the remote cleanup awaited, immediately before the key and the record go. - const heldBeforeWrite = held(); - if (heldBeforeWrite) { await restartTunnel(); return heldBeforeWrite; } if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502); let next: LinkStore; try { diff --git a/tests/clients/link-child-ops-engine.test.ts b/tests/clients/link-child-ops-engine.test.ts index 912fcd88409..86a62aaca63 100644 --- a/tests/clients/link-child-ops-engine.test.ts +++ b/tests/clients/link-child-ops-engine.test.ts @@ -291,3 +291,39 @@ describe("child ops engine (wp3)", () => { for (const id of ids) expect(engine.activeJob(id)).toBeNull(); }); }); + +describe("link reservation (link removal)", () => { + test("a reserved link refuses single-link submits, makes bulk items busy, and frees on release", async () => { + const links = [record(ids[0]!), record(ids[1]!)]; + const { env, writes } = fakeEnv(() => links); + const engine = createChildOpsEngine({ runners: { sync: async () => succeeded } }); + const release = engine.reserve(ids[0]!); + expect(release).not.toBeNull(); + expect(engine.reserve(ids[0]!)).toBeNull(); + expect(engine.activeJob(ids[0]!)).toBeNull(); + expect(engine.submit(manual("sync", [ids[0]!]), env)).toEqual({ ok: false, code: "link_operation_in_progress", linkId: ids[0]! }); + const bulk = submitted(engine, manual("sync", "all"), env); + expect(bulk.items[0]).toMatchObject({ state: "busy", reason: "link-operation-in-progress" }); + await engine.settled(); + expect(writes.filter(write => write.linkId === ids[0])).toEqual([]); + release!(); + release!(); + expect(engine.submit(manual("sync", [ids[0]!]), env).ok).toBe(true); + await engine.settled(); + }); + + test("a link a job holds cannot be reserved; a late release never frees a job's lock", async () => { + const gate = deferred(); + const { env } = fakeEnv(() => [record(ids[0]!)]); + const engine = createChildOpsEngine({ runners: { sync: async () => { await gate.promise; return succeeded; } } }); + const free = engine.reserve(ids[0]!)!; + free(); + submitted(engine, manual("sync", [ids[0]!]), env); + expect(engine.reserve(ids[0]!)).toBeNull(); + free(); + expect(engine.submit(manual("sync", [ids[0]!]), env)).toMatchObject({ ok: false, code: "link_operation_in_progress" }); + gate.resolve(); + await engine.settled(); + }); +}); + diff --git a/tests/clients/link-child-ops-runners.test.ts b/tests/clients/link-child-ops-runners.test.ts index 4cfcfff3666..e684bd495d9 100644 --- a/tests/clients/link-child-ops-runners.test.ts +++ b/tests/clients/link-child-ops-runners.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { APPLY_GRACE_MS, POST_UPDATE_SYNC_RETRY_MS, runObserveItem, runRestartCodexItem, runSyncItem, runUpdateItem, +import { APPLY_GRACE_MS, APPLY_MUTATION_BOUND_MAX_MS, CLOCK_SKEW_HINT, LAUNCHER_HINT, POST_UPDATE_SYNC_RETRY_MS, postUpdateSyncHint, runObserveItem, syncSkipHint, runRestartCodexItem, runSyncItem, runUpdateItem, UNCERTAIN_RECOVERY_WINDOW_MS } from "../../src/link/child-ops-runners"; import type { ChildOpsEnv, ChildRestart, ChildTrigger, ItemRunner } from "../../src/link/child-ops-types"; import type { ChildObservation } from "../../src/link/child-observations"; @@ -339,7 +339,7 @@ describe("update runner", () => { ["a refused plan", plan({ updateRoute: "none", updateReason: "refused:downgrade-refused" }), { state: "refused", reason: "refused:downgrade-refused" }], ["GOAT", plan({ installKind: "goat", updateRoute: "owner-action", updateReason: "goat" }), { state: "owner-action", reason: "goat" }], ["launcher-node-missing", plan({ installKind: "npm", updateRoute: "owner-action", updateReason: "launcher-node-missing" }), - { state: "owner-action", reason: "launcher-node-missing", hint: "Install Node.js where the npm or pnpm that installed OpenCodex on child-01 can find it, or update there with ocx update." }], + { state: "owner-action", reason: "launcher-node-missing", hint: LAUNCHER_HINT }], ["desktop request", plan({ installKind: "desktop-app", updateRoute: "desktop-request", updateReason: "desktop-app-owned" }), { state: "owner-action", reason: "desktop-app" }], ["update in flight", plan({ updateInFlight: true }), { state: "busy", reason: "update-in-flight" }], ] as const)("%s sends no apply", async (_name, classification, expected) => { @@ -398,7 +398,7 @@ describe("update runner", () => { const answer = applied(10, { ...RESULT, applied: false, healthy: false, restarted: false, toVersion: null, error: "deadline-passed" }); const homeClock = fakeSsh({ sync: [SYNCED], apply: [answer] }); expect(await run(runUpdateItem, record(), job("update"), updateEnv(homeClock, observedPlan(plan(), { childNowMs: null })).env)) - .toMatchObject({ state: "refused", reason: "deadline-passed", hint: "The clocks of this Home and child-01 differ (clock-skew); correct the Child's clock or run the update again." }); + .toMatchObject({ state: "refused", reason: "deadline-passed", hint: CLOCK_SKEW_HINT }); const childClock = fakeSsh({ sync: [SYNCED], apply: [answer] }); const outcome = await run(runUpdateItem, record(), job("update"), updateEnv(childClock, observedPlan(plan())).env); expect(outcome).toMatchObject({ state: "refused", reason: "deadline-passed" }); @@ -413,7 +413,7 @@ describe("update runner", () => { expect(retry.kinds()).toEqual(["sync", "apply", "sync", "sync"]); const failing = fakeSsh({ sync: [SYNCED, DROPPED], apply: [applied(0)] }); expect(await run(runUpdateItem, record(), job("update"), updateEnv(failing, observedPlan(plan())).env)) - .toMatchObject({ state: "succeeded", reason: "post-update-sync-failed", hint: "Run ocx link sync --link-id " + LINK + " to restore routing.", sync: { state: "failed", reason: "ssh-failed" } }); + .toMatchObject({ state: "succeeded", reason: "post-update-sync-failed", hint: postUpdateSyncHint(LINK), sync: { state: "failed", reason: "ssh-failed" } }); }); test.each([ @@ -424,7 +424,7 @@ describe("update runner", () => { ["exit 255 mid-apply", DROPPED, { state: "uncertain", reason: "ssh-failed" }, ["sync", "apply"]], ["exit 121 legacy", { code: 121, stdout: "", stderr: "ocx-fanout:child-ocx-lacks-orchestrated-update\n" }, { state: "owner-action", reason: "legacy-ocx" }, ["sync", "apply"]], ["exit 121 child-node-unavailable", { code: 121, stdout: "", stderr: "ocx-fanout:selected:2.98.0\nocx-fanout:entry:pair\nocx-fanout:child-node-unavailable\n" }, - { state: "owner-action", reason: "launcher-node-missing", hint: "Install Node.js where the npm or pnpm that installed OpenCodex on child-01 can find it, or update there with ocx update." }, ["sync", "apply"]], + { state: "owner-action", reason: "launcher-node-missing", hint: LAUNCHER_HINT }, ["sync", "apply"]], ["exit 127", { code: 127, stdout: "", stderr: "" }, { state: "failed", reason: "child-ocx-not-found" }, ["sync", "apply", "sync"]], ] as const)("%s", async (_name, answer, expected, kinds) => { const ssh = fakeSsh({ sync: [SYNCED], apply: [answer] }); @@ -453,6 +453,32 @@ describe("update runner", () => { } }); + test("security L1: a Child reporting a mutation bound above 900 s cannot hold the update place longer", async () => { + const ssh = ok(); + await run(runUpdateItem, record(), job("update"), updateEnv(ssh, observedPlan(plan({ mutationBoundMs: 3_600_000 }))).env); + expect(ssh.calls[1]!.timeoutMs).toBe(APPLY_MUTATION_BOUND_MAX_MS + APPLY_GRACE_MS); + expect(APPLY_MUTATION_BOUND_MAX_MS + APPLY_GRACE_MS).toBe(1_020_000); + }); + + test("final L2: an SSH spawn failure on the apply sent nothing: failed, no recovery record, no resync", async () => { + const ssh = fakeSsh({ sync: [SYNCED], apply: [new SshRunnerError("spawn", "ssh not found")] }); + const { env, writes } = updateEnv(ssh, observedPlan(plan())); + expect(await run(runUpdateItem, record(), job("update"), env)).toEqual({ state: "failed", reason: "ssh-spawn-failed", fromVersion: "2.98.0", toVersion: "2.100.0" }); + expect(writes.some(write => "pendingUpdateRecovery" in write)).toBe(false); + expect(ssh.kinds()).toEqual(["sync", "apply"]); + }); + + test("final L3: a succeeded update records the version the Child reports, else the Home target", async () => { + const reported = fakeSsh({ sync: [SYNCED], apply: [applied(0, { ...RESULT, toVersion: "2.100.1" })] }); + const first = updateEnv(reported, observedPlan(plan())); + await run(runUpdateItem, record(), job("update"), first.env); + expect(first.writes.at(-1)).toMatchObject({ installedVersion: "2.100.1", targetVersion: "2.100.0" }); + const silent = fakeSsh({ sync: [SYNCED], apply: [applied(0, { ...RESULT, toVersion: null })] }); + const second = updateEnv(silent, observedPlan(plan())); + await run(runUpdateItem, record(), job("update"), second.env); + expect(second.writes.at(-1)).toMatchObject({ installedVersion: "2.100.0", targetVersion: "2.100.0" }); + }); + test("F8: an apply argv the builder refuses fails before anything is sent and records no recovery", async () => { const ssh = ok(); const { env, writes } = updateEnv(ssh, observedPlan(plan({ planToken: "short" }))); @@ -500,3 +526,31 @@ describe("a fake 2.96 Child on the real gated script", () => { expect(calls).not.toContain("update"); }); }); + +describe("hints (final L4)", () => { + const SKIP_REASONS = [null, "child-ocx-lacks-expect-link", "child-updating", "child-link-mismatch", "child-link-mismatch:pending_operation", + "child-link-mismatch:disconnect_pending", "child-link-mismatch:disconnected", "child-link-mismatch:other_link", "child-link-mismatch:other_key", + "child-ocx-version-unknown", "child-platform-unsupported", "child-sync-skipped"]; + + test("every hint a runner can write fits the job document's 256-character strings", () => { + const hints = [...SKIP_REASONS.map(reason => syncSkipHint(reason)), LAUNCHER_HINT, CLOCK_SKEW_HINT, postUpdateSyncHint("lnk_ffffffffffffffff")]; + for (const hint of hints) { + expect(hint.length).toBeLessThanOrEqual(256); + expect(hint.endsWith(".")).toBe(true); + } + }); + + test("a 253-character alias never reaches a hint, so nothing is cut", async () => { + const alias = "a".repeat(253); + for (const answer of [UPDATING, MISMATCH, LACKS_EXPECT_LINK]) { + const outcome = await run(runSyncItem, record({ alias }), job("sync"), makeEnv(fakeSsh({ sync: [answer] })).env); + expect(outcome.hint).toBeDefined(); + expect(outcome.hint!.length).toBeLessThanOrEqual(256); + expect(outcome.hint).not.toContain(alias); + } + const failing = fakeSsh({ sync: [SYNCED, DROPPED], apply: [applied(0)] }); + const updated = await run(runUpdateItem, record({ alias }), job("update"), makeEnv(failing, { observe: async () => observedPlan(plan()) }).env); + expect(updated).toMatchObject({ state: "succeeded", reason: "post-update-sync-failed", hint: postUpdateSyncHint(LINK) }); + }); +}); + diff --git a/tests/server/link-child-ops-routes.test.ts b/tests/server/link-child-ops-routes.test.ts index ca8697cfe7d..35de9761b23 100644 --- a/tests/server/link-child-ops-routes.test.ts +++ b/tests/server/link-child-ops-routes.test.ts @@ -55,7 +55,8 @@ function supervisor(): LinkSupervisor { status: () => [{ linkId: HUB, direction: "hub-initiated", state: { kind: "connected", since: 1_762_000_000_000 }, pid: 1 }] }; } -function harness(options: { links?: LinkRecord[]; runtimeRole?: OcxConfig["runtimeRole"]; gate?: Promise; full?: boolean; onFlush?: () => void } = {}) { +function harness(options: { links?: LinkRecord[]; runtimeRole?: OcxConfig["runtimeRole"]; gate?: Promise; full?: boolean; onFlush?: () => void; + disconnectGate?: Promise; disconnectAnswer?: SshRunResult } = {}) { let current: LinkStore = { version: 1, listenerPort: 18181, links: options.links ?? [link(HUB), link(CLIENT, { direction: "client-initiated", hostKeyFingerprint: null })] }; // Persisted too: the observation cache prunes entries whose link is absent from links.json. writeFileSync(join(temp, "ocx", "link", "links.json"), JSON.stringify(current)); @@ -67,7 +68,10 @@ function harness(options: { links?: LinkRecord[]; runtimeRole?: OcxConfig["runti if (options.gate) await options.gate; const remote = argv.at(-1) ?? ""; if (remote.includes("'sync'")) return SYNC_ANSWER; - if (remote.includes("'disconnect'")) return { code: 0, stdout: "", stderr: "" }; + if (remote.includes("'disconnect'")) { + if (options.disconnectGate) await options.disconnectGate; + return options.disconnectAnswer ?? { code: 0, stdout: "", stderr: "" }; + } return PLAN_ANSWER; }, spawnTunnel: () => ({ pid: 1, argv: [], exited: Promise.resolve(0), kill() {} }), @@ -317,3 +321,64 @@ describe("link child operations routes", () => { expect(flushes).toBe(1); }); }); + +describe("link removal reserves the link (Codex P2, final L1, security L2)", () => { + const until = async (check: () => boolean) => { + for (let attempt = 0; attempt < 400 && !check(); attempt++) await new Promise(resolve => setTimeout(resolve, 5)); + if (!check()) throw new Error("condition not reached"); + }; + + test("a job submitted while DELETE awaits the remote disconnect is refused, and DELETE completes", async () => { + let open!: () => void; + const disconnectGate = new Promise(resolve => { open = resolve; }); + const h = harness({ disconnectGate }); + const removal = h.call("/api/link/" + HUB, "DELETE"); + await until(() => h.sshCalls.some(call => call.includes("'disconnect'"))); + const single = await h.call("/api/link/children/operations", "POST", { op: "sync", linkIds: [HUB] }); + expect(single?.status).toBe(409); + expect(((await single!.json()) as { error: { code: string } }).error.code).toBe("link_operation_in_progress"); + const bulk = await h.call("/api/link/children/operations", "POST", { op: "update", linkIds: "all" }); + expect(bulk?.status).toBe(202); + const bulkJob = ((await bulk!.json()) as { job: { items: Array<{ linkId: string; state: string; reason: string | null }> } }).job; + expect(bulkJob.items.find(item => item.linkId === HUB)).toMatchObject({ state: "busy", reason: "link-operation-in-progress" }); + const second = await h.call("/api/link/" + HUB, "DELETE"); + expect(second?.status).toBe(409); + open(); + const removed = await removal; + expect(removed?.status).toBe(200); + expect(h.events).toEqual(["revoke", "store"]); + expect(h.store.links.map(record => record.id)).toEqual([CLIENT]); + // Only the removal's own disconnect reached the Child: the refused jobs sent nothing. + expect(h.sshCalls).toHaveLength(1); + }); + + test("a link a job holds is refused at entry, before any teardown", async () => { + let open!: () => void; + const gate = new Promise(resolve => { open = resolve; }); + const h = harness({ gate }); + const started = await h.call("/api/link/children/operations", "POST", { op: "sync", linkIds: [HUB] }); + const jobId = ((await started!.json()) as { job: { jobId: string } }).job.jobId; + const held = await h.call("/api/link/" + HUB, "DELETE"); + expect(held?.status).toBe(409); + expect(h.events).toEqual([]); + expect(h.sshCalls.some(call => call.includes("'disconnect'"))).toBe(false); + open(); + await pollDone(h, jobId); + }); + + test("the reservation is released when the removal fails", async () => { + const h = harness({ disconnectAnswer: { code: 1, stdout: "", stderr: "refused" } }); + const failed = await h.call("/api/link/" + HUB, "DELETE"); + expect(failed?.status).toBe(502); + expect(h.store.links.map(record => record.id)).toEqual([HUB, CLIENT]); + const after = await h.call("/api/link/children/operations", "POST", { op: "sync", linkIds: [HUB] }); + expect(after?.status).toBe(202); + await pollDone(h, ((await after!.json()) as { job: { jobId: string } }).job.jobId); + const invalid = await h.call("/api/link/" + HUB, "DELETE", { force: "yes" }); + expect(invalid?.status).toBe(400); + const again = await h.call("/api/link/children/operations", "POST", { op: "sync", linkIds: [HUB] }); + expect(again?.status).toBe(202); + await pollDone(h, ((await again!.json()) as { job: { jobId: string } }).job.jobId); + }); +}); + From d4af4638f6edccab21390d8925aa3f0e706d545c Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 09:43:05 +0900 Subject: [PATCH 15/18] docs: link removal refuses while an operation runs; reservation, apply bound --- docs-site/src/content/docs/guides/remote-link.md | 2 +- skills/ocx/references/05_remote_hub.md | 2 +- structure/remote-link.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index c63863adc47..db791e1901c 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -205,7 +205,7 @@ From the Home you can also sync a Home-initiated Child, restart Codex on it, and - `ocx link restart-codex --link-id --yes` syncs first and then restarts the Child's Codex app-servers, so a running Codex loads the current catalog. `--scope codex` also quits and relaunches the Codex desktop app, which needs a signed-in desktop session on the Child. `ocx link sync --link-id ` with `--restart-app-server-only` or `--restart-codex --yes` does the same. A restart ends the Codex turns running on that Child, so it names exactly one link. It needs 2.93.0 or later. - `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again afterwards, also after a failed update. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. -The Home does not restart Codex on a Child while it is relaying a model request for that Child: the restart is skipped (`restart-skipped:link-active`) and the sync still runs. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time, and removing a link waits until it finishes (`409 link_operation_in_progress`). +The Home does not restart Codex on a Child while it is relaying a model request for that Child: the restart is skipped (`restart-skipped:link-active`) and the sync still runs. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, new operations on it are refused the same way. The commands exit 0 when every Child succeeded or was already up to date, 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the Home has no links, or the command ran on a Child. The CLI needs a Home bound to loopback; on any other Home, use the Remote Link page of the dashboard. diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 8b893e6dded..96a6673ac13 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -370,7 +370,7 @@ a request is still running can end it. Windows Children take `sync` only: restarts are `unsupported` (`windows-restart`) and orchestrated updates `unsupported` (`windows-apply`). `ocx-child` Children report restart and update `unsupported`. The CLI verbs need a Home bound to loopback; on any other Home they exit 1 and point at the dashboard's Remote -Link page. While a job holds a link, `ocx link revoke` on it answers 409 `link_operation_in_progress`. +Link page. While a job holds a link, `ocx link revoke` on it is refused with 409 `link_operation_in_progress` (it does not wait); while a revoke runs, a job for that link is refused the same way, or its item ends `busy`. ## Key rotation is a two-step commit diff --git a/structure/remote-link.md b/structure/remote-link.md index b00c12993cb..6744e33add1 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -70,9 +70,9 @@ A connected Child answers `GET` and `HEAD /api/link/status` on its own listener Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link probe|add|port|issue|revoke|status|reconnect|cleanup|check|update|sync|restart-codex`; `cleanup` is local and read-only (see `src/cli/link-cleanup.ts` above). `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. -Child operations run as jobs. `src/server/management/link-child-ops-routes.ts` serves `POST /api/link/children/operations` (body exactly `{op, linkIds, restart?}` with `op` one of `observe`, `sync`, `restart-codex`, `update`; `linkIds` is `"all"` or 1 to 64 distinct link ids; `restart` is `app-server` or `codex`, refused on `observe`, defaulting to `app-server` for `restart-codex`, and any request that carries a restart must name its links, so `"all"` with a restart is `400 invalid_body`; `202 {job}`, `400 invalid_body` for any other op or key including `trigger`, `404 link_not_found`, `409 link_not_home_initiated` for a single Child-initiated link outside `observe`, `409 link_operation_in_progress`, `409 no_eligible_links` only when links.json has no link, `409 runtime_role_client` on a Child) and `GET /api/link/children/jobs/{jobId}` (`ljob_` plus 16 hex; `400 invalid_job_id`, `404 job_not_found`). The route always submits `trigger: "manual"`; only the in-process scheduler can submit `auto`. `handleLinkRoutes` delegates every `/api/link/children/` path before its reconnect and DELETE matchers, so `DELETE /api/link/children/x` answers 404 and never removes a link, and `DELETE /api/link/{id}` answers `409 link_operation_in_progress` while a job holds that link, checked on entry, after the body read and again after the remote cleanup just before the key and record go. Both routes take the same principals as probe and apply; the local-control socket still admits only `GET /api/link/status`, so on a non-loopback Home the CLI verbs exit 1 and name the dashboard. `src/link/child-ops-engine.ts` keeps the `ocx.link.child-job/1` jobs (types in `src/link/child-ops-types.ts`) in one engine per Home process (`sharedChildOpsEngine`; the routes cache it on `LinkRouteState.childOps` and status reads its `activeJob`): one lock per link (a held link makes a single-link request 409 and a multi-link item `busy`, which leaves that link's `lastOperation` to its holder), three items at a time across every job of which at most one is an update (a waiting update does not hold up the items behind it), Child-initiated items of `sync`, `restart-codex` and `update` ended `unsupported`/`child-initiated` at admission, the record re-read before SSH (`refused`/`link-removed` when it vanished), finished jobs kept one hour and at most 32 on each job's own clock, no timer. Each submit carries a `ChildOpsEnv` (store, SSH runner, wire state, activity, Home version, observe, a cache writer that never throws, the Home catalog etag). Items carry closed reason tokens and Home-written hints, never remote text. +Child operations run as jobs. `src/server/management/link-child-ops-routes.ts` serves `POST /api/link/children/operations` (body exactly `{op, linkIds, restart?}` with `op` one of `observe`, `sync`, `restart-codex`, `update`; `linkIds` is `"all"` or 1 to 64 distinct link ids; `restart` is `app-server` or `codex`, refused on `observe`, defaulting to `app-server` for `restart-codex`, and any request that carries a restart must name its links, so `"all"` with a restart is `400 invalid_body`; `202 {job}`, `400 invalid_body` for any other op or key including `trigger`, `404 link_not_found`, `409 link_not_home_initiated` for a single Child-initiated link outside `observe`, `409 link_operation_in_progress`, `409 no_eligible_links` only when links.json has no link, `409 runtime_role_client` on a Child) and `GET /api/link/children/jobs/{jobId}` (`ljob_` plus 16 hex; `400 invalid_job_id`, `404 job_not_found`). The route always submits `trigger: "manual"`; only the in-process scheduler can submit `auto`. `handleLinkRoutes` delegates every `/api/link/children/` path before its reconnect and DELETE matchers, so `DELETE /api/link/children/x` answers 404 and never removes a link, and `DELETE /api/link/{id}` answers `409 link_operation_in_progress` while a job holds that link; otherwise removal reserves the link in the engine (`reserve`) before its first teardown await and releases it when it ends, so a job submitted during the removal gets 409 for that link alone or a `busy` item, and no job reaches a Child the removal is disconnecting. Both routes take the same principals as probe and apply; the local-control socket still admits only `GET /api/link/status`, so on a non-loopback Home the CLI verbs exit 1 and name the dashboard. `src/link/child-ops-engine.ts` keeps the `ocx.link.child-job/1` jobs (types in `src/link/child-ops-types.ts`) in one engine per Home process (`sharedChildOpsEngine`; the routes cache it on `LinkRouteState.childOps` and status reads its `activeJob`): one lock per link (a held link makes a single-link request 409 and a multi-link item `busy`, which leaves that link's `lastOperation` to its holder), three items at a time across every job of which at most one is an update (a waiting update does not hold up the items behind it), Child-initiated items of `sync`, `restart-codex` and `update` ended `unsupported`/`child-initiated` at admission, the record re-read before SSH (`refused`/`link-removed` when it vanished), finished jobs kept one hour and at most 32 on each job's own clock, no timer. Each submit carries a `ChildOpsEnv` (store, SSH runner, wire state, activity, Home version, observe, a cache writer that never throws, the Home catalog etag). Items carry closed reason tokens and Home-written hints, never remote text. -`src/link/child-ops-runners.ts` holds the runners. `observe` is `src/link/child-observe.ts`: client-initiated and `ocx-child` links answer without SSH; otherwise one gated `update --plan` (60 s, 64 KiB) classifies the Child, and on an npm or pnpm Child served through its census pair the script also reports whether a Node answers `--version` within 10 s (`ocx-fanout:launcher:available|unavailable`). `sync` sends the pinned `sync --expect-link` (gate 2.93.0, 180 s) and, when the sync reports synced, records the catalog etag read before it as `catalogSyncedEtag`; with a restart it reads the link's activity after that sync and sends the restart as a second pinned sync carrying `--restart-app-server-only` or `--restart-codex`, withheld (`restart.outcome: "skipped"`, reason `restart-skipped:link-active`) while a relayed request is in flight or the activity is unknown. A second sync refused before an ocx was selected, or skipped by the Child, reports `not-run`; one that may have run but failed or timed out ends `uncertain`/`restart-result-unknown`. `restart-codex` is that path with the restart as its purpose: a busy link sends nothing (`busy`), Windows is refused without SSH (`windows-restart`), `ocx-child` reports `unsupported` (the record's `childKind` or the `selected-kind` marker). `update` sends an identity pinned sync first, then a fresh observe (Windows Children too, because `desktop-request` is decided before the `windows-apply` refusal), then the gated apply `update --yes --json --version --expect-plan --not-after ` (gate 2.97.0, bounded by `mutationBoundMs + 120 s`, not sent with under 15 s of the window left), through ` /bin/ocx.mjs` with `OCX_UPDATE_ORCHESTRATED_CHILD` unset only when the plan's installer is npm or pnpm (a missing Node exits 121 `child-node-unavailable`, owner action `launcher-node-missing`), and a pinned sync after every outcome that may have stopped the Child's proxy (success, failure, roll-back); the target is always the Home's version, exit 11 records `suppressedTarget` and a manual update clears it, an uncertain apply records `pendingUpdateRecovery` for an hour and is never re-sent. The Windows gated builder builds only `update --plan`. A legacy key admission, which is how a Child older than 2.69 reaches the Home, counts as activity only from its response headers, so a non-streaming request from such a Child is invisible to the restart guard while it runs. An item that ran writes its observation fields and then `lastOperation` to `child-observations.json`. `src/cli/link-child-ops.ts` (`runLinkCheck`, `runLinkChildOp`, the tolerant `validateChildJob`) polls every 2 s with a per-op item budget (observe 120 s, sync and restart-codex 420 s, update 2 100 s) that does not run while an item is queued, under a 3-hour cap, and exits 0, 6 for a partial result (including a success with a reason or a restart that did not go out), 1 for a failure, an uncertain item or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route or, for a non-observe op, one that answers `400 invalid_body`, 5 for a conflict. `ocx link update` needs `--yes`, as does any restart of scope `codex` and `restart-codex` itself. Coverage: `tests/server/link-child-ops-routes.test.ts`, `tests/clients/link-child-observe.test.ts`, `tests/clients/link-child-ops-engine.test.ts`, `tests/clients/link-child-ops-runners.test.ts`, `tests/clients/link-child-update-launcher.test.ts`, `tests/cli/cli-link-check.test.ts`, `tests/cli/cli-link-child-ops.test.ts`. +`src/link/child-ops-runners.ts` holds the runners. `observe` is `src/link/child-observe.ts`: client-initiated and `ocx-child` links answer without SSH; otherwise one gated `update --plan` (60 s, 64 KiB) classifies the Child, and on an npm or pnpm Child served through its census pair the script also reports whether a Node answers `--version` within 10 s (`ocx-fanout:launcher:available|unavailable`). `sync` sends the pinned `sync --expect-link` (gate 2.93.0, 180 s) and, when the sync reports synced, records the catalog etag read before it as `catalogSyncedEtag`; with a restart it reads the link's activity after that sync and sends the restart as a second pinned sync carrying `--restart-app-server-only` or `--restart-codex`, withheld (`restart.outcome: "skipped"`, reason `restart-skipped:link-active`) while a relayed request is in flight or the activity is unknown. A second sync refused before an ocx was selected, or skipped by the Child, reports `not-run`; one that may have run but failed or timed out ends `uncertain`/`restart-result-unknown`. `restart-codex` is that path with the restart as its purpose: a busy link sends nothing (`busy`), Windows is refused without SSH (`windows-restart`), `ocx-child` reports `unsupported` (the record's `childKind` or the `selected-kind` marker). `update` sends an identity pinned sync first, then a fresh observe (Windows Children too, because `desktop-request` is decided before the `windows-apply` refusal), then the gated apply `update --yes --json --version --expect-plan --not-after ` (gate 2.97.0, bounded by `min(mutationBoundMs, 900 s) + 120 s`, an SSH spawn failure ending `failed`/`ssh-spawn-failed` with nothing to recover, not sent with under 15 s of the window left), through ` /bin/ocx.mjs` with `OCX_UPDATE_ORCHESTRATED_CHILD` unset only when the plan's installer is npm or pnpm (a missing Node exits 121 `child-node-unavailable`, owner action `launcher-node-missing`), and a pinned sync after every outcome that may have stopped the Child's proxy (success, failure, roll-back); the target is always the Home's version, exit 11 records `suppressedTarget` and a manual update clears it, an uncertain apply records `pendingUpdateRecovery` for an hour and is never re-sent. The Windows gated builder builds only `update --plan`. A legacy key admission, which is how a Child older than 2.69 reaches the Home, counts as activity only from its response headers, so a non-streaming request from such a Child is invisible to the restart guard while it runs. An item that ran writes its observation fields and then `lastOperation` to `child-observations.json`. `src/cli/link-child-ops.ts` (`runLinkCheck`, `runLinkChildOp`, the tolerant `validateChildJob`) polls every 2 s with a per-op item budget (observe 120 s, sync and restart-codex 420 s, update 2 100 s) that does not run while an item is queued, under a 3-hour cap, and exits 0, 6 for a partial result (including a success with a reason or a restart that did not go out), 1 for a failure, an uncertain item or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route or, for a non-observe op, one that answers `400 invalid_body`, 5 for a conflict. `ocx link update` needs `--yes`, as does any restart of scope `codex` and `restart-codex` itself. Coverage: `tests/server/link-child-ops-routes.test.ts`, `tests/clients/link-child-observe.test.ts`, `tests/clients/link-child-ops-engine.test.ts`, `tests/clients/link-child-ops-runners.test.ts`, `tests/clients/link-child-update-launcher.test.ts`, `tests/cli/cli-link-check.test.ts`, `tests/cli/cli-link-child-ops.test.ts`. ## Client link transport From 5a038babefc11d531379870d5e48f37e68f5a557 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 10:19:11 +0900 Subject: [PATCH 16/18] fix(link): CodeRabbit round on #8513: usage split, exit-5 causes, no dashboard claim, docs accuracy --- .../src/content/docs/fr/guides/remote-link.md | 2 +- .../src/content/docs/guides/remote-link.md | 6 ++--- .../src/content/docs/ja/guides/remote-link.md | 2 +- .../src/content/docs/ko/guides/remote-link.md | 2 +- .../content/docs/reference/cli/lifecycle.md | 4 ++-- .../src/content/docs/ru/guides/remote-link.md | 2 +- .../src/content/docs/tr/guides/remote-link.md | 2 +- .../content/docs/zh-cn/guides/remote-link.md | 2 +- .../content/docs/zh-tw/guides/remote-link.md | 2 +- .../references/01_surface_access-remote.md | 6 ++--- skills/ocx/references/02_json_shapes.md | 2 +- skills/ocx/references/04_failure_semantics.md | 12 ++++++---- skills/ocx/references/05_remote_hub.md | 4 ++-- src/cli/capabilities-access-remote.ts | 4 ++-- src/cli/link-child-ops.ts | 6 ++++- src/cli/registry.ts | 3 ++- structure/remote-link.md | 2 +- tests/cli/cli-link-child-ops.test.ts | 23 +++++++++++++++++++ 18 files changed, 59 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index c0be03c6579..2e7d4eaa1b2 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -98,7 +98,7 @@ Sur le Home, `ocx link check (--link-id | --all)` lit pour chaque enfant la ### Opérer les enfants depuis le Home -`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien et exige `--yes` ; il est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise ; il interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sinon, utilisez la page Remote Link du tableau de bord. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). +`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien ; `restart-codex` et `--restart-codex` exigent `--yes`, tandis que `ocx link sync --link-id --restart-app-server-only` redémarre les app-servers sans confirmation. Le redémarrage est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise après une application réussie, échouée ou annulée ; une application au résultat inconnu n’est ni relancée ni resynchronisée, vérifiez-la avec `ocx link check`. La mise à jour interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sur tout autre Home, ces opérations ne sont pas disponibles, et le tableau de bord ne les propose pas. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). Ne liez pas une machine qui ne vous appartient pas : le Home y exécute des commandes avec votre clé SSH. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index db791e1901c..6b4c48cf95b 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -203,11 +203,11 @@ From the Home you can also sync a Home-initiated Child, restart Codex on it, and - `ocx link sync` runs the same pinned `ocx sync --expect-link` that `ocx sync` sends to Children. It needs OpenCodex 2.93.0 or later on the Child. - `ocx link restart-codex --link-id --yes` syncs first and then restarts the Child's Codex app-servers, so a running Codex loads the current catalog. `--scope codex` also quits and relaunches the Codex desktop app, which needs a signed-in desktop session on the Child. `ocx link sync --link-id ` with `--restart-app-server-only` or `--restart-codex --yes` does the same. A restart ends the Codex turns running on that Child, so it names exactly one link. It needs 2.93.0 or later. -- `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again afterwards, also after a failed update. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. +- `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again after a successful, failed or rolled-back apply. An apply whose result is unknown is neither retried nor resynced; check that Child with `ocx link check`. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. -The Home does not restart Codex on a Child while it is relaying a model request for that Child: the restart is skipped (`restart-skipped:link-active`) and the sync still runs. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, new operations on it are refused the same way. +The Home does not restart Codex on a Child while it is relaying a model request for that Child. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, new operations on it are refused the same way. -The commands exit 0 when every Child succeeded or was already up to date, 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the Home has no links, or the command ran on a Child. The CLI needs a Home bound to loopback; on any other Home, use the Remote Link page of the dashboard. +The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running, so read it again for the result), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard has no controls for them. Do not link a machine you do not own: the Home runs commands there with your SSH key. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 5fb2d86b391..797c33de0c1 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -98,7 +98,7 @@ Home で `ocx link check (--link-id | --all)` を実行すると、Home か ### Home から Child を操作する -`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定し、`--yes` が必要です。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理した後に再同期し、その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではダッシュボードの Remote Link ページを使ってください。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 +`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定します。`restart-codex` と `--restart-codex` には `--yes` が必要ですが、`ocx link sync --link-id --restart-app-server-only` は確認なしで app-server を再起動します。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理し、適用が成功、失敗、ロールバックした後に再同期します。結果が不明な適用は再実行も再同期もしないため、`ocx link check` で確認してください。その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではこれらの操作は使えず、ダッシュボードにもその操作はありません。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 自分が所有していないマシンをリンクしないでください。Home はあなたの SSH 鍵でそのマシン上のコマンドを実行します。 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 84fc1cd40bd..cfc357c1633 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -98,7 +98,7 @@ Home에서 `ocx link check (--link-id | --all)`를 실행하면 Home이 추 ### Home에서 Child 운영하기 -`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정하고 `--yes`가 필요하며, Home이 그 Child의 모델 요청을 중계하는 동안에는 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리한 뒤 다시 동기화하며, 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요하며, 그렇지 않은 Home에서는 대시보드의 Remote Link 페이지를 사용하세요. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. +`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정합니다. `restart-codex`와 `--restart-codex`에는 `--yes`가 필요하지만 `ocx link sync --link-id --restart-app-server-only`는 확인 없이 app-server를 재시작합니다. Home이 그 Child의 모델 요청을 중계하는 동안에는 재시작을 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리하며, 적용이 성공, 실패, 롤백된 뒤에는 다시 동기화합니다. 결과를 알 수 없는 적용은 다시 실행하거나 동기화하지 않으므로 `ocx link check`로 확인하세요. 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요합니다. 그렇지 않은 Home에서는 이 작업을 실행할 수 없으며, 대시보드에도 이 작업은 없습니다. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. 소유하지 않은 컴퓨터는 링크하지 마세요. Home은 그 컴퓨터에서 사용자의 SSH 키로 명령을 실행합니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index d1248325078..e340e88e78c 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1387,12 +1387,12 @@ These commands start one job on the Home for Home-initiated Children and print i | Exit | Meaning | |---|---| -| 0 | every item `succeeded` or is `up-to-date`, and every requested restart went out | +| 0 | every item `succeeded` or is `up-to-date`, and every requested restart went out; with `--no-wait`, the job was accepted and may still be running (read it again for the result) | | 6 | an item is `busy`, `offline`, `refused`, `owner-action` or `unsupported`, or succeeded with a reason such as `restart-skipped:link-active` or `post-update-sync-failed` | | 1 | an item `failed` or is `uncertain`, an item state this CLI does not know, the job did not finish within its wait, or a Home not bound to loopback | | 2 | invalid arguments, for example a restart with `--all` or `update` without `--yes` | | 4 | an unknown link, or a Home without Child operations | -| 5 | another operation holds the link, the Home has no links, or the command ran on a Child | +| 5 | another operation holds the link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which | The wait does not count time an item spends queued behind another job. Removing a link while a job holds it answers `409 link_operation_in_progress`. diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index a3888d026a7..406e00024af 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### Управление Child с Home -`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку и требует `--yes`; он пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, после чего снова синхронизирует; запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; в остальных случаях используйте страницу Remote Link в панели управления. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). +`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку; `restart-codex` и `--restart-codex` требуют `--yes`, а `ocx link sync --link-id --restart-app-server-only` перезапускает app-server без подтверждения. Перезапуск пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, и снова синхронизирует после успешного, неудачного или откатанного применения; применение с неизвестным результатом не повторяется и не синхронизируется, проверьте его через `ocx link check`. Запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; на любом другом Home эти операции недоступны, и в панели управления их нет. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). Не связывайте машину, которая вам не принадлежит: Home выполняет на ней команды с вашим SSH-ключом. diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index a80d975037b..08ec2c91721 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -98,7 +98,7 @@ Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her ### Child'ları Home'dan yönetmek -`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler ve `--yes` gerektirir; Home o Child için bir model isteği aktarırken atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir ve ardından yeniden eşitlenir; o Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; aksi halde panodaki Remote Link sayfasını kullanın. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). +`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler; `restart-codex` ve `--restart-codex` için `--yes` gerekir, `ocx link sync --link-id --restart-app-server-only` ise app-server süreçlerini onay istemeden yeniden başlatır. Home o Child için bir model isteği aktarırken yeniden başlatma atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir; başarılı, başarısız veya geri alınmış bir uygulamadan sonra yeniden eşitlenir. Sonucu bilinmeyen bir uygulama tekrarlanmaz ve eşitlenmez; `ocx link check` ile denetleyin. O Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; başka bir Home'da bu işlemler kullanılamaz ve panoda da bu işlemler yoktur. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). Size ait olmayan bir makineyi bağlamayın: Home orada sizin SSH anahtarınızla komut çalıştırır. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 349d358138a..dd9b85c0f72 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 从 Home 操作子机 -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接,并且需要 `--yes`;当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,之后再次同步;正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;否则请使用仪表盘的 Remote Link 页面。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 无需确认即可重启 app-server。当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,应用成功、失败或回滚后会再次同步;结果未知的应用不会重试也不会同步,请用 `ocx link check` 检查。正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;在其他 Home 上无法执行这些操作,仪表盘也不提供这些操作。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 不要链接不属于你的机器:Home 会用你的 SSH 密钥在那里运行命令。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index 9e9202a9cfc..98d55682703 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 從 Home 操作 Child -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結,且需要 `--yes`;當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,之後再次同步;正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;否則請使用儀表板的 Remote Link 頁面。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 不需確認即可重新啟動 app-server。當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,套用成功、失敗或回復後會再次同步;結果未知的套用不會重試也不會同步,請用 `ocx link check` 檢查。正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;在其他 Home 上無法執行這些操作,儀表板也不提供這些操作。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 不要連結不屬於你的機器:Home 會用你的 SSH 金鑰在那裡執行指令。 diff --git a/skills/ocx/references/01_surface_access-remote.md b/skills/ocx/references/01_surface_access-remote.md index 7e22025a2f8..5fd8fcb17b0 100644 --- a/skills/ocx/references/01_surface_access-remote.md +++ b/skills/ocx/references/01_surface_access-remote.md @@ -723,12 +723,12 @@ State-changing: yes. JSON mode: `payload`. | `--json` | boolean | Emit the ocx.link.child-job/1 document (always JSON). | - Runs a pinned sync --expect-link first to confirm the Child still belongs to this link, then the Child's own ocx update --plan through the served entry, then --yes with --expect-plan and a --not-after 120 s after the plan on the Child's own clock, through the link's pinned SSH trust. npm and pnpm installs apply through their Node launcher; Bun installs plan and apply on their served Bun entry. The target is always this Home's version; the Child refuses a downgrade. -- Children below 2.97.0 run nothing and report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply). An update restarts the Child's OpenCodex and ends any turn currently relayed for it, so --yes is required. A pinned sync runs after every update, also after a failed or rolled-back one. -- Exit 0 all succeeded or up-to-date, 6 partial (including a kept update whose follow-up sync failed), 1 failed, uncertain or an unknown state, 5 another operation holds the link, the Home has no links or the command ran on a Child, 4 the link is unknown or the Home lacks Child operations. On a Home not bound to loopback it exits 1 and names the dashboard. +- Children below 2.97.0 receive no update command: from 2.93.0 they still get the identity sync, then report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply). An update restarts the Child's OpenCodex and ends any turn currently relayed for it, so --yes is required. A pinned sync follows a successful, failed or rolled-back apply; an apply whose result is unknown is recorded and neither retried nor resynced, so re-check it with ocx link check. +- Exit 0 all succeeded or up-to-date (with --no-wait: the job was accepted and may still be running), 6 partial (including a kept update whose follow-up sync failed), 1 failed, uncertain or an unknown state, 5 another operation holds the link, the only link named was started from the Child, the Home has no links or the command ran on a Child, 4 the link is unknown or the Home lacks Child operations. On a Home not bound to loopback it exits 1: these operations are not available there, and the dashboard has no controls for them. ### `ocx link sync` -Usage: `ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex --yes] [--no-wait] [--json]` +Usage: `ocx link sync (--link-id | --all) [--no-wait] [--json] | ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json]` Run the pinned catalog and settings sync on Home-initiated Children. diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index c79eddae2d1..8a0da76c543 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -418,7 +418,7 @@ state:"queued"|"running"|"done", createdAt, finishedAt, items}`, each item `sync` is `{state:"synced"|"skipped"|"failed"|"timed-out", reason}` for the pinned sync the item ran; `restart` is `{requested:"app-server"|"codex", outcome}` with `outcome` `requested` (sent, not confirmed), `unsupported` (Windows or `ocx-child`), `not-run` (the sync before it failed) or `skipped` (a relayed request was in -flight). Item states are `succeeded`, `up-to-date`, `busy`, `offline`, `refused`, `owner-action`, +flight). Item states are `queued` and `running` while the job runs (a `--no-wait` document can carry them), then `succeeded`, `up-to-date`, `busy`, `offline`, `refused`, `owner-action`, `unsupported`, `failed` and `uncertain`. Reasons are closed tokens such as `legacy-ocx`, `child-initiated`, `ssh-failed`, `link-removed`, `link-operation-in-progress`, `restart-skipped:link-active`, `restart-result-unknown`, `post-update-sync-failed`, `rolled-back`, `windows-apply` or `launcher-node-missing`; diff --git a/skills/ocx/references/04_failure_semantics.md b/skills/ocx/references/04_failure_semantics.md index 9a951da4873..8733a93280b 100644 --- a/skills/ocx/references/04_failure_semantics.md +++ b/skills/ocx/references/04_failure_semantics.md @@ -37,8 +37,10 @@ restart withheld because a relayed request was in flight (`restart-skipped:link- `ocx link check`, never re-sent blindly. A job still running past its deadline prints the job and exits 1 (`job_still_running`); time an item spends queued behind another job does not count. Exit 4 means an unknown link id, or a Home too old for Child operations (the message says to update the Home); exit 5 -means a conflict: another operation holds the link, the Home has no links, or the command ran on a -Child (each has its own message). On a Home not bound to loopback the verbs exit 1 and name the dashboard. +means a conflict: another operation holds the link, the only link named was started from the Child, the +Home has no links, or the command ran on a Child (each has its own message). On a Home not bound to +loopback the verbs exit 1: these operations are not available there, and the dashboard has no controls +for them. With `--no-wait`, exit 0 only means the job was accepted; read it again for the result. ## Distinguishing "not running" from "failing" @@ -159,8 +161,10 @@ flags, so check `ocx --version` before relying on them. The Home's own `ocx link sync|restart-codex|update` runs this pinned sync on the Child and reports the refusal as an item (`refused`, reason `child-link-mismatch:`) instead of an exit 5. Their exit 5 -is `link_operation_in_progress`: another job of the Home holds that link, and `ocx link revoke` on it -answers the same 409 until the job ends. +is a conflict the error message names: another job holds the link (`link_operation_in_progress`, which `ocx link revoke` on it +answers the same 409 until the job ends), the only link named was started from the Child +(`link_not_home_initiated`), the Home has no links, or the command ran on a Child. Read the message before +retrying. ## Destructive verbs fail closed diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 96a6673ac13..3a133f28280 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -369,8 +369,8 @@ a request is still running can end it. Windows Children take `sync` only: restarts are `unsupported` (`windows-restart`) and orchestrated updates `unsupported` (`windows-apply`). `ocx-child` Children report restart and update `unsupported`. The CLI -verbs need a Home bound to loopback; on any other Home they exit 1 and point at the dashboard's Remote -Link page. While a job holds a link, `ocx link revoke` on it is refused with 409 `link_operation_in_progress` (it does not wait); while a revoke runs, a job for that link is refused the same way, or its item ends `busy`. +verbs need a Home bound to loopback; on any other Home they exit 1, these operations are not available +there, and the dashboard has no controls for them. While a job holds a link, `ocx link revoke` on it is refused with 409 `link_operation_in_progress` (it does not wait); while a revoke runs, a job for that link is refused the same way, or its item ends `busy`. ## Key rotation is a two-step commit diff --git a/src/cli/capabilities-access-remote.ts b/src/cli/capabilities-access-remote.ts index b76affdd57a..2b6fb38dbb6 100644 --- a/src/cli/capabilities-access-remote.ts +++ b/src/cli/capabilities-access-remote.ts @@ -183,11 +183,11 @@ export const ACCESS_REMOTE_CAPABILITIES: readonly Capability[] = [ { name: "--yes", value: "boolean", required: true, summary: "Confirms that each updated Child's OpenCodex restarts and its relayed turns end." }, { name: "--no-wait", value: "boolean", summary: "Print the queued job and return." }, { name: "--json", value: "boolean", summary: "Emit the ocx.link.child-job/1 document (always JSON)." }], mutates: true, json: "payload", - details: ["Runs a pinned sync --expect-link first to confirm the Child still belongs to this link, then the Child's own ocx update --plan through the served entry, then --yes with --expect-plan and a --not-after 120 s after the plan on the Child's own clock, through the link's pinned SSH trust. npm and pnpm installs apply through their Node launcher; Bun installs plan and apply on their served Bun entry. The target is always this Home's version; the Child refuses a downgrade.", "Children below 2.97.0 run nothing and report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply). An update restarts the Child's OpenCodex and ends any turn currently relayed for it, so --yes is required. A pinned sync runs after every update, also after a failed or rolled-back one.", "Exit 0 all succeeded or up-to-date, 6 partial (including a kept update whose follow-up sync failed), 1 failed, uncertain or an unknown state, 5 another operation holds the link, the Home has no links or the command ran on a Child, 4 the link is unknown or the Home lacks Child operations. On a Home not bound to loopback it exits 1 and names the dashboard."], + details: ["Runs a pinned sync --expect-link first to confirm the Child still belongs to this link, then the Child's own ocx update --plan through the served entry, then --yes with --expect-plan and a --not-after 120 s after the plan on the Child's own clock, through the link's pinned SSH trust. npm and pnpm installs apply through their Node launcher; Bun installs plan and apply on their served Bun entry. The target is always this Home's version; the Child refuses a downgrade.", "Children below 2.97.0 receive no update command: from 2.93.0 they still get the identity sync, then report owner-action legacy-ocx; ocx-child and GOAT-managed Children report unsupported or owner-action; Windows Children are refused orchestrated apply (unsupported windows-apply). An update restarts the Child's OpenCodex and ends any turn currently relayed for it, so --yes is required. A pinned sync follows a successful, failed or rolled-back apply; an apply whose result is unknown is recorded and neither retried nor resynced, so re-check it with ocx link check.", "Exit 0 all succeeded or up-to-date (with --no-wait: the job was accepted and may still be running), 6 partial (including a kept update whose follow-up sync failed), 1 failed, uncertain or an unknown state, 5 another operation holds the link, the only link named was started from the Child, the Home has no links or the command ran on a Child, 4 the link is unknown or the Home lacks Child operations. On a Home not bound to loopback it exits 1: these operations are not available there, and the dashboard has no controls for them."], }, { command: ["link", "sync"], summary: "Run the pinned catalog and settings sync on Home-initiated Children.", - usage: "ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex --yes] [--no-wait] [--json]", + usage: "ocx link sync (--link-id | --all) [--no-wait] [--json] | ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json]", routes: [{ method: "POST", path: "/api/link/children/operations" }, { method: "GET", path: "/api/link/children/jobs/{jobId}" }], flags: [{ name: "--link-id", value: "string", summary: "Home-initiated link to sync; repeatable, up to 64, exactly one with a restart." }, { name: "--all", value: "boolean", summary: "Every link in the Home's store, three at a time; refused with a restart." }, { name: "--restart-app-server-only", value: "boolean", summary: "Also restart the Child's Codex app-servers after the sync writes." }, { name: "--restart-codex", value: "boolean", summary: "Also quit and relaunch the Child's Codex desktop app; ends live turns." }, diff --git a/src/cli/link-child-ops.ts b/src/cli/link-child-ops.ts index 0da02716bf4..489c546d7a0 100644 --- a/src/cli/link-child-ops.ts +++ b/src/cli/link-child-ops.ts @@ -14,10 +14,14 @@ export const LINK_CHECK_POLL_MS = 2_000; export const OLD_HOME_MESSAGE = "This Home does not support Child operations; update the Home."; export const NO_ELIGIBLE_LINKS_MESSAGE = "This Home has no links."; export const RUNTIME_ROLE_CLIENT_MESSAGE = "This machine is a Child; run Child operations on its Home."; -export const LOOPBACK_REQUIRED_MESSAGE = "Child operations from the CLI need a Home bound to loopback; use the Remote Link page in the dashboard."; +export const LINK_NOT_HOME_INITIATED_MESSAGE = "This link was started from the Child, so the Home cannot run operations on it."; +export const LINK_OPERATION_IN_PROGRESS_MESSAGE = "Another operation is running on this link; wait for it to finish, then try again."; +export const LOOPBACK_REQUIRED_MESSAGE = "Child operations from the CLI need a Home bound to loopback; on this Home they are not available, and the dashboard has no controls for them."; const CONFLICT_MESSAGES: Readonly> = { no_eligible_links: NO_ELIGIBLE_LINKS_MESSAGE, runtime_role_client: RUNTIME_ROLE_CLIENT_MESSAGE, + link_not_home_initiated: LINK_NOT_HOME_INITIATED_MESSAGE, + link_operation_in_progress: LINK_OPERATION_IN_PROGRESS_MESSAGE, }; /** How long one item may run once it has started: the sum of its runner's own SSH bounds plus a margin. */ diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 372564489eb..3819e1d7965 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -163,7 +163,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Cleanup: ocx link cleanup [--link-id ] [--json]", "Check: ocx link check (--link-id | --all) [--no-wait] [--json]", "Update: ocx link update (--link-id | --all) --yes [--no-wait] [--json]", - "Sync: ocx link sync (--link-id | --all) [--restart-app-server-only | --restart-codex --yes] [--no-wait] [--json]", + "Sync: ocx link sync (--link-id | --all) [--no-wait] [--json]", + "Sync with a restart: ocx link sync --link-id (--restart-app-server-only | --restart-codex --yes) [--no-wait] [--json]", "Restart Codex: ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wait] [--json]", "Issue, status, revoke, reconnect, check, update, sync and restart-codex use the running proxy's loopback management API and admin token; update, sync and restart-codex act only on Home-initiated links.", ], diff --git a/structure/remote-link.md b/structure/remote-link.md index 6744e33add1..dd08b6d96b8 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -70,7 +70,7 @@ A connected Child answers `GET` and `HEAD /api/link/status` on its own listener Applying a link probes the host key into a temporary file, waits for the operator to confirm the fingerprint, issues a data key, records the link, starts the tunnel and runs the client's `ocx connect --link --key-stdin` over SSH with the key on standard input. A failed step revokes the new key first and removes the record only after revocation succeeds; if revocation fails the `src/link/` state persists a `compensation_failed` marker, and status reports the failed compensation after restart. A listener that is not listening fails the request instead of handing out a key. Removing a link stops its tunnel, disconnects the client, revokes the key and deletes the record; a failed client disconnect restarts the tunnel and keeps the record and key unless removal is forced. `src/cli/link.ts` provides `ocx link probe|add|port|issue|revoke|status|reconnect|cleanup|check|update|sync|restart-codex`; `cleanup` is local and read-only (see `src/cli/link-cleanup.ts` above). `ocx link revoke` is idempotent: a `404 link_not_found` answer exits 0, because removal revokes the key before it deletes the record, so a missing record means the key is already gone. A 404 without that code still fails. -Child operations run as jobs. `src/server/management/link-child-ops-routes.ts` serves `POST /api/link/children/operations` (body exactly `{op, linkIds, restart?}` with `op` one of `observe`, `sync`, `restart-codex`, `update`; `linkIds` is `"all"` or 1 to 64 distinct link ids; `restart` is `app-server` or `codex`, refused on `observe`, defaulting to `app-server` for `restart-codex`, and any request that carries a restart must name its links, so `"all"` with a restart is `400 invalid_body`; `202 {job}`, `400 invalid_body` for any other op or key including `trigger`, `404 link_not_found`, `409 link_not_home_initiated` for a single Child-initiated link outside `observe`, `409 link_operation_in_progress`, `409 no_eligible_links` only when links.json has no link, `409 runtime_role_client` on a Child) and `GET /api/link/children/jobs/{jobId}` (`ljob_` plus 16 hex; `400 invalid_job_id`, `404 job_not_found`). The route always submits `trigger: "manual"`; only the in-process scheduler can submit `auto`. `handleLinkRoutes` delegates every `/api/link/children/` path before its reconnect and DELETE matchers, so `DELETE /api/link/children/x` answers 404 and never removes a link, and `DELETE /api/link/{id}` answers `409 link_operation_in_progress` while a job holds that link; otherwise removal reserves the link in the engine (`reserve`) before its first teardown await and releases it when it ends, so a job submitted during the removal gets 409 for that link alone or a `busy` item, and no job reaches a Child the removal is disconnecting. Both routes take the same principals as probe and apply; the local-control socket still admits only `GET /api/link/status`, so on a non-loopback Home the CLI verbs exit 1 and name the dashboard. `src/link/child-ops-engine.ts` keeps the `ocx.link.child-job/1` jobs (types in `src/link/child-ops-types.ts`) in one engine per Home process (`sharedChildOpsEngine`; the routes cache it on `LinkRouteState.childOps` and status reads its `activeJob`): one lock per link (a held link makes a single-link request 409 and a multi-link item `busy`, which leaves that link's `lastOperation` to its holder), three items at a time across every job of which at most one is an update (a waiting update does not hold up the items behind it), Child-initiated items of `sync`, `restart-codex` and `update` ended `unsupported`/`child-initiated` at admission, the record re-read before SSH (`refused`/`link-removed` when it vanished), finished jobs kept one hour and at most 32 on each job's own clock, no timer. Each submit carries a `ChildOpsEnv` (store, SSH runner, wire state, activity, Home version, observe, a cache writer that never throws, the Home catalog etag). Items carry closed reason tokens and Home-written hints, never remote text. +Child operations run as jobs. `src/server/management/link-child-ops-routes.ts` serves `POST /api/link/children/operations` (body exactly `{op, linkIds, restart?}` with `op` one of `observe`, `sync`, `restart-codex`, `update`; `linkIds` is `"all"` or 1 to 64 distinct link ids; `restart` is `app-server` or `codex`, refused on `observe`, defaulting to `app-server` for `restart-codex`, and any request that carries a restart must name its links, so `"all"` with a restart is `400 invalid_body`; `202 {job}`, `400 invalid_body` for any other op or key including `trigger`, `404 link_not_found`, `409 link_not_home_initiated` for a single Child-initiated link outside `observe`, `409 link_operation_in_progress`, `409 no_eligible_links` only when links.json has no link, `409 runtime_role_client` on a Child) and `GET /api/link/children/jobs/{jobId}` (`ljob_` plus 16 hex; `400 invalid_job_id`, `404 job_not_found`). The route always submits `trigger: "manual"`; only the in-process scheduler can submit `auto`. `handleLinkRoutes` delegates every `/api/link/children/` path before its reconnect and DELETE matchers, so `DELETE /api/link/children/x` answers 404 and never removes a link, and `DELETE /api/link/{id}` answers `409 link_operation_in_progress` while a job holds that link; otherwise removal reserves the link in the engine (`reserve`) before its first teardown await and releases it when it ends, so a job submitted during the removal gets 409 for that link alone or a `busy` item, and no job reaches a Child the removal is disconnecting. Both routes take the same principals as probe and apply; the local-control socket still admits only `GET /api/link/status`, so on a non-loopback Home the CLI verbs exit 1 because these operations are not available there (the dashboard has no controls for them). `src/link/child-ops-engine.ts` keeps the `ocx.link.child-job/1` jobs (types in `src/link/child-ops-types.ts`) in one engine per Home process (`sharedChildOpsEngine`; the routes cache it on `LinkRouteState.childOps` and status reads its `activeJob`): one lock per link (a held link makes a single-link request 409 and a multi-link item `busy`, which leaves that link's `lastOperation` to its holder), three items at a time across every job of which at most one is an update (a waiting update does not hold up the items behind it), Child-initiated items of `sync`, `restart-codex` and `update` ended `unsupported`/`child-initiated` at admission, the record re-read before SSH (`refused`/`link-removed` when it vanished), finished jobs kept one hour and at most 32 on each job's own clock, no timer. Each submit carries a `ChildOpsEnv` (store, SSH runner, wire state, activity, Home version, observe, a cache writer that never throws, the Home catalog etag). Items carry closed reason tokens and Home-written hints, never remote text. `src/link/child-ops-runners.ts` holds the runners. `observe` is `src/link/child-observe.ts`: client-initiated and `ocx-child` links answer without SSH; otherwise one gated `update --plan` (60 s, 64 KiB) classifies the Child, and on an npm or pnpm Child served through its census pair the script also reports whether a Node answers `--version` within 10 s (`ocx-fanout:launcher:available|unavailable`). `sync` sends the pinned `sync --expect-link` (gate 2.93.0, 180 s) and, when the sync reports synced, records the catalog etag read before it as `catalogSyncedEtag`; with a restart it reads the link's activity after that sync and sends the restart as a second pinned sync carrying `--restart-app-server-only` or `--restart-codex`, withheld (`restart.outcome: "skipped"`, reason `restart-skipped:link-active`) while a relayed request is in flight or the activity is unknown. A second sync refused before an ocx was selected, or skipped by the Child, reports `not-run`; one that may have run but failed or timed out ends `uncertain`/`restart-result-unknown`. `restart-codex` is that path with the restart as its purpose: a busy link sends nothing (`busy`), Windows is refused without SSH (`windows-restart`), `ocx-child` reports `unsupported` (the record's `childKind` or the `selected-kind` marker). `update` sends an identity pinned sync first, then a fresh observe (Windows Children too, because `desktop-request` is decided before the `windows-apply` refusal), then the gated apply `update --yes --json --version --expect-plan --not-after ` (gate 2.97.0, bounded by `min(mutationBoundMs, 900 s) + 120 s`, an SSH spawn failure ending `failed`/`ssh-spawn-failed` with nothing to recover, not sent with under 15 s of the window left), through ` /bin/ocx.mjs` with `OCX_UPDATE_ORCHESTRATED_CHILD` unset only when the plan's installer is npm or pnpm (a missing Node exits 121 `child-node-unavailable`, owner action `launcher-node-missing`), and a pinned sync after every outcome that may have stopped the Child's proxy (success, failure, roll-back); the target is always the Home's version, exit 11 records `suppressedTarget` and a manual update clears it, an uncertain apply records `pendingUpdateRecovery` for an hour and is never re-sent. The Windows gated builder builds only `update --plan`. A legacy key admission, which is how a Child older than 2.69 reaches the Home, counts as activity only from its response headers, so a non-streaming request from such a Child is invisible to the restart guard while it runs. An item that ran writes its observation fields and then `lastOperation` to `child-observations.json`. `src/cli/link-child-ops.ts` (`runLinkCheck`, `runLinkChildOp`, the tolerant `validateChildJob`) polls every 2 s with a per-op item budget (observe 120 s, sync and restart-codex 420 s, update 2 100 s) that does not run while an item is queued, under a 3-hour cap, and exits 0, 6 for a partial result (including a success with a reason or a restart that did not go out), 1 for a failure, an uncertain item or an unknown item state, 4 for an unknown link or (with its own message) a Home without the route or, for a non-observe op, one that answers `400 invalid_body`, 5 for a conflict. `ocx link update` needs `--yes`, as does any restart of scope `codex` and `restart-codex` itself. Coverage: `tests/server/link-child-ops-routes.test.ts`, `tests/clients/link-child-observe.test.ts`, `tests/clients/link-child-ops-engine.test.ts`, `tests/clients/link-child-ops-runners.test.ts`, `tests/clients/link-child-update-launcher.test.ts`, `tests/cli/cli-link-check.test.ts`, `tests/cli/cli-link-child-ops.test.ts`. diff --git a/tests/cli/cli-link-child-ops.test.ts b/tests/cli/cli-link-child-ops.test.ts index b6241417afc..337d15dbb14 100644 --- a/tests/cli/cli-link-child-ops.test.ts +++ b/tests/cli/cli-link-child-ops.test.ts @@ -222,3 +222,26 @@ describe("contract", () => { } finally { out.mockRestore(); } }); }); + +describe("CodeRabbit 4532654426 / 4532654389: messages an operator can act on", () => { + test("a Home not bound to loopback: the message states the limit and offers no dashboard path", async () => { + const result = await run(["update", "--link-id", LINK, "--yes"], deps(() => ({ status: 403, body: { error: { code: "admin_loopback_required", message: "x" } } }))); + expect(result.code).toBe(1); + expect(result.stderr[0]).toBe("Error: Child operations from the CLI need a Home bound to loopback; on this Home they are not available, and the dashboard has no controls for them."); + }); + + test("each exit-5 conflict names its cause", async () => { + const cases: Array<[string, string]> = [ + ["link_not_home_initiated", "This link was started from the Child, so the Home cannot run operations on it."], + ["link_operation_in_progress", "Another operation is running on this link; wait for it to finish, then try again."], + ["no_eligible_links", "This Home has no links."], + ["runtime_role_client", "This machine is a Child; run Child operations on its Home."], + ]; + for (const [code, message] of cases) { + const result = await run(["sync", "--link-id", LINK], deps(() => ({ status: 409, body: { error: { code, message: "x" } } }))); + expect(result.code).toBe(5); + expect(result.stderr[0]).toBe("Error: " + message); + } + }); +}); + From bc1f8eac255ef876359d42a39d0a37476bfaa831 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 10:51:44 +0900 Subject: [PATCH 17/18] docs(link): distinguish single-link and batch refusals during removal; name the job read for --no-wait --- docs-site/src/content/docs/guides/remote-link.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 6b4c48cf95b..fd15459bb06 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -205,9 +205,9 @@ From the Home you can also sync a Home-initiated Child, restart Codex on it, and - `ocx link restart-codex --link-id --yes` syncs first and then restarts the Child's Codex app-servers, so a running Codex loads the current catalog. `--scope codex` also quits and relaunches the Codex desktop app, which needs a signed-in desktop session on the Child. `ocx link sync --link-id ` with `--restart-app-server-only` or `--restart-codex --yes` does the same. A restart ends the Codex turns running on that Child, so it names exactly one link. It needs 2.93.0 or later. - `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again after a successful, failed or rolled-back apply. An apply whose result is unknown is neither retried nor resynced; check that Child with `ocx link check`. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. -The Home does not restart Codex on a Child while it is relaying a model request for that Child. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, new operations on it are refused the same way. +The Home does not restart Codex on a Child while it is relaying a model request for that Child. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, a new operation that names only that link is refused with the same 409, and a job that names several links reports that link's item as `busy` (`link-operation-in-progress`) while its other links still run. -The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running, so read it again for the result), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard has no controls for them. +The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running. The printed job carries a `jobId`; read the result with `GET /api/link/children/jobs/` on the Home, using the admin token on loopback), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard has no controls for them. Do not link a machine you do not own: the Home runs commands there with your SSH key. From c2a00f9e4771d503dbfec9eb39b2accf57db81cd Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 11:22:45 +0900 Subject: [PATCH 18/18] docs(link): restart protection is best-effort; job results expire --- docs-site/src/content/docs/fr/guides/remote-link.md | 2 +- docs-site/src/content/docs/guides/remote-link.md | 4 ++-- docs-site/src/content/docs/ja/guides/remote-link.md | 2 +- docs-site/src/content/docs/ko/guides/remote-link.md | 2 +- docs-site/src/content/docs/ru/guides/remote-link.md | 2 +- docs-site/src/content/docs/tr/guides/remote-link.md | 2 +- docs-site/src/content/docs/zh-cn/guides/remote-link.md | 2 +- docs-site/src/content/docs/zh-tw/guides/remote-link.md | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 2e7d4eaa1b2..74d4430d3c1 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -98,7 +98,7 @@ Sur le Home, `ocx link check (--link-id | --all)` lit pour chaque enfant la ### Opérer les enfants depuis le Home -`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien ; `restart-codex` et `--restart-codex` exigent `--yes`, tandis que `ocx link sync --link-id --restart-app-server-only` redémarre les app-servers sans confirmation. Le redémarrage est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise après une application réussie, échouée ou annulée ; une application au résultat inconnu n’est ni relancée ni resynchronisée, vérifiez-la avec `ocx link check`. La mise à jour interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sur tout autre Home, ces opérations ne sont pas disponibles, et le tableau de bord ne les propose pas. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). +`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien ; `restart-codex` et `--restart-codex` exigent `--yes`, tandis que `ocx link sync --link-id --restart-app-server-only` redémarre les app-servers sans confirmation. Le redémarrage est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise après une application réussie, échouée ou annulée ; une application au résultat inconnu n’est ni relancée ni resynchronisée, vérifiez-la avec `ocx link check`. La mise à jour interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sur tout autre Home, ces opérations ne sont pas disponibles, et le tableau de bord ne les propose pas. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). Cette protection est au mieux : une requête qui démarre entre la vérification et le redémarrage peut encore être interrompue ; suspendez les nouvelles requêtes sur cet enfant si ses tours doivent être préservés. Ne liez pas une machine qui ne vous appartient pas : le Home y exécute des commandes avec votre clé SSH. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index fd15459bb06..c2ed4503850 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -205,9 +205,9 @@ From the Home you can also sync a Home-initiated Child, restart Codex on it, and - `ocx link restart-codex --link-id --yes` syncs first and then restarts the Child's Codex app-servers, so a running Codex loads the current catalog. `--scope codex` also quits and relaunches the Codex desktop app, which needs a signed-in desktop session on the Child. `ocx link sync --link-id ` with `--restart-app-server-only` or `--restart-codex --yes` does the same. A restart ends the Codex turns running on that Child, so it names exactly one link. It needs 2.93.0 or later. - `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again after a successful, failed or rolled-back apply. An apply whose result is unknown is neither retried nor resynced; check that Child with `ocx link check`. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. -The Home does not restart Codex on a Child while it is relaying a model request for that Child. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, a new operation that names only that link is refused with the same 409, and a job that names several links reports that link's item as `busy` (`link-operation-in-progress`) while its other links still run. +The Home does not restart Codex on a Child while it is relaying a model request for that Child. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). This protection is best-effort: a request that starts between that check and the restart can still be ended, so pause new requests on that Child when its turns must survive. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, a new operation that names only that link is refused with the same 409, and a job that names several links reports that link's item as `busy` (`link-operation-in-progress`) while its other links still run. -The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running. The printed job carries a `jobId`; read the result with `GET /api/link/children/jobs/` on the Home, using the admin token on loopback), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard has no controls for them. +The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running. The printed job carries a `jobId`; read the result with `GET /api/link/children/jobs/` on the Home, using the admin token on loopback. Finished jobs stay readable for one hour, at most 32 of them, and are lost when the Home restarts, so read the result promptly), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard has no controls for them. Do not link a machine you do not own: the Home runs commands there with your SSH key. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 797c33de0c1..5d4e7f75901 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -98,7 +98,7 @@ Home で `ocx link check (--link-id | --all)` を実行すると、Home か ### Home から Child を操作する -`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定します。`restart-codex` と `--restart-codex` には `--yes` が必要ですが、`ocx link sync --link-id --restart-app-server-only` は確認なしで app-server を再起動します。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理し、適用が成功、失敗、ロールバックした後に再同期します。結果が不明な適用は再実行も再同期もしないため、`ocx link check` で確認してください。その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではこれらの操作は使えず、ダッシュボードにもその操作はありません。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 +`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定します。`restart-codex` と `--restart-codex` には `--yes` が必要ですが、`ocx link sync --link-id --restart-app-server-only` は確認なしで app-server を再起動します。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理し、適用が成功、失敗、ロールバックした後に再同期します。結果が不明な適用は再実行も再同期もしないため、`ocx link check` で確認してください。その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではこれらの操作は使えず、ダッシュボードにもその操作はありません。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 この保護はベストエフォートです。確認から再起動までの間に始まったリクエストは終了することがあるため、ターンを残す必要がある場合はその Child で新しいリクエストを止めてください。 自分が所有していないマシンをリンクしないでください。Home はあなたの SSH 鍵でそのマシン上のコマンドを実行します。 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 cfc357c1633..f0ef7dceb59 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -98,7 +98,7 @@ Home에서 `ocx link check (--link-id | --all)`를 실행하면 Home이 추 ### Home에서 Child 운영하기 -`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정합니다. `restart-codex`와 `--restart-codex`에는 `--yes`가 필요하지만 `ocx link sync --link-id --restart-app-server-only`는 확인 없이 app-server를 재시작합니다. Home이 그 Child의 모델 요청을 중계하는 동안에는 재시작을 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리하며, 적용이 성공, 실패, 롤백된 뒤에는 다시 동기화합니다. 결과를 알 수 없는 적용은 다시 실행하거나 동기화하지 않으므로 `ocx link check`로 확인하세요. 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요합니다. 그렇지 않은 Home에서는 이 작업을 실행할 수 없으며, 대시보드에도 이 작업은 없습니다. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. +`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정합니다. `restart-codex`와 `--restart-codex`에는 `--yes`가 필요하지만 `ocx link sync --link-id --restart-app-server-only`는 확인 없이 app-server를 재시작합니다. Home이 그 Child의 모델 요청을 중계하는 동안에는 재시작을 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리하며, 적용이 성공, 실패, 롤백된 뒤에는 다시 동기화합니다. 결과를 알 수 없는 적용은 다시 실행하거나 동기화하지 않으므로 `ocx link check`로 확인하세요. 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요합니다. 그렇지 않은 Home에서는 이 작업을 실행할 수 없으며, 대시보드에도 이 작업은 없습니다. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. 이 보호는 최선의 노력일 뿐입니다. 확인과 재시작 사이에 시작된 요청은 끝날 수 있으므로, 턴을 보존해야 하면 그 Child에서 새 요청을 멈추세요. 소유하지 않은 컴퓨터는 링크하지 마세요. Home은 그 컴퓨터에서 사용자의 SSH 키로 명령을 실행합니다. diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index 406e00024af..fdba38ccb63 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### Управление Child с Home -`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку; `restart-codex` и `--restart-codex` требуют `--yes`, а `ocx link sync --link-id --restart-app-server-only` перезапускает app-server без подтверждения. Перезапуск пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, и снова синхронизирует после успешного, неудачного или откатанного применения; применение с неизвестным результатом не повторяется и не синхронизируется, проверьте его через `ocx link check`. Запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; на любом другом Home эти операции недоступны, и в панели управления их нет. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). +`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку; `restart-codex` и `--restart-codex` требуют `--yes`, а `ocx link sync --link-id --restart-app-server-only` перезапускает app-server без подтверждения. Перезапуск пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, и снова синхронизирует после успешного, неудачного или откатанного применения; применение с неизвестным результатом не повторяется и не синхронизируется, проверьте его через `ocx link check`. Запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; на любом другом Home эти операции недоступны, и в панели управления их нет. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). Эта защита работает по возможности: запрос, начавшийся между проверкой и перезапуском, всё равно может быть прерван, поэтому приостановите новые запросы на этом Child, если его ходы нужно сохранить. Не связывайте машину, которая вам не принадлежит: Home выполняет на ней команды с вашим SSH-ключом. diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index 08ec2c91721..1a11f5a7766 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -98,7 +98,7 @@ Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her ### Child'ları Home'dan yönetmek -`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler; `restart-codex` ve `--restart-codex` için `--yes` gerekir, `ocx link sync --link-id --restart-app-server-only` ise app-server süreçlerini onay istemeden yeniden başlatır. Home o Child için bir model isteği aktarırken yeniden başlatma atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir; başarılı, başarısız veya geri alınmış bir uygulamadan sonra yeniden eşitlenir. Sonucu bilinmeyen bir uygulama tekrarlanmaz ve eşitlenmez; `ocx link check` ile denetleyin. O Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; başka bir Home'da bu işlemler kullanılamaz ve panoda da bu işlemler yoktur. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). +`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler; `restart-codex` ve `--restart-codex` için `--yes` gerekir, `ocx link sync --link-id --restart-app-server-only` ise app-server süreçlerini onay istemeden yeniden başlatır. Home o Child için bir model isteği aktarırken yeniden başlatma atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir; başarılı, başarısız veya geri alınmış bir uygulamadan sonra yeniden eşitlenir. Sonucu bilinmeyen bir uygulama tekrarlanmaz ve eşitlenmez; `ocx link check` ile denetleyin. O Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; başka bir Home'da bu işlemler kullanılamaz ve panoda da bu işlemler yoktur. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). Bu koruma en iyi çaba esaslıdır: kontrol ile yeniden başlatma arasında başlayan bir istek yine de sonlandırılabilir; turların korunması gerekiyorsa o Child'da yeni istekleri duraklatın. Size ait olmayan bir makineyi bağlamayın: Home orada sizin SSH anahtarınızla komut çalıştırır. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index dd9b85c0f72..a47079e4350 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 从 Home 操作子机 -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 无需确认即可重启 app-server。当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,应用成功、失败或回滚后会再次同步;结果未知的应用不会重试也不会同步,请用 `ocx link check` 检查。正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;在其他 Home 上无法执行这些操作,仪表盘也不提供这些操作。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 无需确认即可重启 app-server。当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,应用成功、失败或回滚后会再次同步;结果未知的应用不会重试也不会同步,请用 `ocx link check` 检查。正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;在其他 Home 上无法执行这些操作,仪表盘也不提供这些操作。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保护为尽力而为:在检查与重启之间开始的请求仍可能被结束,因此需要保留轮次时,请先暂停该子机上的新请求。 不要链接不属于你的机器:Home 会用你的 SSH 密钥在那里运行命令。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index 98d55682703..93f97d3db37 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -98,7 +98,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 從 Home 操作 Child -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 不需確認即可重新啟動 app-server。當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,套用成功、失敗或回復後會再次同步;結果未知的套用不會重試也不會同步,請用 `ocx link check` 檢查。正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;在其他 Home 上無法執行這些操作,儀表板也不提供這些操作。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 不需確認即可重新啟動 app-server。當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,套用成功、失敗或回復後會再次同步;結果未知的套用不會重試也不會同步,請用 `ocx link check` 檢查。正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;在其他 Home 上無法執行這些操作,儀表板也不提供這些操作。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保護僅為盡力而為:在檢查與重新啟動之間開始的請求仍可能被結束,因此需要保留回合時,請先暫停該 Child 上的新請求。 不要連結不屬於你的機器:Home 會用你的 SSH 金鑰在那裡執行指令。