diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 2463c8263ac..c08ebb07992 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -33,6 +33,8 @@ file, and removes it again. Each client below has its own switch: | Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback | | T3 Code | `$T3CODE_HOME/userdata/settings.json` (default `~/.t3/userdata/settings.json`) | JSON | immediately — T3 Code watches the file | non-secret loopback marker | +From the CLI, `ocx integration client enable|disable --client ` changes this machine only, like the dashboard toggle. On a Remote Link Home that uses route intent, the change is recorded as the Home's own decision, so `ocx sync` keeps it, and linked Children keep their defaults. To change what Children inherit, use `ocx route to on|off`. On a Remote Link Child a plain toggle is refused and prints the Home command for that device; use `ocx route to off --local` there to opt that Child out. See [Remote Link](/guides/remote-link/#route-clients-per-device). + ## Gentle Shell Choose **Connect → Gentle Shell**, or use the same managed-integration commands: diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 383f73c8fa9..acc9f0a092b 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -107,6 +107,45 @@ ocx disconnect # or ~/.cache/opencodex/bin/ocx-child detach on a lightweight C To disconnect a Child-initiated link, run `ocx disconnect` on the Child. It disconnects the client tunnel and revokes the link on Home over SSH. If Home revocation fails, it prints: `Home revoke failed; run ocx link revoke --link-id on the home.` +## Route clients per device + +The Home owns which clients route through OpenCodex. Each Child materializes that intent using its own installed clients, filesystem paths, listener port and served model catalog. No client config file or credential is copied from the Home. Device overrides take precedence over Home defaults; the built-in default enables Codex and Claude Code and leaves other clients off. + +On the Home itself, Codex, Claude Code, Grok and Claude Desktop follow the Home's own on/off switches, the same ones `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, turning the Messages API off, agent settings and the dashboard toggles change. Only an explicit `false` turns one off; an unset switch is on. Home defaults and device overrides never decide these four clients on the Home. An unset or enabled Grok or Claude Desktop switch that no route write is deciding does only what startup does: Grok follows the startup sync rules, and Claude Desktop refreshes a gateway profile OpenCodex already owns but never creates one or changes the Desktop mode. An owned profile that was changed since OpenCodex wrote it is reported as a conflict instead of being refreshed. A Desktop library OpenCodex never owned is left alone and reported as current, so a full sync does not fail on it. Run `ocx route to claude-desktop on --device home` (or `ocx route to claude-desktop on`) to create the Desktop profile. None of this enables Grok or Claude Desktop on Child devices. + +Children never read the Home's switches. A Child takes its device override, then the saved Home default, then the built-in default. The Home saves the defaults for these four clients once: Codex and Claude Code as the Home's switches stand at that moment (on unless explicitly off), Grok and Claude Desktop off. A Home default write (`ocx route to on|off` without `--device`) and enrolling a Child always save them. Once the Home has a link, they are also saved before anything Home-only can change a switch: when the link listener starts, and in `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, turning the Messages API off, the Claude toggle, the native toggles and a `--device home` route write. Before a Home-only integration change (a dashboard toggle, `ocx integration client enable|disable`, an undo from the integration history or an Aside profile change) a linked Home saves every default its Children inherit, file integrations included, and the link listener does the same when it starts, so a Home upgraded from a version that saved only these four freezes the rest before anything moves them. A link to an ephemeral `ocx-child` does not count here: it never reads route intent, so a Home whose only links are ephemeral has no Child to protect. On a Home with no link these Home-only commands save no default, so a Child enrolled later inherits the switches as they stand when it enrolls, unless a Home default write already saved the defaults. If such a Home already uses route intent because an earlier default write saved defaults, a Home-only integration change is still recorded as the Home's own row so `ocx sync` keeps it; Children never inherit that row. If saving fails when the link listener starts (for example, another process holds the configuration lock), it keeps retrying in the background. Reading route intent, through `ocx route to status` or by a Child, never saves anything. After that, what Children inherit changes only through a Home default write. Those Home-only commands change the Home alone. If the saved route intent file (`route-intent.json` in the OpenCodex config directory) cannot be read, these commands on a linked Home, and every `ocx route to` change, refuse without changing anything and name the file to fix or remove. On a Home with no link the native toggles, `ocx restore` and Home-only integration changes still work: an integration change then records nothing, and `ocx sync`, which cannot read the file either, cannot undo it. + +When `ocx route to on|off --device home` decides Codex, Claude Code, Grok or Claude Desktop, it saves that client's switch and then applies it. If applying ends in a conflict or failure (for example an external Codex provider, an incomplete Grok block or a changed Claude Desktop profile), the command fails and says whether the switch was saved: "The Home's grok switch was saved, but applying it ended in conflict" means the switch changed, and "The Home did not apply" means nothing changed. Claude Desktop in first-party mode refuses before saving. + +Once this Home uses route intent (it has a saved default or a Home override), `ocx sync` applies it to every client on the Home. So each Home-only integration change is recorded as this Home's decision for that client and `ocx sync` keeps it: a dashboard toggle, `ocx integration client enable|disable` (the same Home-only decision; `ocx route to on|off` changes what Children inherit), an undo from the integration history, and an Aside profile change, which is recorded as the stable Aside userIds of every profile the Home routes afterwards. Linked Children keep their defaults. When an Aside profile has no stable userId in Aside's `accounts.json`, an Aside profile change on such a Home is refused before anything changes (`route_intent_aside_unresolved`); repair the manifest, or decide Aside for the Home with `ocx route to aside on|off --device home`. A Home that already routed such profiles before it used route intent (for example after upgrading with a link) keeps a working `ocx sync`: until Aside is decided or the manifest is repaired, the sync leaves Aside exactly as it is, applies every other client, and prints how to resolve it. + +```sh +ocx route to status --device all --json +ocx route to aside on --accounts all +ocx route to aside on --device my-child --accounts stable-aside-user-id +ocx route to pi off --device my-child +ocx route to pi inherit --device my-child +ocx route to sync --device my-child +``` + +Status JSON uses `ocx.route-to/1` and distinguishes desired intent, its source, and the last applied outcome. The source is `builtin`, `default`, `device` or `local` (a Child opt-out); the Home's rows for Codex, Claude Code, Grok and Claude Desktop report `native`, meaning the Home's own switch. `not_installed`, `conflict` and `failed` remain visible; a changed intent makes any older outcome `stale`, including an earlier conflict or failure, so a new decision is not judged by the result of the one it replaced. A missing receipt is not evidence of a successful apply. Interrupted or locally opted-out applies withdraw the previous Home confirmation. A client-specific command reports failures for that client and warns about other unresolved clients; explicit sync reports any unresolved failure and exits nonzero when intent or catalog discovery is deferred, even when cached off clients are already clean. Conflicts require an explicit `--overwrite-conflict` for that apply. When an apply fails unexpectedly after a write saved route policy or the Home's switch, the error says so and carries `desiredCommitted: true`. + +Without `--device`, a Home write changes the Home default and applies only that client locally through the same controller. For Codex, Claude Code, Grok and Claude Desktop it applies by writing the Home's switch, so `ocx route to codex off` turns Codex off on the Home and changes the default Children inherit. `--device home` (or `this` on the Home) decides the Home alone: for those four clients it writes only the Home's switch and leaves the Children's default unchanged, and `inherit` sets the Home's switch to the current default (the built-in default until one is saved); for other clients it records a Home override. A route write for another client, `ocx route to sync` and a plain `ocx sync` on the Home never change the Home's native switches. They only refresh what each switch already says, so they do not undo `ocx restore`, `ocx restore back`, turning the Messages API off or a dashboard toggle. Children inherit on their next sync tick. Explicit Home sync uses the existing identity-pinned SSH sync for Home-initiated links. With `--overwrite-conflict` (in `ocx route to` or `ocx sync`), Home runs that sync only on a Child whose OpenCodex is 2.100.0 or later, because an older one ignores the flag and would report a plain sync as success; any other Child is skipped with `child-ocx-lacks-overwrite`. A Child-initiated link keeps a bounded authenticated read watch; Home sync wakes it immediately and reports `pending` until the Child publishes an apply report. Without an active watcher, the result explicitly waits for the next tick. Conflict overwrite on a Child-initiated link requires the person at that Child to run `ocx route to sync --overwrite-conflict`; a wakeup grants no takeover permission. Local `ocx route to sync` always applies immediately. + +Aside `--accounts all` selects every account-backed profile on that device; `none` selects none. A list matches Aside **userId**, never numeric `u/N` slots, so different local slot layouts are safe. Model IDs are rendered from the device's catalog. Toggles preserve omitted account/model-name policy. Owned profiles missing from the Aside account manifest remain incomplete until their ownership is resolved; an unmanaged off client needs no account manifest. + +On a Child, `status` and `sync` work locally. Home-owned writes print the exact command to run on the Home. A person at the Child can opt out with `ocx route to aside off --local`; `ocx route to aside inherit --local` drops that opt-out. Local `on` is refused. The opt-out is reported as `source: local`. Home intent also wins over native commands run on a linked Child: `ocx restore` there turns Codex routing off only until the next sync reapplies the Home intent, about a minute later. Use `ocx route to codex off --local` to keep Codex off on that Child. A Child reconciles once a minute; each apply withdraws its previous confirmation before changing anything, so a status report sent during an apply can briefly show the Child as not applied until the next report. + +Existing owned integrations become Home defaults when migrating; unowned blocks are never inferred as consent. The native switch defaults are saved as described above, before any Home switch changes. Enrollment retains an explicit `connect --clients` selection as the initial device override; without an explicit selection, the Child inherits Home defaults and later changes. A subsequent Home device `on`, `off` or `inherit` supersedes that client's initial selection. No Child report changes Home policy. Native toggles, in the dashboard and through `ocx integrations native on|off`, change the Home's switch and run that client's own transition (including Claude Desktop's first-party switch), preserving Child defaults; they record no route intent for the Home. A Home override for one of these four clients saved by an earlier version is ignored and removed on the next route change. On a Child the CLI refuses and prints the Home command for that device. Retired device overrides are removed with their links. Claude Desktop first-party mode is preserved. It already routes through OpenCodex, so an enabled intent reports `current` without writing anything. An off intent reports a conflict naming the mode switch to run: `ocx integrations native claude-desktop off` on the Home, or `ocx claude desktop apply --gateway` on a Child followed by `ocx route to sync`. + +A Home write that decides a client the Home then cannot apply fails. `ocx route to claude-desktop off` on a first-party Home, with or without `--device home`, answers 409, reports that client's conflict and names the mode switch, and the CLI exits nonzero; the same happens when the Home's switch cannot be written. A default write still saves the Children's default. + +While linked, a Child writes its Home's effective intent into its own on/off switches for Codex, Claude Code, Claude Desktop and Grok; turning Claude Code off can also pin the Messages API setting. Before the first such change, the Child records the value each switch had on its own, including a switch that was never set. `ocx disconnect`, and an enrollment that fails, put those values back. If a failed enrollment cannot put them back, it keeps its service token and names the record it could not restore; fix the reported problem, then run `ocx disconnect`, which restores the switches and removes that token even though the machine is not connected. A switch you changed yourself while linked keeps your value. One limit: a switch you set to exactly the value the Home last applied cannot be told apart from the Home's write, so it is restored. Local opt-outs (`--local`) are your own choice on that Child and stay after disconnect. + +File integrations get the same round trip. At the first apply after linking, the Child records which file integrations it already had set up on its own (Aside profiles are not included). `ocx disconnect` keeps those that are still set up, sets up again the ones the Home turned off, and removes only the integrations the link added. A local opt-out, or a block you wrote yourself while linked, is kept. A restored integration first lists the models the Child last received from the Home; the next standalone start refreshes it from the local catalog. A Child that applied its Home's route intent before it ran a version that keeps this record has none, so `ocx disconnect` cannot tell which file integrations it had on its own: it removes the ones the link owns and does not set up again any the Home turned off. Set those up again after disconnecting. + +Disconnect removes owned keys and blocks while preserving later model, hook, project and Computer Use edits. Retry `ocx disconnect` to resume an interrupted disconnect, retaining `--keep-catalog` if the first attempt used it. For changed owned keys or evidenced legacy journals, `ocx disconnect --recover-config --yes` first saves a private current snapshot and then performs key-level recovery. It still refuses unknown ownership or an edited, unverified profile. Preserve the reported backup directory. Run `ocx service restart` after disconnect so a service-owned Child is replaced by standalone OpenCodex before linking again. + ## Troubleshooting When a step fails, the dashboard shows the reason and, when SSH reported one, a short sanitized hint from its last non-empty error line under the message. Remote-shell errors can appear there even when the remote shell emits non-UTF-8 text; OpenCodex removes terminal controls, link keys and URL queries and limits the hint's length. @@ -212,6 +251,8 @@ To prepare a build, run `cargo build --release --locked --manifest-path native/o Unlike a full `ocx` Child, a clean detached `ocx-child` host needs no pre-running background daemon before `ocx link add`: Home checks target-port availability and `~/.codex/config.toml` ownership before committing (`409 child_port_busy` if port `10100` is held by another process, or `409 child_external_provider_conflict` if another model provider or custom base URL already owns `config.toml`, leaving Child files untouched and rolling back any newly planted binary), pipes the link credential over SSH stdin, starts `ocx-child serve`, and confirms an authenticated `/v1/catalog` relay. Connected rows in `#remote` display a **Lightweight** / **Full install** badge and a **Home settings synced** / **Sync needed** parity badge. Running `ocx link revoke --link-id ` keeps the cached binary for fast re-attach, whereas `ocx link revoke --link-id --purge` (or `ocx-child detach --purge` on the Child) also deletes the planted binary from the managed cache directory. +An `ocx-child` Child refreshes the Home catalog through `ocx sync` and never reads route intent. `ocx route to status` shows it as `route intent unsupported (ephemeral-child-no-route-intent)`. A route write naming it with `--device`, and `ocx route to sync --device` for it, are refused with `409 route_intent_unsupported` before anything changes; `ocx route to sync --device all` reports it `unsupported` without contacting it. Enrolling one saves no route intent, and `ocx sync` sends it the plain catalog sync even with `--overwrite-conflict`. + Keep `OPENCODEX_HOME` and `CODEX_HOME` pointed at the same directories throughout the connection. If detach reports a restore or shutdown error, keep the Child's recovery files and resolve the error before retrying. ### Windows computers diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index db7efc81b07..fd544bb661c 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -76,6 +76,8 @@ OpenClaw에는 역할이 서로 다른 여러 설정이 있습니다. `OPENCLAW_ opencodex는 자체 환경에서 이 값을 읽습니다. 게이트웨이가 프로필이나 옮겨진 홈에서 실행된다면 opencodex도 같은 변수를 설정해 시작하세요. 그렇지 않으면 다른 설치를 올바르게 따라가게 됩니다. +CLI의 `ocx integration client enable|disable --client `는 대시보드 토글처럼 이 컴퓨터만 바꿉니다. route 의도를 쓰는 Remote Link Home에서는 이 변경을 Home 자신의 결정으로 기록하므로 `ocx sync`가 유지하고, 연결된 Child는 기본값을 그대로 따릅니다. Child가 상속하는 값을 바꾸려면 `ocx route to on|off`를 사용하세요. Remote Link Child에서는 일반 토글을 거부하고 그 기기용 Home 명령을 출력합니다. 해당 Child만 제외하려면 그곳에서 `ocx route to off --local`을 사용하세요. [Remote Link](/ko/guides/remote-link/)를 참고하세요. + ## 스위치가 아닌 다른 다섯 서피스 **API Keys**는 클라이언트가 아니라 opencodex 자체 자격 증명을 관리합니다. **Codex CLI**는 프록시 서비스가 직접 연결합니다. opencodex를 시작하면 적용되고 멈추면 네이티브 라우팅이 복원되므로 파일별 스위치가 없습니다. **Claude**는 자체 활성화 플래그와 Desktop의 Save/Apply 흐름을 유지하고, **Grok Build**는 모델을 선택한 뒤 적용하는 경계를 유지합니다. 모두 이 기능보다 먼저 있던 동작이며 바뀌지 않았습니다. **Cursor**는 아무 파일도 쓰지 않습니다. 탭에서 탐지 결과, 게이트웨이 값, 마지막 요청을 보여주고 나머지는 Cursor Private Inference 안에서 처리합니다. 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 21673c7cb45..c134fe8a127 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -71,6 +71,45 @@ ocx disconnect # 경량 모듈 Child인 경우 ~/.cache/opencodex/bin/ocx-chil 자식 연결을 끊으려면 자식에서 `ocx disconnect`를 실행합니다. 이 명령은 클라이언트 터널을 끊고 SSH를 통해 홈에서 링크를 폐기합니다. 홈에서 폐기하지 못하면 다음 문구를 출력합니다: `Home revoke failed; run ocx link revoke --link-id on the home.` +## 기기별 Route to + +Home의 Route to 의도는 Child에 기본으로 동기화됩니다. 각 기기는 자신의 설치된 클라이언트, 경로, 수신 포트, 제공 모델 카탈로그로 설정을 생성합니다. 우선순위는 기기 override > Home 기본값 > 내장 기본값(Codex·Claude Code만 켜짐)입니다. + +등록 시 클라이언트를 명시적으로 선택하지 않으면 Child는 Home 기본값과 이후 변경을 상속합니다. 클라이언트별 명령은 해당 클라이언트의 실패를 반환하고 다른 클라이언트의 미해결 충돌은 경고하며, 전체 sync는 모든 미해결 실패를 보고합니다. Claude Desktop first-party 모드는 이미 OpenCodex를 거치므로 켜기 의도는 아무것도 쓰지 않고 `current`로 표시합니다. 끄기 의도는 실행할 모드 전환 명령(Home에서는 `ocx integrations native claude-desktop off`, Child에서는 `ocx claude desktop apply --gateway` 후 `ocx route to sync`)을 알려 주는 충돌로 표시합니다. + +Home 자신에서 Codex, Claude Code, Grok, Claude Desktop은 Home의 켜기/끄기 스위치를 따릅니다. `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, Messages API 끄기, 에이전트 설정, 대시보드 토글이 바꾸는 바로 그 스위치입니다. 명시적인 `false`만 꺼짐이고 설정하지 않은 스위치는 켜짐입니다. Home 기본값과 기기 override는 Home에서 이 네 클라이언트를 결정하지 않습니다. 어떤 route 쓰기도 결정하고 있지 않은 Grok·Claude Desktop 스위치가 설정되지 않았거나 켜져 있으면 시작 시 동작과 같은 범위만 적용합니다. Grok은 시작 시 동기화 규칙을 따르고, Claude Desktop은 OpenCodex가 이미 소유한 gateway 프로필만 갱신하며 새로 만들거나 Desktop 모드를 바꾸지 않습니다. OpenCodex가 쓴 뒤 바뀐 소유 프로필은 갱신하지 않고 충돌로 보고합니다. OpenCodex가 소유한 적 없는 Desktop 라이브러리는 건드리지 않고 current로 보고하므로 전체 sync가 이 때문에 실패하지 않습니다. Desktop 프로필을 만들려면 `ocx route to claude-desktop on --device home`(또는 `ocx route to claude-desktop on`)을 실행하세요. 이것만으로 Child의 Grok·Claude Desktop이 켜지지는 않습니다. + +Child는 Home의 스위치를 읽지 않습니다. Child는 기기 override, 저장된 Home 기본값, 내장 기본값 순으로 따릅니다. Home은 이 네 클라이언트의 기본값을 한 번만 저장합니다. Codex와 Claude Code는 저장 시점의 Home 스위치 값(명시적으로 끄지 않았다면 켜짐)으로, Grok과 Claude Desktop은 꺼짐으로 저장합니다. Home 기본값 쓰기(`--device` 없는 `ocx route to on|off`)와 Child 등록은 항상 저장합니다. Home에 링크가 있으면 Home 전용 작업이 스위치를 바꾸기 전에도 저장합니다. 링크 리스너를 시작할 때, 그리고 `ocx restore`, `ocx restore back`, `ocx claude desktop apply`, Messages API 끄기, Claude 토글, native 토글, `--device home` route 쓰기 안에서입니다. Home 전용 통합 변경(대시보드 토글, `ocx integration client enable|disable`, 통합 기록에서의 되돌리기, Aside 프로필 변경) 전에는 링크가 있는 Home이 Child가 상속하는 모든 기본값을 파일 통합까지 포함해 저장하며, 링크 리스너도 시작할 때 같은 일을 합니다. 그래서 이 네 기본값만 저장하던 버전에서 업그레이드한 Home도 무엇이 바뀌기 전에 나머지를 고정합니다. 경량 `ocx-child` 링크는 여기서 링크로 세지 않습니다. route 의도를 읽지 않으므로 경량 링크만 있는 Home에는 보호할 Child가 없습니다. 링크가 없는 Home에서는 이 Home 전용 명령이 기본값을 저장하지 않으므로, 나중에 등록한 Child는 등록 시점의 스위치 값을 상속합니다. 단, Home 기본값 쓰기로 기본값이 이미 저장되어 있으면 그 값을 상속합니다. 이전 기본값 쓰기로 이미 route 의도를 쓰고 있는 Home이라면, Home 전용 통합 변경은 `ocx sync`가 유지하도록 Home 자신의 행으로 기록되며, 이 행은 Child가 상속하지 않습니다. 링크 리스너가 시작할 때 저장에 실패하면(예: 다른 프로세스가 설정 잠금을 잡고 있을 때) 백그라운드에서 계속 다시 시도합니다. `ocx route to status`나 Child가 route 의도를 읽는 것은 아무것도 저장하지 않습니다. 그 뒤 Child가 상속하는 값은 Home 기본값 쓰기로만 바뀝니다. 위의 Home 전용 명령은 Home만 바꿉니다. 저장된 route 의도 파일(OpenCodex 설정 디렉터리의 `route-intent.json`)을 읽을 수 없으면, 링크가 있는 Home의 이 명령들과 모든 `ocx route to` 변경은 아무것도 바꾸지 않고 거부하며, 고치거나 지울 파일을 알려 줍니다. 링크가 없는 Home에서는 native 토글, `ocx restore`, Home 전용 통합 변경이 그대로 동작합니다. 이때 통합 변경은 아무것도 기록하지 않으며, `ocx sync`도 이 파일을 읽지 못하므로 그 변경을 되돌릴 수 없습니다. + +`ocx route to on|off --device home`이 Codex, Claude Code, Grok, Claude Desktop을 결정하면 먼저 그 클라이언트의 스위치를 저장한 뒤 적용합니다. 적용이 충돌이나 실패로 끝나면(예: 외부 Codex provider, 불완전한 Grok 블록, 바뀐 Claude Desktop 프로필) 명령은 실패하고 스위치가 저장되었는지 알려 줍니다. "The Home's grok switch was saved, but applying it ended in conflict"는 스위치가 바뀌었다는 뜻이고, "The Home did not apply"는 아무것도 바뀌지 않았다는 뜻입니다. first-party 모드의 Claude Desktop은 저장하기 전에 거부합니다. + +이 Home이 route 의도를 쓰기 시작하면(저장된 기본값이나 Home override가 있으면) `ocx sync`는 Home의 모든 클라이언트에 route 의도를 적용합니다. 그래서 Home 전용 통합 변경은 그 클라이언트에 대한 이 Home의 결정으로 기록되고 `ocx sync`가 유지합니다. 대시보드 토글, `ocx integration client enable|disable`(같은 Home 전용 결정이며, Child가 상속하는 값은 `ocx route to on|off`로 바꿉니다), 통합 기록에서의 되돌리기, Aside 프로필 변경이 여기에 해당합니다. Aside는 변경 뒤 Home이 라우팅하는 모든 프로필의 안정적인 Aside userId로 기록합니다. 링크된 Child는 기본값을 그대로 따릅니다. Aside의 `accounts.json`에 안정적인 userId가 없는 프로필이 있으면 이런 Home의 Aside 프로필 변경은 아무것도 바꾸기 전에 거부합니다(`route_intent_aside_unresolved`). 명세를 고치거나 `ocx route to aside on|off --device home`으로 Home의 Aside를 직접 결정하세요. route 의도를 쓰기 전부터 이런 프로필을 라우팅하던 Home(예: 링크가 있는 상태로 업그레이드한 경우)에서도 `ocx sync`는 계속 동작합니다. Aside를 결정하거나 명세를 고치기 전까지 sync는 Aside를 그대로 두고 나머지 클라이언트만 적용하며, 해결 방법을 알려 줍니다. + +```sh +ocx route to status --device all --json +ocx route to aside on --accounts all +ocx route to aside on --device my-child --accounts stable-aside-user-id +ocx route to pi off --device my-child +ocx route to pi inherit --device my-child +ocx route to sync --device my-child +``` + +`--device` 없는 Home 쓰기는 기본값을 바꾸고 Home에는 지정한 클라이언트만 같은 적용 경로로 반영합니다. Codex, Claude Code, Grok, Claude Desktop은 Home의 스위치를 써서 반영하므로 `ocx route to codex off`는 Home의 Codex를 끄고 Child가 상속하는 기본값도 바꿉니다. `--device home`(Home에서는 `this`)은 Home만 결정합니다. 이 네 클라이언트는 Home의 스위치만 쓰고 Child 기본값은 그대로 두며, `inherit`는 Home의 스위치를 현재 기본값(저장된 기본값이 없으면 내장 기본값)으로 맞춥니다. 다른 클라이언트는 Home override를 기록합니다. 다른 클라이언트에 대한 route 쓰기, `ocx route to sync`, Home에서 실행한 일반 `ocx sync`는 Home의 native 스위치를 바꾸지 않고 각 스위치가 가리키는 상태만 갱신하므로 `ocx restore`, `ocx restore back`, Messages API 끄기, 대시보드 토글을 되돌리지 않습니다. Child는 다음 동기화 tick에 반영합니다. Home이 추가한 Child는 명시적 sync로 즉시 적용합니다. `--overwrite-conflict`(`ocx route to`나 `ocx sync`)를 쓰면 Home은 OpenCodex 2.100.0 이상인 Child에서만 그 sync를 실행합니다. 이전 버전은 이 플래그를 무시하고 일반 sync를 성공으로 보고하기 때문이며, 그런 Child는 `child-ocx-lacks-overwrite`로 건너뜁니다. Child가 먼저 연결한 링크는 인증된 읽기 watch를 유지하며, Home sync가 이를 즉시 깨웁니다. 적용 보고가 도착하기 전에는 `pending`으로 표시하고, 활성 watch가 없으면 다음 tick을 기다린다고 안내합니다. 이 링크의 충돌 덮어쓰기는 Child에서 사람이 `ocx route to sync --overwrite-conflict`로 허용해야 합니다. Child의 로컬 sync는 즉시 적용합니다. + +Aside 계정은 로컬 `u/N` 번호가 아닌 **userId**로 선택합니다. `all`은 해당 기기의 모든 계정 프로필, `none`은 선택 없음입니다. Child의 일반 쓰기는 실행할 정확한 Home 명령을 안내하며 거부합니다. Child 사용자는 `ocx route to aside off --local`로 자신의 기기만 제외하고 `inherit --local`로 복귀할 수 있습니다. 로컬 `on`은 허용하지 않습니다. 연결된 Child에서 실행한 native 명령보다도 Home 의도가 우선합니다. 그곳의 `ocx restore`는 약 1분 뒤 다음 동기화가 Home 의도를 다시 적용할 때까지만 Codex 라우팅을 끕니다. 해당 Child에서 Codex를 계속 끄려면 `ocx route to codex off --local`을 사용하세요. Child는 1분마다 조정하며, 적용할 때마다 무엇을 바꾸기 전에 이전 적용 확인을 철회하므로 적용 중에 보낸 상태 보고는 다음 보고 전까지 잠시 미적용으로 보일 수 있습니다. 적용 중단이나 로컬 제외가 발생하면 Home의 이전 적용 확인을 철회합니다. 의도나 카탈로그 조회가 지연된 명시적 로컬 sync는 캐시된 off 상태가 정리되어 있어도 0이 아닌 종료 코드를 반환합니다. 토글에서 생략한 계정·모델 이름 정책은 유지됩니다. 소유 기록이 있는데 Aside 계정 명세에서 사라진 프로필은 소유권을 해결하기 전까지 완료로 표시하지 않습니다. + +상태 JSON은 `ocx.route-to/1`이며 의도·출처·적용 결과를 구분합니다. 출처는 `builtin`, `default`, `device`, `local`(Child 제외)이며, Home의 Codex·Claude Code·Grok·Claude Desktop 행은 Home 자신의 스위치를 뜻하는 `native`로 표시합니다. `not_installed`, `conflict`, `failed`는 성공으로 처리하지 않습니다. 의도가 바뀌면 이전 충돌이나 실패를 포함한 이전 결과는 모두 `stale`이 됩니다. 충돌 덮어쓰기는 해당 실행의 명시적 `--overwrite-conflict`가 필요합니다. 쓰기가 route 정책이나 Home 스위치를 저장한 뒤 적용이 예상치 못하게 실패하면 오류가 그 사실을 알리고 `desiredCommitted: true`를 담습니다. 경량 `ocx-child` Child는 `ocx sync`로 Home 카탈로그만 갱신하며 route 의도를 읽지 않습니다. `ocx route to status`는 이 Child를 `route intent unsupported (ephemeral-child-no-route-intent)`로 표시하고, 이 Child를 `--device`로 지정한 route 쓰기와 `ocx route to sync`는 아무것도 바꾸기 전에 `409 route_intent_unsupported`로 거부하며, `ocx route to sync --device all`은 접속하지 않고 `unsupported`로 보고합니다. 등록할 때 route 의도를 저장하지 않으며, `ocx sync`는 `--overwrite-conflict`가 있어도 일반 카탈로그 sync를 보냅니다. 자세한 동작은 [영문 가이드](/guides/remote-link/#route-clients-per-device)를 참고하세요. + +기존에 소유권 기록이 있는 연동은 Home 기본값으로 이전합니다. 명시적인 `connect --clients` 선택은 초기 기기 override로 유지하며, Home의 해당 기기 `on|off|inherit`가 그 선택을 대체합니다. Child 보고는 Home 정책을 변경하지 않습니다. + +Home이 결정한 클라이언트를 Home에 적용하지 못하면 그 쓰기는 실패합니다. first-party 모드인 Home에서 `ocx route to claude-desktop off`를 실행하면 `--device home` 여부와 관계없이 409로 응답하고, 해당 클라이언트의 충돌과 모드 전환 명령을 알려 주며, CLI는 0이 아닌 코드로 끝납니다. Home의 스위치를 쓰지 못한 경우도 같습니다. 기본값 쓰기라면 Child 기본값은 그대로 저장됩니다. + +연결된 Child는 Home의 실효 의도를 자신의 Codex, Claude Code, Claude Desktop, Grok 켜기/끄기 스위치에 기록합니다. Claude Code를 끄면 Messages API 설정도 고정될 수 있습니다. Child는 처음 바꾸기 전에 각 스위치의 독립형 값을, 설정되지 않았던 상태까지 포함해 기록합니다. `ocx disconnect`와 실패한 등록은 그 값을 되돌립니다. 실패한 등록이 값을 되돌리지 못하면 서비스 토큰을 유지하고 복원하지 못한 기록 파일을 알려 줍니다. 보고된 문제를 해결한 뒤 `ocx disconnect`를 실행하면 연결되지 않은 상태에서도 스위치를 복원하고 그 토큰을 제거합니다. 연결 중 사용자가 직접 바꾼 스위치는 그 값을 유지합니다. 다만 Home이 마지막으로 적용한 값과 정확히 같은 값으로 직접 설정한 스위치는 Home의 기록과 구별할 수 없어 복원됩니다. 로컬 제외(`--local`)는 해당 Child 사용자의 선택이므로 disconnect 후에도 유지됩니다. + +파일 연동도 같은 방식으로 되돌립니다. 링크 후 첫 적용 때 Child는 스스로 설정해 둔 파일 연동을 기록합니다(Aside 프로필은 제외). `ocx disconnect`는 아직 설정된 연동은 유지하고, Home이 끈 연동은 다시 설정하며, 링크가 추가한 연동만 제거합니다. 로컬 제외나 연결 중 직접 작성한 블록은 유지합니다. 복원된 연동은 처음에는 Child가 Home에서 마지막으로 받은 모델 목록을 쓰며, 다음 독립형 시작 때 로컬 카탈로그로 갱신됩니다. 이 기록을 남기는 버전을 실행하기 전에 이미 Home의 route 의도를 적용한 Child에는 기록이 없으므로, `ocx disconnect`는 링크가 소유한 연동만 제거하고 Home이 끈 연동을 다시 설정하지 않습니다. disconnect 후 직접 다시 설정하세요. + +disconnect는 소유한 키만 복구하므로 이후 편집한 모델·훅·신뢰 프로젝트·Computer Use 설정이 남습니다. 중단된 작업은 `ocx disconnect`로 재개하며 최초 사용한 `--keep-catalog`를 유지하세요. 소유 키 충돌이나 소유권 근거가 남은 이전 저널은 `ocx disconnect --recover-config --yes`로 현재 상태를 비공개 백업한 뒤 키 단위로 복구합니다. 소유권을 확인할 수 없거나 프로필이 편집되어 검증할 수 없으면 계속 거부합니다. 백업 경로를 보존하고, disconnect 후 `ocx service restart`로 독립형 런타임을 시작한 다음 다시 연결하세요. + ## 보안 Child는 링크를 통해 Home 컴퓨터의 프로바이더와 프로바이더 인증 정보를 사용합니다. Home은 Child마다 별도의 링크 키를 만들며, 링크를 제거하면 그 키를 폐기합니다. 확인 전에 호스트 지문을 비교하여 잘못된 컴퓨터나 변경된 호스트 키를 실수로 허용하지 않도록 하세요. Tailscale identity로 발급된 대시보드 세션은 머신 링크를 관리할 수 없습니다. Child에서 키는 OpenCodex 안에만 머뭅니다. Codex나 Claude Code가 Child에서 보내는 인증 정보는 Home으로 전달되지 않으며, Child에서 `127.0.0.1:`에 접근하는 프로그램은 키 없이 Home을 사용합니다. 이는 독립형 설치가 로컬 프로그램에 주는 것과 같은 신뢰 수준입니다. 다른 사이트의 웹 페이지는 거부됩니다. @@ -89,6 +128,8 @@ ocx link reconnect --link-id [--json] 비대화형 실행(TTY 없음)에서는 `--expect-fingerprint`가 필수입니다. 대화형 터미널에서는 옵션을 생략할 수 있지만, 제시된 지문을 명시적으로 확인해야 합니다. +대시보드와 `ocx integrations native on|off`의 native 토글은 Home의 스위치를 바꾸고 해당 클라이언트의 전환(Claude Desktop first-party 전환 포함)을 실행하여 Child 기본값을 유지하며, Home에 대한 route 의도는 기록하지 않습니다. 이전 버전이 저장한 이 네 클라이언트의 Home override는 무시하고 다음 route 변경 때 제거합니다. Child에서 CLI는 거부하고 해당 기기의 Home 명령을 출력합니다. 링크를 폐기하면 그 기기의 override도 정리합니다. Claude Desktop의 first-party 모드는 보존합니다. 켜기 의도는 쓰기 없이 `current`이며, 끄기 의도는 모드 전환을 마칠 때까지 conflict로 표시합니다. + ## 관련 가이드 - [Remote Hub 배포](/ko/guides/remote-hub/) diff --git a/native/ocx-child/src/parity.rs b/native/ocx-child/src/parity.rs index 3d5be5ad6a1..ff2ea5d3217 100644 --- a/native/ocx-child/src/parity.rs +++ b/native/ocx-child/src/parity.rs @@ -69,7 +69,11 @@ fn json_safe_nonneg_int(val: &serde_json::Value) -> Option { None } -/// Strict exact-key parser matching `parseLinkChildParityReport` in `src/link/child-parity.ts`. +/// Strict exact-key parser for the five-key base report `ocx-child` sends, capped at +/// `LINK_CHILD_PARITY_MAX_BYTES` (1 KiB). It is narrower than the Home's TypeScript parser +/// (`parseLinkChildParityReport` in `src/link/child-parity.ts`), which also accepts the extended +/// report carrying `appliedRouteIntentDigest` and `routeClientOutcomes` and caps the body at 8 KiB. +/// `ocx-child` never reads route intent, so it never sends those fields. pub fn parse_link_child_parity_report(raw_json: &str) -> Option { if raw_json.len() > LINK_CHILD_PARITY_MAX_BYTES { return None; diff --git a/scripts/test-layout/layout-route-intent.json b/scripts/test-layout/layout-route-intent.json new file mode 100644 index 00000000000..52cc2101adb --- /dev/null +++ b/scripts/test-layout/layout-route-intent.json @@ -0,0 +1,60 @@ +{ + "cli-link-route-intent.test.ts": "cli", + "cli-route-to-aliases.test.ts": "cli", + "cli-route-to-confirmation.test.ts": "cli", + "cli-route-to.test.ts": "cli", + "client-disconnect-config-recovery.test.ts": "clients", + "client-disconnect-edited-codex.test.ts": "clients", + "client-disconnect-field-snapshot.test.ts": "clients", + "codex-disconnect-fields.test.ts": "codex-integration", + "codex-disconnect-owned-recovery.test.ts": "codex-integration", + "codex-journal-provider-reserialization.test.ts": "codex-integration", + "home-route-intent-upgrade.test.ts": "clients", + "link-home-route-intent.test.ts": "clients", + "link-route-intent.test.ts": "server", + "management-route-intent-native-errors.test.ts": "server", + "management-route-intent-native-review.test.ts": "server", + "route-intent-aside.test.ts": "clients", + "route-intent-claude-surface.test.ts": "clients", + "route-intent-codex-connect-disabled.test.ts": "clients", + "route-intent-codex-transitions.test.ts": "clients", + "route-intent-connect-recovery.test.ts": "clients", + "route-intent-connected-review.test.ts": "clients", + "route-intent-connected.test.ts": "clients", + "route-intent-desktop.test.ts": "clients", + "route-intent-file-originals.test.ts": "clients", + "route-intent-home-consent.test.ts": "clients", + "route-intent-local-receipt.test.ts": "clients", + "route-intent-materializer.test.ts": "clients", + "route-intent-native-originals.test.ts": "clients", + "route-intent-native-review.test.ts": "clients", + "route-intent-offline-mode-switch.test.ts": "clients", + "route-intent-overwrite.test.ts": "clients", + "route-intent-parity-fallback.test.ts": "clients", + "route-intent-parity-withdrawal.test.ts": "clients", + "route-intent-parity.test.ts": "clients", + "route-intent-reporter.test.ts": "clients", + "route-intent-runtime-stop.test.ts": "clients", + "route-intent-scheduler.test.ts": "clients", + "route-intent-state.test.ts": "clients", + "route-intent-watch-boundary.test.ts": "server", + "route-intent-watch-client.test.ts": "clients", + "route-intent-watch-waiters.test.ts": "server", + "route-intent.test.ts": "clients", + "route-to-child-defaults.test.ts": "routing", + "route-to-home-live-flags.test.ts": "routing", + "link-pinned-sync-overwrite.test.ts": "clients", + "route-to-ephemeral.test.ts": "server", + "route-to-home-decisions.test.ts": "routing", + "cli-sync-fanout-ephemeral.test.ts": "cli", + "route-to-home-native-truth.test.ts": "routing", + "route-to-home-off-review.test.ts": "routing", + "route-to-live-settings.test.ts": "server", + "route-to-policy-review.test.ts": "server", + "route-to-retirement-review.test.ts": "server", + "route-to-routes.test.ts": "server", + "route-to-sync-child.test.ts": "routing", + "service-disconnected-client-restart.test.ts": "service", + "service-restart-registration-guard.test.ts": "service", + "service-scheduler-restart-cleanup.test.ts": "service" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index aed2989c321..d9a7b164e61 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], + "explicitShards": ["layout-route-intent.json", "layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], "explicit": { "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index d49db6cfa0b..f01318a08f4 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -28,7 +28,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin | [lifecycle](01_surface_lifecycle.md) | 14 | | [providers-models](01_surface_providers-models.md) | 50 | | [accounts](01_surface_accounts.md) | 43 | -| [agents-routing](01_surface_agents-routing.md) | 68 | +| [agents-routing](01_surface_agents-routing.md) | 71 | | [integrations](01_surface_integrations.md) | 41 | | [observe-system](01_surface_observe-system.md) | 99 | | [access-remote](01_surface_access-remote.md) | 34 | @@ -1527,8 +1527,20 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_lab.md#ocx-lab-run) +### `ocx route to` + +[State-changing task](01_surface_agents-routing.md#ocx-route-to) + +### `ocx route to status` + +[Read-oriented task](01_surface_agents-routing.md#ocx-route-to-status) + +### `ocx route to sync` + +[State-changing task](01_surface_agents-routing.md#ocx-route-to-sync) + ## Counts -- declared capabilities: 370 -- of those, state-changing: 228 +- declared capabilities: 373 +- of those, state-changing: 230 - 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 3a9df271275..bdcc876c627 100644 --- a/skills/ocx/references/01_surface_access-remote.md +++ b/skills/ocx/references/01_surface_access-remote.md @@ -81,6 +81,7 @@ State-changing: yes. JSON mode: `payload`. | Flag | Value | Meaning | |---|---|---| +| `--clients` | string | Initial device selection; defaults to Codex and Claude Code. Other Home client defaults remain inherited. | | `--alias` | string | SSH host alias for the linked machine. | | `--tunnel-port` | number | Remote loopback port for the reverse tunnel. | | `--json` | boolean | Emit the issue result as JSON. | @@ -328,7 +329,7 @@ State-changing: yes. JSON mode: `payload`. ### `ocx disconnect` -Usage: `ocx disconnect [--keep-catalog] [--json] | ocx disconnect --orphaned-token --yes [--json]` +Usage: `ocx disconnect [--keep-catalog] [--recover-config --yes] [--json] | ocx disconnect --orphaned-token --yes [--json]` Disconnect a client or preserve orphaned token files for recovery. @@ -339,8 +340,9 @@ Drives no management route. | Flag | Value | Meaning | |---|---|---| | `--keep-catalog` | boolean | Keep the connected client's catalog during ordinary disconnect. | +| `--recover-config` | boolean | With --yes, snapshot current Codex artifacts and recover only evidenced owned keys; unknown ownership still refuses. | | `--orphaned-token` | boolean | Preserve unowned service-token files in a private backup when disconnected. | -| `--yes` | boolean | Explicitly confirm orphan-token recovery; requires --orphaned-token. | +| `--yes` | boolean | Explicitly confirm --recover-config or --orphaned-token recovery. | | `--json` | boolean | Emit the local result and backup location as JSON. | - Orphan recovery requires a disconnected durable state, no pending connect or disconnect, no Hub or admission-secret use, no installed or unverified service, and verified absence of a client runtime. It preserves rather than deletes token files and does not establish their ownership. diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md index b30b7d6f50c..10187ebd5ec 100644 --- a/skills/ocx/references/01_surface_agents-routing.md +++ b/skills/ocx/references/01_surface_agents-routing.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: 68. +Declared capabilities: 71. ### `ocx agent subagents force` @@ -1339,3 +1339,69 @@ State-changing: no. JSON mode: `payload`. | `--json` | boolean | Emit the result as JSON. | - Alias of observe memory. + +### `ocx route to` + +Usage: `ocx route to [--device ] [--accounts ] [--model-names ] [--overwrite-conflict] [--local] [--json]` + +Set Home-default or per-device route intent, or opt out locally on a Child. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| PUT | `/api/route-to` | + +| Flag | Value | Meaning | +|---|---|---| +| `--device` | string | Home: alias, id, all or this. Child: this or its own link id only. | +| `--json` | boolean | Emit the ocx.route-to/1 status matrix. | +| `--overwrite-conflict` | boolean | Explicitly permit replacing conflicting managed blocks. | +| `--local` | boolean | Child only: opt out with off, or clear the opt-out with inherit. | +| `--accounts` | string | Aside on only: all, none, or comma-separated stable userIds; never u/N slots. | +| `--model-names` | string | Hermes/Command Code on only: alias or routed model names. | + +- Clients: codex, claude, claude-desktop, grok and the supported file integrations. Without --device a Home updates its default and materializes itself; all is only for status/sync. +- inherit requires --device unless clearing a Child --local opt-out. Child writes print the exact Home command with the Child link id; --local supports off/inherit only. --overwrite-conflict applies only to on or sync. +- On the Home, codex, claude, grok and claude-desktop follow the Home's own native switches: a Home default or --device home write for them writes that switch (Home inherit sets it to the default). + +### `ocx route to status` + +Usage: `ocx route to status [--device ] [--json]` + +Read per-device route intent, its source and actual client apply outcomes. + +State-changing: no. JSON mode: `payload`. + +| Method | Route | +|---|---| +| GET | `/api/route-to` | + +| Flag | Value | Meaning | +|---|---|---| +| `--device` | string | Home: alias, id, all or this. Child: this or its own link id only. | +| `--json` | boolean | Emit the ocx.route-to/1 status matrix. | + +- On a Child this reads local receipts without a Home management relay. Intent and applied state are distinct; null applied means no recorded outcome. +- Sources: builtin, default, device, local (Child opt-out) and native (a Home row governed by the Home's own switch). An outcome recorded for an earlier intent reads stale. + +### `ocx route to sync` + +Usage: `ocx route to sync [--device ] [--overwrite-conflict] [--json]` + +Reapply route intent on selected devices, or locally on a Child. + +State-changing: yes. JSON mode: `payload`. + +| Method | Route | +|---|---| +| POST | `/api/route-to/sync` | + +| Flag | Value | Meaning | +|---|---|---| +| `--device` | string | Home: alias, id, all or this. Child: this or its own link id only. | +| `--overwrite-conflict` | boolean | Explicitly permit replacing conflicting managed blocks. | +| `--json` | boolean | Emit the ocx.route-to/1 status matrix. | + +- No conflict retry or implicit overwrite. Child sync calls its local materializer and sends no Home management write. +- Home sync never changes the Home's native switches for codex, claude, grok or claude-desktop; it refreshes what each switch says. diff --git a/skills/ocx/references/01_surface_integrations.md b/skills/ocx/references/01_surface_integrations.md index 067e554644d..f7e55100a69 100644 --- a/skills/ocx/references/01_surface_integrations.md +++ b/skills/ocx/references/01_surface_integrations.md @@ -192,7 +192,7 @@ State-changing: yes. JSON mode: `payload`. |---|---|---| | `--json` | boolean | Emit the client rows or toggle result as JSON. | -- Each toggle writes the selected client configuration through its runtime owner. Refused disables remain failures. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor. +- Each on/off writes the Home's native switch, recording no route intent for the Home, and applies it through the client's runtime owner, including Claude Desktop's first-party transition; Children keep inheriting their saved default. Refused disables remain failures. On a Child it is refused with the exact Home command. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor. ### `ocx integration client` @@ -228,7 +228,7 @@ State-changing: yes. JSON mode: `payload`. | `--confirm-drift` | boolean | Explicitly allow replacing edits made after the snapshot. | - status/show/list reads all clients, one client or Aside profiles. history/journal reads rollback records; expired snapshots stay visible. -- enable/disable requires --client; an omitted Aside --profile toggles all profiles. --overwrite-conflict applies only to enable. +- enable/disable requires --client; an omitted Aside --profile toggles all profiles. Each toggle is this Home's own decision, like the dashboard: once the Home uses route intent it is recorded so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). --overwrite-conflict applies only to enable. - restore requires --op (alias --op-id); --client requires --profile and only Aside supports that profile selector. --confirm-drift is an explicit user waiver, never auto-retried. Use client preview or restore --preview to inspect a change, history remove --op ID --yes to retire a journal row, and sync --client aside to refresh managed profiles. ### `ocx grok status` @@ -417,7 +417,7 @@ State-changing: yes. JSON mode: `payload`. | `--reasoning-default` | string | Repeat MODEL=EFFORT to replace the full Droid defaults map; exact model IDs, duplicate keys refused. | | `--clear-reasoning-defaults` | boolean | Droid apply/overwrite only: send an empty map, distinct from omitted inherited defaults. | -- An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned. +- A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned. - Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal. - Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview. - Droid map omission preserves inheritance; supplied entries replace the full map, clear sends {}. Only Droid apply/overwrite accepts these options. @@ -443,7 +443,7 @@ State-changing: yes. JSON mode: `payload`. | `--json` | boolean | Emit the result as JSON. | | `--plan-fingerprint` | string | Optional versioned token from a matching preview; stale intent is refused without retry. | -- An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned. +- A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned. - Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal. - Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview. diff --git a/skills/ocx/references/01_surface_lifecycle.md b/skills/ocx/references/01_surface_lifecycle.md index ce7372e8b50..9d4e6edbac6 100644 --- a/skills/ocx/references/01_surface_lifecycle.md +++ b/skills/ocx/references/01_surface_lifecycle.md @@ -85,7 +85,7 @@ Drives no management route. ### `ocx sync` -Usage: `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +Usage: `ocx sync [--expect-link | --expect-hub-key ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` Synchronize client catalogs, including Aside profiles through the running server's mutation owner. @@ -97,6 +97,8 @@ State-changing: yes. JSON mode: `payload`. | Flag | Value | Meaning | |---|---|---| +| `--overwrite-conflict` | boolean | Allow conflicting route materialization only for this explicit sync. | +| `--overwrite-client` | string | Restrict takeover to one client; requires --overwrite-conflict. | | `--local-only` | boolean | Do not fan out the Home sync to linked Children. | | `--json` | boolean | Emit the sync fan-out result as JSON. | | `--restart-codex` | boolean | Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog, cache, or changed configuration write, on macOS, Linux and Windows. | diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 25d0f85e509..3b91d3b26c4 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -1,6 +1,6 @@ import { readClientConnectionState } from "../client/state"; import { readServiceApiTokenState, readTokenBackupState } from "../lib/service-secrets"; -import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { assertClientLifecycleHeld, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesktopStore } from "./desktop-remote-store"; import { canonicalDirectory } from "./desktop-remote-store-io"; import { @@ -571,6 +571,7 @@ export function removeDesktop3pStandardPivot( options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null; unlink?: (path: string) => void; lifecycleLockDeps?: ClientLifecycleLockDeps; + lifecycleHeld?: ClientLifecycleHeld; /** * The desired-state guard below exists for OFF flows racing a concurrent enable. A * mode switch (gateway → first-party) removes the profile while the integration stays @@ -581,7 +582,7 @@ export function removeDesktop3pStandardPivot( ): Desktop3pRemovalResult { const libraryPath = resolveDesktop3pConfigLibraryPath(options); try { - return withClientLifecycleSync(held => withConfigMutationLockSync(() => { + const remove = (held: ClientLifecycleHeld): Desktop3pRemovalResult => withConfigMutationLockSync(() => { const connection = readClientConnectionState(); if (connection.kind === "invalid" || connection.kind === "mismatched") { return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: `desktop_client_state_${connection.kind}` }; @@ -621,7 +622,9 @@ export function removeDesktop3pStandardPivot( return result.ok ? { ok: true, changed: result.changed, kind: result.changed ? "removed" : "noop", libraryPath } : { ok: false, changed: result.changed, kind: result.reason === "cleanup_pending" ? "cleanup_incomplete" : "unsafe", reason: result.reason, libraryPath }; - }), options.lifecycleLockDeps); + }); + if (options.lifecycleHeld) { assertClientLifecycleHeld(options.lifecycleHeld); return remove(options.lifecycleHeld); } + return withClientLifecycleSync(remove, options.lifecycleLockDeps); } catch (error) { return { ok: false, changed: false, kind: "write_failed", libraryPath, reason: desktopMutationFailureReason(error) }; } @@ -724,9 +727,10 @@ export function writeDesktop3pConfig( nativeContextCap?: NativeContextLimitsInput, lifecycleLockDeps?: ClientLifecycleLockDeps, refreshOnly?: { appliedFingerprint: string; admit: () => boolean }, + lifecycleHeld?: ClientLifecycleHeld, ): { written: boolean; path: string; reason?: string; fingerprint?: string } { try { - return withClientLifecycleSync(() => withConfigMutationLockSync(() => { + const write = () => withConfigMutationLockSync(() => { const connection = readClientConnectionState(); if (connection.kind === "invalid" || connection.kind === "mismatched") { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: `desktop_client_state_${connection.kind}` }; @@ -785,7 +789,9 @@ export function writeDesktop3pConfig( generateDesktop3pConfig(destination.origin, nativeSlugs, routedModels, gatewayKey, mode, profile, nativeContextCap, desktop3pModelOptions(latest.config.claudeCode)) ), requiredId && refreshOnly ? { requiredId, expectedFingerprint: refreshOnly.appliedFingerprint, admit: refreshOnly.admit } : undefined); - }), lifecycleLockDeps); + }); + if (lifecycleHeld) { assertClientLifecycleHeld(lifecycleHeld); return write(); } + return withClientLifecycleSync(write, lifecycleLockDeps); } catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; } } diff --git a/src/claude/desktop-gateway-state.ts b/src/claude/desktop-gateway-state.ts index 322970c0560..d9b1d4c373b 100644 --- a/src/claude/desktop-gateway-state.ts +++ b/src/claude/desktop-gateway-state.ts @@ -25,10 +25,12 @@ export function persistCommittedDesktopGateway( snapshot: OcxConfig, profile: DesktopProfile | undefined, fingerprint: string | undefined, + admit?: (current: OcxConfig) => boolean, ): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" | "unavailable" } { const appliedAt = new Date().toISOString(); try { const outcome = mutatePersistedConfig(current => { + if (admit && !admit(current)) return { changed: false, value: null }; if (JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(profile)) { return { changed: false, value: null }; } diff --git a/src/claude/desktop-remote-store-artifact.ts b/src/claude/desktop-remote-store-artifact.ts index ecefac46cc6..cc79200d22a 100644 --- a/src/claude/desktop-remote-store-artifact.ts +++ b/src/claude/desktop-remote-store-artifact.ts @@ -5,7 +5,7 @@ import { readServiceApiTokenState, readTokenBackupState, serviceApiTokenFingerpr import { profilePath, type Desktop3pMetadata } from "./desktop-3p-library"; import { StoreIO, metadata, storePaths, MAX_DESKTOP_METADATA_ENTRIES, type JsonFile } from "./desktop-remote-store-io"; import { - DesktopStoreError, digest, origin, parseBaseline, parseDisconnect, parseOwner, parseState, projection, projectionHash, mergeProjection, + DesktopStoreError, canonical, digest, origin, parseBaseline, parseDisconnect, parseOwner, parseState, projection, projectionHash, mergeProjection, sameOwner, type ArtifactPending, type Baseline, type DesktopRemoteOwner, type Projection, type StoreState, } from "./desktop-remote-store-state"; @@ -125,7 +125,7 @@ export function establish(io: StoreIO, owner: DesktopRemoteOwner, known: readonl return bundle; } -export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: ArtifactPending["kind"], tokenFingerprint: string, desiredSelection?: string | null): void { +export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: ArtifactPending["kind"], tokenFingerprint: string, desiredSelection?: string | null, overwriteConflict = false): void { const state = bundle.state!; const targetPath = profilePath(bundle.paths.library, state.targetId); let current = io.read(targetPath); @@ -143,7 +143,18 @@ export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: Ar if (pending.kind !== kind || pending.after !== after || pending.tokenFingerprint !== tokenFingerprint || (before !== pending.before && before !== pending.after)) throw new DesktopStoreError("conflict"); if (kind === "apply" && selection !== pending.beforeSelection && selection !== pending.afterSelection) throw new DesktopStoreError("conflict"); - } else if (before !== state.lastProjectionHash) throw new DesktopStoreError("conflict"); + } else if (before !== state.lastProjectionHash) { + // Consent covers only edited fields of the selected, settled, already-owned row. + // Owner, path, metadata, token and pending-transaction checks remain mandatory. + if (!overwriteConflict || kind !== "apply" || state.phase !== "active" || !current + || !existing || selection !== state.targetId) throw new DesktopStoreError("conflict"); + const snapshotPath = join(bundle.paths.root, "overwrites", `${current.hash}.json`); + const snapshot = io.read(snapshotPath, 1024 * 1024, true); + if (snapshot && canonical(snapshot.value) !== canonical(current.value)) throw new DesktopStoreError("conflict"); + if (!snapshot) io.write(snapshotPath, current.value, null); + // artifact's ordinary pending record now journals this exact edited preimage. + io.compare(targetPath, current); + } const afterSelection = desiredSelection === undefined ? selection : desiredSelection; const settledPhase = kind === "restore" ? "restored" : "active"; if (!pending && existing && before === after && afterSelection === selection diff --git a/src/claude/desktop-remote-store.ts b/src/claude/desktop-remote-store.ts index 3e18307c980..1ae510dfbd5 100644 --- a/src/claude/desktop-remote-store.ts +++ b/src/claude/desktop-remote-store.ts @@ -81,24 +81,32 @@ export function inspectRemoteDesktopStore(owner: DesktopRemoteOwner): { export function applyRemoteDesktopStore(held: ClientLifecycleHeld, options: { owner: DesktopRemoteOwner; expectedTokenFingerprint: string; baseUrl: string; apiKey: string; mode: Desktop3pConfigMode; models: Desktop3pModelEntry[]; + /** Link-only local materialization: exact Child listener and a non-secret placeholder. */ + localListener?: boolean; + /** One operation's consent to replace edited fields of this already-owned profile. */ + overwriteConflict?: boolean; }): DesktopStoreResult { return mutation(held, io => { const { config, client } = currentConnection(options.owner); noDisconnect(io); if (client.pendingOperation) throw new DesktopStoreError("conflict"); const token = readServiceApiTokenState(); + const local = options.localListener === true; + const validDestination = local + ? client.transport === "link" && !!client.link && options.baseUrl === `http://127.0.0.1:${config.port}` && options.apiKey === "opencodex-loopback" + : serviceApiTokenFingerprint(options.apiKey) === (token.kind === "present" ? token.fingerprint : "") && origin(options.baseUrl) === options.owner.serverUrl; if (token.kind !== "present" || token.fingerprint !== options.expectedTokenFingerprint || token.fingerprint !== client.tokenFingerprint - || serviceApiTokenFingerprint(options.apiKey) !== token.fingerprint || origin(options.baseUrl) !== options.owner.serverUrl) throw new DesktopStoreError("conflict"); + || !validDestination) throw new DesktopStoreError("conflict"); if (config.clientIntegrations?.["claude-desktop"] === false) throw new DesktopStoreError("desired_disabled"); if (!options.models.length || !["static", "hybrid", "discovery"].includes(options.mode)) throw new DesktopStoreError("unsafe"); assertDesktop3pModelsValid(options.models); const bundle = establish(io, options.owner, [token.fingerprint, ...serviceGenerations()], false)!; const value = { - inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: options.owner.serverUrl, + inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: local ? options.baseUrl : options.owner.serverUrl, inferenceGatewayApiKey: options.apiKey, modelDiscoveryEnabled: options.mode !== "static", ...(options.mode === "discovery" ? {} : { inferenceModels: options.models }), }; - artifact(io, bundle, value, "apply", token.fingerprint, bundle.state!.targetId); + artifact(io, bundle, value, "apply", token.fingerprint, bundle.state!.targetId, options.overwriteConflict); cleanBackup(io, bundle, [token.fingerprint, ...serviceGenerations()]); return success(io, bundle, "applied"); }); @@ -123,6 +131,15 @@ export function replaceRemoteDesktopCredential(held: ClientLifecycleHeld, option const file = io.read(profilePath(bundle.paths.library, bundle.state!.targetId)); if (!file) throw new DesktopStoreError("unsafe"); const credential = profileCredential(file.value); + const local = client.transport === "link" && !!client.link + && file.value.inferenceGatewayBaseUrl === `http://127.0.0.1:${config.port}` && file.value.inferenceGatewayApiKey === "opencodex-loopback"; + if (local) { + // artifact still verifies the recorded projection and selection. Only the service-key + // generation changes; the local file never receives that secret during rotation. + artifact(io, bundle, projection(file.value), "rotate", replacement); + cleanBackup(io, bundle, [options.expectedTokenFingerprint, ...generations]); + return success(io, bundle, "updated"); + } if (!credential || credential.origin !== options.owner.serverUrl || ![options.expectedTokenFingerprint, replacement].includes(credential.fingerprint)) throw new DesktopStoreError("conflict"); artifact(io, bundle, { ...projection(file.value), inferenceGatewayApiKey: options.replacementKey }, "rotate", replacement); diff --git a/src/cli/capabilities-access-remote.ts b/src/cli/capabilities-access-remote.ts index add2702b22f..d5773102f0e 100644 --- a/src/cli/capabilities-access-remote.ts +++ b/src/cli/capabilities-access-remote.ts @@ -4,12 +4,13 @@ import type { Capability } from "./capability-types"; export const ACCESS_REMOTE_CAPABILITIES: readonly Capability[] = [ { command: ["disconnect"], summary: "Disconnect a client or preserve orphaned token files for recovery.", - usage: "ocx disconnect [--keep-catalog] [--json] | ocx disconnect --orphaned-token --yes [--json]", + usage: "ocx disconnect [--keep-catalog] [--recover-config --yes] [--json] | ocx disconnect --orphaned-token --yes [--json]", routes: [], flags: [ { name: "--keep-catalog", value: "boolean", summary: "Keep the connected client's catalog during ordinary disconnect." }, + { name: "--recover-config", value: "boolean", summary: "With --yes, snapshot current Codex artifacts and recover only evidenced owned keys; unknown ownership still refuses." }, { name: "--orphaned-token", value: "boolean", summary: "Preserve unowned service-token files in a private backup when disconnected." }, - { name: "--yes", value: "boolean", summary: "Explicitly confirm orphan-token recovery; requires --orphaned-token." }, + { name: "--yes", value: "boolean", summary: "Explicitly confirm --recover-config or --orphaned-token recovery." }, { name: "--json", value: "boolean", summary: "Emit the local result and backup location as JSON." }, ], mutates: true, json: "payload", diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index b79e3ba6a65..5f2b8a4f47b 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -51,7 +51,7 @@ export const CAPABILITIES: readonly Capability[] = [ command: ["link","issue"], summary: "Issue one link credential and record its tunnel metadata.", routes: [{"method":"POST","path":"/api/link/issue"}], - flags: [{"name":"--alias","value":"string","required":true,"summary":"SSH host alias for the linked machine."},{"name":"--tunnel-port","value":"number","required":true,"summary":"Remote loopback port for the reverse tunnel."},{"name":"--json","value":"boolean","summary":"Emit the issue result as JSON."}], + flags: [{"name":"--clients","value":"string","summary":"Initial device selection; defaults to Codex and Claude Code. Other Home client defaults remain inherited."},{"name":"--alias","value":"string","required":true,"summary":"SSH host alias for the linked machine."},{"name":"--tunnel-port","value":"number","required":true,"summary":"Remote loopback port for the reverse tunnel."},{"name":"--json","value":"boolean","summary":"Emit the issue result as JSON."}], mutates: true, json: "payload", details: ["Human-only handoff: issues a credential and prints its plaintext once. Never capture it in an agent transcript. Requires loopback admin authority; tunnel port must be 1024-65535."], @@ -752,7 +752,7 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [{"name":"--json","value":"boolean","summary":"Emit the client rows or toggle result as JSON."}], mutates: true, json: "payload", - details: ["Each toggle writes the selected client configuration through its runtime owner. Refused disables remain failures. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor."], + details: ["Each on/off writes the Home's native switch, recording no route intent for the Home, and applies it through the client's runtime owner, including Claude Desktop's first-party transition; Children keep inheriting their saved default. Refused disables remain failures. On a Child it is refused with the exact Home command. Cursor status and local-installer are separate read-only subcommands; they do not toggle or install Cursor."], usage: "ocx integration native [list] [--json]; ocx integration native [--json]", }, { @@ -762,7 +762,7 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [{"name":"--model-names","value":"string","summary":"Hermes/Command Code apply or overwrite only: alias or routed; omission preserves managed policy."},{"name":"--client","value":"string","summary":"File integration ID, including gentle-shell; aside selects profiles."},{"name":"--profile","value":"number","summary":"Aside nonnegative integer account ID; requires --client aside."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."},{"name":"--overwrite-conflict","value":"boolean","summary":"Explicitly permit replacing a conflicting block."},{"name":"--op","value":"string","summary":"Operation ID; --op-id is an alias."},{"name":"--confirm-drift","value":"boolean","summary":"Explicitly allow replacing edits made after the snapshot."}], mutates: true, json: "payload", - details: ["status/show/list reads all clients, one client or Aside profiles. history/journal reads rollback records; expired snapshots stay visible.","enable/disable requires --client; an omitted Aside --profile toggles all profiles. --overwrite-conflict applies only to enable.","restore requires --op (alias --op-id); --client requires --profile and only Aside supports that profile selector. --confirm-drift is an explicit user waiver, never auto-retried. Use client preview or restore --preview to inspect a change, history remove --op ID --yes to retire a journal row, and sync --client aside to refresh managed profiles."], + details: ["status/show/list reads all clients, one client or Aside profiles. history/journal reads rollback records; expired snapshots stay visible.","enable/disable requires --client; an omitted Aside --profile toggles all profiles. Each toggle is this Home's own decision, like the dashboard: once the Home uses route intent it is recorded so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). --overwrite-conflict applies only to enable.","restore requires --op (alias --op-id); --client requires --profile and only Aside supports that profile selector. --confirm-drift is an explicit user waiver, never auto-retried. Use client preview or restore --preview to inspect a change, history remove --op ID --yes to retire a journal row, and sync --client aside to refresh managed profiles."], usage: "ocx integration client status [--client ] [--profile ] [--json]; ocx integration client history [--client ] [--profile ] [--json]; ocx integration client enable --client [--profile ] [--overwrite-conflict] [--model-names ] [--json]; ocx integration client disable --client [--profile ] [--json]; ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json]", }, { @@ -770,6 +770,8 @@ export const CAPABILITIES: readonly Capability[] = [ summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.", routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }], flags: [ + { name: "--overwrite-conflict", value: "boolean", summary: "Allow conflicting route materialization only for this explicit sync." }, + { name: "--overwrite-client", value: "string", summary: "Restrict takeover to one client; requires --overwrite-conflict." }, { name: "--local-only", value: "boolean", summary: "Do not fan out the Home sync to linked Children." }, { name: "--json", value: "boolean", summary: "Emit the sync fan-out result as JSON." }, { name: "--restart-codex", value: "boolean", summary: "Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog, cache, or changed configuration write, on macOS, Linux and Windows." }, @@ -787,7 +789,7 @@ export const CAPABILITIES: readonly Capability[] = [ "After a successful Home sync, a connected Child skipped during fan-out, a failed or timed-out Child, or an unreadable link store yields partial exit 6. Skipped connected Children include an actionable hint; update an outdated ocx through its owning installer, with the Desktop tray as an option for Desktop installs. Intentional skips, including client-initiated links and --local-only, keep their existing exit behavior.", "--expect-link/--expect-hub-key are checked under the lock that selects the sync path. A mismatch writes nothing, never falls back to local providers, exits 5, and ends stderr with {\"phase\":\"child_link_mismatch\"|\"hub_client_mismatch\",\"reason\":...}; reasons are disconnected, invalid_state, disconnect_pending, pending_operation, hub_client, link_client, other_link and other_key. Usage errors exit 2.", ], - usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", + usage: "ocx sync [--expect-link | --expect-hub-key ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", }, { command: ["agent","subagents","force"], diff --git a/src/cli/capabilities-integrations.ts b/src/cli/capabilities-integrations.ts index 338240652c8..5df20204675 100644 --- a/src/cli/capabilities-integrations.ts +++ b/src/cli/capabilities-integrations.ts @@ -89,7 +89,7 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [ flags: [{"name":"--model-names","value":"string","summary":"Hermes/Command Code apply or overwrite only: alias or routed; omission preserves managed policy."},{"name":"--client","value":"string","summary":"Explicit file integration ID.","required":true},{"name":"--profile","value":"number","summary":"Aside nonnegative integer account ID; requires --client aside."},{"name":"--overwrite-conflict","value":"boolean","summary":"Explicitly permit replacing a conflicting block."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."},{"name":"--plan-fingerprint","value":"string","summary":"Optional versioned token from a matching preview; stale intent is refused without retry."},{"name":"--reasoning-default","value":"string","summary":"Repeat MODEL=EFFORT to replace the full Droid defaults map; exact model IDs, duplicate keys refused."},{"name":"--clear-reasoning-defaults","value":"boolean","summary":"Droid apply/overwrite only: send an empty map, distinct from omitted inherited defaults."}], mutates: true, json: "payload", - details: ["An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview.","Droid map omission preserves inheritance; supplied entries replace the full map, clear sends {}. Only Droid apply/overwrite accepts these options."], + details: ["A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview.","Droid map omission preserves inheritance; supplied entries replace the full map, clear sends {}. Only Droid apply/overwrite accepts these options."], }, { command: ["integration","client","disable"], @@ -99,7 +99,7 @@ export const INTEGRATION_CAPABILITIES: readonly Capability[] = [ flags: [{"name":"--client","value":"string","summary":"Explicit file integration ID.","required":true},{"name":"--profile","value":"number","summary":"Aside nonnegative integer account ID; requires --client aside."},{"name":"--json","value":"boolean","summary":"Emit the result as JSON."},{"name":"--plan-fingerprint","value":"string","summary":"Optional versioned token from a matching preview; stale intent is refused without retry."}], mutates: true, json: "payload", - details: ["An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview."], + details: ["A Home-only decision, like the dashboard toggle: once the Home uses route intent the server records it as this Home's override so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off changes those). On a Child a plain toggle is refused with the Home command. An omitted Aside profile toggles all Aside profiles. Ownership, snapshots, journal and conflict refusals remain server-owned.","Partial Aside failures remain failures after emitting the per-profile result. Never infer permission to overwrite from a refusal.","Bound Aside mutations require one explicit profile. Repeat the exact preview action/profile/options. The token is concurrency evidence, not authorization; stale state requires a new explicit preview."], }, { command: ["integration","client","restore"], diff --git a/src/cli/capabilities-route-to.ts b/src/cli/capabilities-route-to.ts new file mode 100644 index 00000000000..002883519b3 --- /dev/null +++ b/src/cli/capabilities-route-to.ts @@ -0,0 +1,41 @@ +import type { Capability, CapabilityFlag } from "./capability-types"; + +const device: CapabilityFlag = { name: "--device", value: "string", summary: "Home: alias, id, all or this. Child: this or its own link id only." }; +const json: CapabilityFlag = { name: "--json", value: "boolean", summary: "Emit the ocx.route-to/1 status matrix." }; +const overwrite: CapabilityFlag = { name: "--overwrite-conflict", value: "boolean", summary: "Explicitly permit replacing conflicting managed blocks." }; +const local: CapabilityFlag = { name: "--local", value: "boolean", summary: "Child only: opt out with off, or clear the opt-out with inherit." }; + +export const ROUTE_TO_CAPABILITIES: readonly Capability[] = [ + { + command: ["route", "to"], + usage: "ocx route to [--device ] [--accounts ] [--model-names ] [--overwrite-conflict] [--local] [--json]", + summary: "Set Home-default or per-device route intent, or opt out locally on a Child.", + routes: [{ method: "PUT", path: "/api/route-to" }], + flags: [device, json, overwrite, local, + { name: "--accounts", value: "string", summary: "Aside on only: all, none, or comma-separated stable userIds; never u/N slots." }, + { name: "--model-names", value: "string", summary: "Hermes/Command Code on only: alias or routed model names." }, + ], + mutates: true, json: "payload", + details: ["Clients: codex, claude, claude-desktop, grok and the supported file integrations. Without --device a Home updates its default and materializes itself; all is only for status/sync.", + "inherit requires --device unless clearing a Child --local opt-out. Child writes print the exact Home command with the Child link id; --local supports off/inherit only. --overwrite-conflict applies only to on or sync.", + "On the Home, codex, claude, grok and claude-desktop follow the Home's own native switches: a Home default or --device home write for them writes that switch (Home inherit sets it to the default)."], + }, + { + command: ["route", "to", "status"], + usage: "ocx route to status [--device ] [--json]", + summary: "Read per-device route intent, its source and actual client apply outcomes.", + routes: [{ method: "GET", path: "/api/route-to" }], + flags: [device, json], mutates: false, json: "payload", + details: ["On a Child this reads local receipts without a Home management relay. Intent and applied state are distinct; null applied means no recorded outcome.", + "Sources: builtin, default, device, local (Child opt-out) and native (a Home row governed by the Home's own switch). An outcome recorded for an earlier intent reads stale."], + }, + { + command: ["route", "to", "sync"], + usage: "ocx route to sync [--device ] [--overwrite-conflict] [--json]", + summary: "Reapply route intent on selected devices, or locally on a Child.", + routes: [{ method: "POST", path: "/api/route-to/sync" }], + flags: [device, overwrite, json], mutates: true, json: "payload", + details: ["No conflict retry or implicit overwrite. Child sync calls its local materializer and sends no Home management write.", + "Home sync never changes the Home's native switches for codex, claude, grok or claude-desktop; it refreshes what each switch says."], + }, +]; diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 7a3c82376e7..8f1dda7f061 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -13,6 +13,7 @@ import { INTEGRATION_CAPABILITIES } from "./capabilities-integrations"; import { OBSERVE_SYSTEM_CAPABILITIES } from "./capabilities-observe-system"; import { ACCESS_REMOTE_CAPABILITIES } from "./capabilities-access-remote"; import { LAB_CAPABILITIES } from "./capabilities-lab"; +import { ROUTE_TO_CAPABILITIES } from "./capabilities-route-to"; export const CAPABILITIES: readonly Capability[] = [ ...BASE_CAPABILITIES, @@ -23,6 +24,7 @@ export const CAPABILITIES: readonly Capability[] = [ ...OBSERVE_SYSTEM_CAPABILITIES, ...ACCESS_REMOTE_CAPABILITIES, ...LAB_CAPABILITIES, + ...ROUTE_TO_CAPABILITIES, ]; export { HEAD_CAPABILITIES } from "./capabilities-base"; diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 209c4a05c15..5e668fa7cdc 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -7,11 +7,12 @@ import { resolve } from "node:path"; import { getConfigDir, withConfigDirHardeningSettled } from "../config/paths"; import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config"; import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state"; +import { setHomeIntegrationEnabled } from "../routing/home-route-capture"; import { readClientConnectionState, assertClientConnectionUnchanged, assertNoClientDisconnectPending, type ClientConnectionState } from "../client/state"; import { downloadDesktop3pModels, HubClientError, normalizeHubOrigin } from "../client/hub-client"; import { readServiceApiTokenState } from "../lib/service-secrets"; import { applyRemoteDesktopStore, type DesktopStoreResult } from "../claude/desktop-remote-store"; -import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { assertClientLifecycleHeld, withClientLifecycle, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; import { DESKTOP_FAMILIES, moveDesktopRoute, @@ -305,12 +306,16 @@ async function applyConnectedDesktopProfile( } /** Persist the operator's Desktop mode choice; a failed marker save never undoes the apply. */ -async function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): Promise { +async function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps, held?: ClientLifecycleHeld): Promise { try { - return await settleConfigRoot(configRoot(), () => withClientLifecycleSync(() => { - const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode)); - return outcome.status !== "unavailable"; - }, deps.lifecycleLockDeps)); + return await settleConfigRoot(configRoot(), () => { + const save = () => { + const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode)); + return outcome.status !== "unavailable"; + }; + if (held) { assertClientLifecycleHeld(held); return save(); } + return withClientLifecycleSync(save, deps.lifecycleLockDeps); + }); } catch { return false; } @@ -435,8 +440,21 @@ async function applyFirstPartyDesktop( return { ok: false, path: "", reason: error instanceof Error ? error.message : "daemon apply failed" }; } } + // The offline path has no picker controller to serialize CLI/server writers. Keep L + // across settings, gateway retirement, mode publication and any conditional rollback. + try { return await withClientLifecycle(held => applyFirstPartyDesktopOffline(deps, held), deps.lifecycleLockDeps); } + catch (error) { + return { ok: false, path: "", reason: error instanceof Error && error.message === "client_lifecycle_busy" + ? "client_lifecycle_busy" : "desktop_apply_failed" }; + } +} + +async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: ClientLifecycleHeld): Promise { + assertClientLifecycleHeld(held); + assertNoClientDisconnectPending(); + if (readClientConnectionState().kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; const config = loadConfig(); - const desired = await withFirstPartySecretsSettled(getConfigDir(), () => setIntegrationEnabled("claude-desktop", true)); + const desired = await withFirstPartySecretsSettled(getConfigDir(), () => setHomeIntegrationEnabled("claude-desktop", true)); if (!desired.ok) return "path" in desired ? desired : { ok: false, path: "", reason: desired.message }; // Establish the replacement before deleting the working gateway. A refused // cleanup restores only our managed env keys, preserving unrelated settings. @@ -452,7 +470,8 @@ async function applyFirstPartyDesktop( const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { let removed: ReturnType; - try { removed = await settleConfigRoot(configRoot(), () => removeDesktop3pStandardPivot({ appliedFingerprint: readConfigForAdmittedMutation().claudeCode?.desktopProfile?.appliedFingerprint ?? null, replaceWhileEnabled: true })); } + try { removed = await settleConfigRoot(configRoot(), () => removeDesktop3pStandardPivot({ appliedFingerprint: readConfigForAdmittedMutation().claudeCode?.desktopProfile?.appliedFingerprint ?? null, replaceWhileEnabled: true, + lifecycleLockDeps: deps.lifecycleLockDeps, lifecycleHeld: held })); } catch (error) { removed = { ok: false, changed: false, kind: "write_failed", libraryPath: library.libraryPath, reason: error instanceof WindowsSecretAclSettleTimeoutError ? "acl_settlement_timeout" : "desktop_mutation_failed" }; @@ -460,7 +479,7 @@ async function applyFirstPartyDesktop( gatewayRemoved = removed.changed; if (!removed.ok) { const restored = removed.changed || !applied.changed || await rollbackDesktopFirstPartySettled(getConfigDir(), rollback).catch(() => false); - const modeSaved = !removed.changed || await saveDesktopMode("first-party", deps); + const modeSaved = !removed.changed || await saveDesktopMode("first-party", deps, held); const warning = [restored ? "" : "first-party settings rollback did not complete", modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; "); return { ok: false, path: library.selectedProfilePath ?? "", reason: removed.kind === "cleanup_incomplete" ? "gateway_cleanup_incomplete" : `gateway_profile_active:${removed.reason ?? removed.kind}`, @@ -468,7 +487,7 @@ async function applyFirstPartyDesktop( ...(warning ? { warning } : {}) }; } } - const saved = await saveDesktopMode("first-party", deps); + const saved = await saveDesktopMode("first-party", deps, held); modeCommitted = saved; return { ok: true, @@ -521,7 +540,7 @@ export async function applyProfile( // so a process crash cannot leave a gateway profile that startup immediately removes. let committedIntent = false, savedProfile = false, gatewayApplied = false; try { - const desired = await settleConfigRoot(configRoot(), () => setIntegrationEnabled("claude-desktop", true)); + const desired = await settleConfigRoot(configRoot(), () => setHomeIntegrationEnabled("claude-desktop", true)); if (!desired.ok) return { ok: false, path: "", reason: desired.message }; committedIntent = true; const config = loadConfig(); diff --git a/src/cli/connect.ts b/src/cli/connect.ts index b3986f7df24..5271a540108 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -26,6 +26,7 @@ import { createSshRunner, type SshRunner } from "../link/ssh-runner"; import type { OrphanTunnelResult } from "../client/link-tunnel"; import { inspectRemoteDesktopStore } from "../claude/desktop-remote-store"; import type { OcxConnectedClientId } from "../types"; +import { ROUTE_CLIENT_IDS, isRouteClientId } from "../link/route-intent"; import { CliUsageError, csv, @@ -240,8 +241,8 @@ export function collectClientConnectionStatus( function parseClients(raw: string | undefined): OcxConnectedClientId[] { const values = csv(raw) ?? ["codex", "claude"]; - if (values.length < 1 || values.some(value => value !== "codex" && value !== "claude")) { - throw new CliUsageError("--clients must contain codex and/or claude", CONNECT_USAGE); + if (values.length < 1 || values.some(value => !isRouteClientId(value))) { + throw new CliUsageError(`--clients must contain route client ids: ${ROUTE_CLIENT_IDS.join(", ")}`, CONNECT_USAGE); } return values as OcxConnectedClientId[]; } @@ -390,6 +391,9 @@ async function runHubConnect(argv: string[], deps: ClientCommandDeps): Promise client !== "codex" && client !== "claude")) { + throw new CliUsageError("Hub connections support only codex and claude; use Link for other clients", CONNECT_USAGE); + } const catalogTimeoutSeconds = takeIntegerOption(args, "--catalog-timeout", { min: 1 }); if (catalogTimeoutSeconds !== undefined && catalogTimeoutSeconds > 120) { throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE); @@ -431,9 +435,9 @@ async function runHubConnect(argv: string[], deps: ClientCommandDeps): Promise 120) { throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE); @@ -468,6 +473,7 @@ async function runLinkConnect(argv: string[], deps: ClientCommandDeps): Promise< managementUrl: `http://127.0.0.1:${rawPort}`, credential: { kind: "link", ...credential }, selectedClients: clients, + selectedClientsExplicit: rawClients !== undefined, managementTransport: "direct", transport: "link", link: { tunnelPort: rawPort, linkId }, @@ -481,9 +487,9 @@ async function runLinkConnect(argv: string[], deps: ClientCommandDeps): Promise< throw terminalSafeError(error); }); const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {}); - const report = connectCompletionReport(connection, clients, readiness); + const report = connectCompletionReport(connection, connection.selectedClients, readiness); for (const line of report.lines) console.log(line); - printCodexShimReadiness(clients); + printCodexShimReadiness(connection.selectedClients); if (report.failure) throw new Error(report.failure); } @@ -538,8 +544,10 @@ export async function handleDisconnectCommand( const keepCatalog = takeFlag(args, "--keep-catalog"); const wantsJson = takeFlag(args, "--json"); const orphanedToken = takeFlag(args, "--orphaned-token"); + const recoverConfig = takeFlag(args, "--recover-config"); const yes = takeFlag(args, "--yes"); rejectArgs(args, DISCONNECT_USAGE, { redactValues: true }); + if (recoverConfig && (!yes || orphanedToken)) throw new CliUsageError("--recover-config requires --yes and cannot be combined with --orphaned-token", DISCONNECT_USAGE); if (orphanedToken) { if (!yes || keepCatalog) throw new CliUsageError("--orphaned-token requires --yes and cannot be combined with --keep-catalog", DISCONNECT_USAGE); const backup = await recoverOrphanedTokens({ ...deps.orphanRecoveryDeps, lifecycleLockDeps: deps.lifecycleLockDeps }); @@ -550,7 +558,7 @@ export async function handleDisconnectCommand( ]); return; } - if (yes) throw new CliUsageError("--yes requires --orphaned-token", DISCONNECT_USAGE); + if (yes && !recoverConfig) throw new CliUsageError("--yes requires --orphaned-token or --recover-config", DISCONNECT_USAGE); const teardown = await teardownClientLink({ readSidecar: readClientLinkState, connectedLinkId: () => { @@ -564,7 +572,14 @@ export async function handleDisconnectCommand( knownHostsFile: deps.linkTeardownDeps?.knownHostsFile ?? linkKnownHostsPath(), timeoutMs: deps.linkTeardownDeps?.timeoutMs, }); - const result = await disconnectClient({ keepCatalog }, deps); + const result = await disconnectClient({ keepCatalog, recoverConfig }, deps); + if (result.nativeIntentOnly) { + printData(result, wantsJson, [ + "Not connected; restored the standalone native switches an interrupted Remote Link enrollment had changed.", + ...(result.tokenRemoved ? ["Removed the service token that enrollment left behind."] : []), + ]); + return; + } const payload = { ...result, homeRevoke: teardown.homeRevoke, @@ -576,6 +591,7 @@ export async function handleDisconnectCommand( }; printData(payload, wantsJson, [ "Disconnected locally; native Codex state was restored.", + ...(result.recoveryBackupDir ? [`Codex recovery snapshot: ${result.recoveryBackupDir}/snapshot.json (private; retained for manual inspection).`] : []), ...(result.desktopRestoration === "standard_fallback" ? ["Previous Desktop settings were not recorded; the managed profile was switched to standard mode."] : []), ...(result.restartRequired ? ["Fully quit and reopen Claude Desktop; local cleanup cannot revoke an in-memory credential."] : []), diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 1a9fd889871..dba1a4abb35 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -21,11 +21,11 @@ import { printUnknownCommand } from "./help-recovery"; import { HUB_GATED_SKIP_MESSAGE, localClientSkipMessage, - setIntegrationEnabled, shouldSyncCodexOnStart, type LocalClientSkipReason, } from "../codex/desired-state"; import { siblingSkipMessage } from "../codex/sibling-start"; +import { setHomeIntegrationEnabled, type HomeIntegrationResult } from "../routing/home-route-capture"; import { syncModelsToCodex } from "../codex/sync"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { restoreNativeCodexAsync, type CodexNativeRestoreResult } from "../codex/inject"; @@ -89,6 +89,12 @@ export function selectDefaultGuiUrl( return `http://${guiHost}:${live?.port ?? config.port ?? 10100}`; } +/** An unavailable route-intent file names the file to repair; the other refusals keep their reason. */ +function desiredStateRefusal(desired: Extract): string { + return desired.reason === "unavailable" ? `Codex desired state was not saved (unavailable): ${desired.message}` + : `Codex desired state was not saved (${desired.reason}).`; +} + const commandRunners: Record = { init: async () => { const { runInit } = await import("./init"); @@ -173,13 +179,13 @@ const commandRunners: Record = { if (!live) { return emitBack(false, "No running proxy found. Run 'ocx start' — it injects opencodex automatically.", 1); } - const desired = setIntegrationEnabled("codex", true); + const desired = setHomeIntegrationEnabled("codex", true); if (!desired.ok) { - return emitBack(false, `Codex desired state was not saved (${desired.reason}).`, desired.reason === "conflict" ? 2 : 1); + return emitBack(false, desiredStateRefusal(desired), desired.reason === "conflict" ? 2 : 1); } const synced = await syncModelsToCodex(live.port); if (synced.status === "skipped") { - // `setIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and + // `setHomeIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and // is not a competing writer either — on a hub it is the role gate. Telling the operator // to "retry after the competing integration change finishes" sent them waiting for a // writer that does not exist (#4236). @@ -200,15 +206,15 @@ const commandRunners: Record = { if (removeProviderTable && !restoreJson) { console.log("⚠️ Removing [model_providers.opencodex] means conversations already tagged opencodex will stop opening."); } - const desired = setIntegrationEnabled("codex", false); + const desired = setHomeIntegrationEnabled("codex", false); if (!desired.ok) { if (restoreJson) { // Machine-readable contract: every restore --json outcome emits one // schema-complete envelope on stdout, including pre-machinery failures. const { skippedRestoreEnvelope } = await import("../codex/inject"); - console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`))); + console.log(JSON.stringify(skippedRestoreEnvelope(false, desiredStateRefusal(desired)))); } else { - console.error(`Codex desired state was not saved (${desired.reason}).`); + console.error(desiredStateRefusal(desired)); } return desired.reason === "conflict" ? 2 : 1; } @@ -752,8 +758,12 @@ const commandRunners: Record = { return await handleCompanionCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy }); }, route: async deps => { + if (deps.args[1] === "to") { + const { handleRouteToCommand } = await import("./route-to"); + return await handleRouteToCommand(deps.args.slice(2), { findLiveProxy: deps.findLiveProxy }); + } if (deps.args[1] !== "combo" && deps.args[1] !== "policy") { - console.error("Usage: ocx route "); + console.error("Usage: ocx route "); return 2; } if (deps.args[1] === "combo") { diff --git a/src/cli/help-catalog.ts b/src/cli/help-catalog.ts index df29a851a4e..7743c2e0510 100644 --- a/src/cli/help-catalog.ts +++ b/src/cli/help-catalog.ts @@ -1,5 +1,6 @@ import { CAPABILITIES, type Capability } from "./capabilities"; import { CLI_COMMANDS, findCommand, type CliCommandEntry } from "./registry"; +import { EXPORT_CLIENT_IDS } from "../clients/client-ids"; export type HelpResolution = | { kind: "entry"; entry: CliCommandEntry; canonicalName: string; capability?: Capability; children: readonly Capability[] } @@ -88,6 +89,16 @@ export function resolveHelpPath(requested: readonly string[]): HelpResolution { const capability = declared.find(candidate => candidate.command.length === path.length && startsWithPath(candidate.command, path)); const children = declared.filter(candidate => candidate.command.length > path.length && startsWithPath(candidate.command, path)); if (capability) return { kind: "capability", path, capability, children }; + // Clients and modes are operands of one capability, not registry leaves. Resolve their + // help without multiplying the generated operating surface by every client/action pair. + if (path[0] === "route" && path[1] === "to" && path.length >= 3 + && ["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS].includes(path[2])) { + const owner = declared.find(candidate => candidate.command.join(" ") === "route to"); + if (owner && path.length === 3) return { kind: "prefix", path, children: ["on", "off", "inherit"].map(action => ({ ...owner, command: [...path, action] })) }; + if (owner && path.length === 4 && ["on", "off", "inherit"].includes(path[3])) { + return { kind: "capability", path, capability: { ...owner, command: path }, children: [] }; + } + } if (children.length) return { kind: "prefix", path, children }; for (let length = Math.min(path.length - 1, MAX_DOCUMENTED_DEPTH); length > 1; length--) { diff --git a/src/cli/help.ts b/src/cli/help.ts index 67780c76753..d86cc8464e2 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -86,7 +86,7 @@ Usage: ocx fallback Turn model or provider fallback off per scope; per-device local fallback ocx observe Logs, usage, storage, memory, and debug data ocx inspect Effective config, catalog, analytics, pacing, client-config - ocx route Routing features (combo, policy) + ocx route Routing features (to, combo, policy) ocx logs [filters] Alias of ocx observe logs ocx usage [--range ] [--provider ] [--model ] [--top <1-1000>] Token and estimated-cost report (alias of ocx observe usage) diff --git a/src/cli/inspect.ts b/src/cli/inspect.ts index 4cabb0ceae8..8f8a85f8f09 100644 --- a/src/cli/inspect.ts +++ b/src/cli/inspect.ts @@ -109,8 +109,17 @@ async function nativeIntegration(argv: string[], deps: RuntimeApiDeps): Promise< throw new CliUsageError(`expected on or off after ${action}`, USAGE); } - // Toggling rewrites the client's own config file, which is why each client has its own route - // rather than one route with a client parameter. + // A native switch is a Home-only decision. The native route (/api/native-integrations/) + // writes the Home's switch, records no Home route intent, and runs the client's own transition + // (Claude Desktop first-party included); Children keep inheriting their saved default. A Child + // refuses here with the Home command for its own device. + const { readLinkChildContext } = await import("./link-child"); + const routeDeps = deps as import("./route-to").RouteToDeps; + if ((routeDeps.readChildContext ?? readLinkChildContext)()) { + const { routeToAction } = await import("./route-to"); + await routeToAction([action, state, ...(wantsJson ? ["--json"] : [])], routeDeps); + return; + } const result = await runtimeRequest(`/api/native-integrations/${action}`, { method: "PUT", headers: { "content-type": "application/json" }, diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 09fa2fb392c..82e5eb9ffa2 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -15,6 +15,7 @@ import { import { clientIntegrationPath, validateAsideProfile } from "./integration-input"; import { asideProfileRecoveryLines } from "./aside-profile-recovery"; import type { IntegrationClientId } from "../integrations/registry"; +import type { RouteToDeps } from "./route-to"; const CLAUDE_USAGE = `Usage: ocx claude config [status] [--json] @@ -240,10 +241,31 @@ export async function handleClientIntegrationCommand( const { handleIntegrationAsideSync } = await import("./integration-aside-sync"); return handleIntegrationAsideSync(argv.slice(1), deps); } + // Enable and disable are the Home's own decision, like the dashboard toggle: the server records + // it so ocx sync keeps it, and linked Children keep their defaults (ocx route to on|off + // changes those). On a Child the Home decides, so a plain toggle goes to route-to, which refuses + // and prints the Home command. + const toggle = ["enable", "disable"].includes(argv[0] ?? ""); + if (toggle && !argv.some(arg => ["--profile", "--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults"] + .some(flag => arg === flag || arg.startsWith(`${flag}=`)))) { + const { readLinkChildContext } = await import("./link-child"); + if (((deps as RouteToDeps).readChildContext ?? readLinkChildContext)()) { + return runCliAction(async () => { + const { integrationOption } = await import("./integration-input"); + const { routeToAction } = await import("./route-to"); + const args = argv.slice(1); + const client = integrationOption(args, "--client"); + if (!client) throw new CliUsageError("--client is required", CLIENT_USAGE); + await routeToAction([client, argv[0] === "enable" ? "on" : "off", ...args], deps); + }); + } + } if (argv[0] === "preview" || argv.some(arg => ["--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults", "--model-names"] .some(flag => arg === flag || arg.startsWith(`${flag}=`)))) { const { handleIntegrationPreviewCommand } = await import("./integration-preview"); - return handleIntegrationPreviewCommand(argv, deps, expectedClientId); + const code = await handleIntegrationPreviewCommand(argv, deps, expectedClientId); + if (code === 0 && toggle) console.error(HOME_ONLY_NOTE); + return code; } return runCliAction(async () => { const args = [...argv]; @@ -357,9 +379,12 @@ export async function handleClientIntegrationCommand( ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}${row.residual === true ? " Recovery did not finish." : ""}${typeof row.snapshotPath === "string" ? ` Backup: ${row.snapshotPath}` : ""}`) : [String(batch.message ?? `${client} ${action}d.`)]); if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result); + console.error(HOME_ONLY_NOTE); }); } +const HOME_ONLY_NOTE = "This changed this machine only; linked Children keep their route defaults (ocx route to on|off changes those)."; + export const INTEGRATION_USAGE = { claude: CLAUDE_USAGE, grok: GROK_USAGE, client: CLIENT_USAGE }; const ZCODE_USAGE = `Usage: diff --git a/src/cli/link.ts b/src/cli/link.ts index c7591093281..a35921f6856 100644 --- a/src/cli/link.ts +++ b/src/cli/link.ts @@ -7,6 +7,7 @@ import { import { TUNNEL_CAUSES } from "../link/tunnel-history"; import { isEpochMs, type LinkChildParity } from "../link/child-parity"; import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings"; +import { isRouteClientId, parseRouteClientOutcomes, ROUTE_INTENT_DIGEST_PATTERN, routeClientOutcomesForPeer } from "../link/route-intent"; import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility"; import { runLinkEnrollment } from "./link-enrollment"; import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup"; @@ -36,7 +37,7 @@ export const LINK_USAGE = `Usage: ocx link probe [--json] ocx link add [--plant] [--expect-fingerprint SHA256:…] [--json] ocx link port [--json] - ocx link issue --alias --tunnel-port [--json] + ocx link issue --alias --tunnel-port [--clients ] [--json] ocx link status [--json] ocx link revoke --link-id [--purge] [--force --yes] [--json] ocx link reconnect --link-id [--json] @@ -270,6 +271,9 @@ async function runPort(args: string[], deps: LinkCliDeps): Promise { async function runIssue(args: string[], deps: LinkCliDeps): Promise { takeJsonFlag(args); const alias = takeOption(args, "--alias"); + const rawClients = takeOption(args, "--clients"); + const selectedClients = rawClients?.split(","); + if (selectedClients && (!selectedClients.length || !selectedClients.every(isRouteClientId) || new Set(selectedClients).size !== selectedClients.length)) throw new CliUsageError("--clients needs unique supported client IDs", LINK_USAGE); const tunnelPort = takeIntegerOption(args, "--tunnel-port", { min: 1024 }); if (!alias || tunnelPort === undefined || !isLinkPort(tunnelPort)) { throw new CliUsageError("issue requires --alias and --tunnel-port", LINK_USAGE); @@ -280,7 +284,7 @@ async function runIssue(args: string[], deps: LinkCliDeps): Promise { const result = validateIssue(await linkRequest("/api/link/issue", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ alias, tunnelPort }), + body: JSON.stringify({ alias, tunnelPort, ...(selectedClients ? { selectedClients } : {}) }), }, deps)); console.log(JSON.stringify(result)); } @@ -391,7 +395,7 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}): }); } -const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const; +const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt", "appliedRouteIntentDigest", "routeClientOutcomes"] as const; const WIRE_STATES = new Set(["connecting", "connected", "reconnecting", "failed", "idle"]); const CAUSES = new Set(TUNNEL_CAUSES); @@ -431,10 +435,20 @@ function validateParity(candidate: Record, index: number): Link if (digest !== undefined && digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) { throw new Error(`invalid link API response: link ${index} appliedCodexSettingsDigest`); } + let route: Pick = {}; + if (candidate.appliedRouteIntentDigest !== undefined || candidate.routeClientOutcomes !== undefined) { + const routeDigest = candidate.appliedRouteIntentDigest; + const outcomes = parseRouteClientOutcomes(candidate.routeClientOutcomes); + if ((routeDigest !== null && (typeof routeDigest !== "string" || !ROUTE_INTENT_DIGEST_PATTERN.test(routeDigest))) || !outcomes) { + throw new Error(`invalid link API response: link ${index} route intent`); + } + route = { appliedRouteIntentDigest: routeDigest, routeClientOutcomes: routeClientOutcomesForPeer(outcomes) }; + } return { lastApplyAt: time("lastApplyAt"), runtimeStartedAt: time("runtimeStartedAt"), appliedCodexSettingsDigest: typeof digest === "string" ? digest : null, reportedAt: time("reportedAt"), + ...route, }; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index a9f48283878..0cb96d09997 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -178,12 +178,12 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "disconnect", - usage: "ocx disconnect [--keep-catalog] [--json]", + usage: "ocx disconnect [--keep-catalog] [--recover-config --yes] [--json]", summary: "Restore local client state offline and clear the remote-hub connection.", }, { name: "sync", - usage: "ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", + usage: "ocx sync [--expect-link | --expect-hub-key ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", summary: "Fetch provider models and inject them into Codex config.", details: [ "A current Home catalog fans out to linked Children; --local-only disables fan-out and --json emits the result. Partial fan-out exits 6.", @@ -431,8 +431,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "route", - usage: "ocx route ", - summary: "Manage combo routing and routing profiles.", + usage: "ocx route ", + summary: "Manage combo routing, routing profiles and per-device client intent.", }, { name: "effort", diff --git a/src/cli/route-to.ts b/src/cli/route-to.ts new file mode 100644 index 00000000000..4627ad94374 --- /dev/null +++ b/src/cli/route-to.ts @@ -0,0 +1,169 @@ +import { + CliUsageError, RuntimeApiError, printData, rejectArgs, runCliAction, runtimeRequest, + type RuntimeApiDeps, +} from "./runtime-api"; +import { integrationFlag, integrationOption } from "./integration-input"; +import { readLinkChildContext, linkChildRefusal, type LinkChildContext } from "./link-child"; +import { isRouteClientId, parseRouteClientIntent } from "../link/route-intent"; +import type { RouteToStatus } from "../routing/route-to-status"; + +export const ROUTE_TO_USAGE = `Usage: + ocx route to status [--device ] [--json] + ocx route to sync [--device ] [--overwrite-conflict] [--json] + ocx route to [--device ] [--accounts ] [--model-names ] [--overwrite-conflict] [--json] + ocx route to --local [--device this] [--json]`; + +const loadLocal = () => import("../routing/route-to-local"); +export interface RouteToDeps extends RuntimeApiDeps { + readChildContext?: () => LinkChildContext | null; + loadLocal?: typeof loadLocal; +} + +export function routeToStatusLines(status: RouteToStatus): string[] { + return ["DEVICE CLIENT INTENT SOURCE APPLIED TARGET", ...status.devices.flatMap(device => + device.routeIntent === "unsupported" ? [`${device.alias || device.id} route intent unsupported (${device.reason ?? "unsupported"})`] + : device.clients.map(row => [device.alias || device.id, row.client, row.intent, row.source, + row.applied ?? "-", row.target ?? "-"].join(" "))), + ...(status.syncs ?? []).map(row => `sync ${row.id}: ${row.state}${row.reason ? ` (${row.reason})` : ""}`), + ...(status.deferred ? [`Local reconciliation deferred: Home intent or the Child catalog is unavailable${status.deferred.clients.length ? ` (${status.deferred.clients.join(", ")})` : ""}.`] : [])]; +} + +function accountsOption(raw: string | undefined): "all" | "none" | string[] | undefined { + if (raw === undefined || raw === "all" || raw === "none") return raw; + const values = raw.split(",").map(value => value.trim()); + if (!parseRouteClientIntent({ enabled: true, accounts: values }, "aside") + || values.some(value => value === "all" || value === "none")) { + throw new CliUsageError("--accounts requires all, none, or unique Aside userIds (not u/N slots)", ROUTE_TO_USAGE); + } + return values; +} + +/** Shared with legacy toggle aliases, inside their existing error boundary. */ +export async function routeToAction(argv: string[], deps: RouteToDeps = {}): Promise { + const args = [...argv]; + const json = integrationFlag(args, "--json"); + const local = integrationFlag(args, "--local"); + const overwriteConflict = integrationFlag(args, "--overwrite-conflict"); + const device = integrationOption(args, "--device"); + const accounts = accountsOption(integrationOption(args, "--accounts")); + const modelNames = integrationOption(args, "--model-names"); + const subject = args.shift() ?? "status"; + const read = subject === "status"; + const sync = subject === "sync"; + const client = read || sync ? undefined : subject; + const action = client === undefined ? undefined : args.shift(); + rejectArgs(args, ROUTE_TO_USAGE); + if (device !== undefined && (!device.trim() || /[\x00-\x20\x7f]/.test(device))) { + throw new CliUsageError("--device requires an alias, id, all or this", ROUTE_TO_USAGE); + } + if (client !== undefined && !isRouteClientId(client)) { + throw new CliUsageError("Expected a supported route-to client", ROUTE_TO_USAGE); + } + if (client !== undefined && !["on", "off", "inherit"].includes(action ?? "")) { + throw new CliUsageError("Expected a supported client and on, off or inherit", ROUTE_TO_USAGE); + } + if (client !== undefined && device === "all") { + throw new CliUsageError("--device all applies only to status or sync; omit --device to change the Home default", ROUTE_TO_USAGE); + } + if (modelNames !== undefined && (modelNames !== "alias" && modelNames !== "routed")) { + throw new CliUsageError("--model-names must be alias or routed", ROUTE_TO_USAGE); + } + if (accounts !== undefined && (client !== "aside" || action !== "on")) { + throw new CliUsageError("--accounts requires aside on", ROUTE_TO_USAGE); + } + if (modelNames !== undefined && (action !== "on" || (client !== "hermes" && client !== "commandcode"))) { + throw new CliUsageError("--model-names requires hermes or commandcode on", ROUTE_TO_USAGE); + } + if (overwriteConflict && !sync && action !== "on") { + throw new CliUsageError("--overwrite-conflict applies only to on or sync", ROUTE_TO_USAGE); + } + if (local && (client === undefined || !["off", "inherit"].includes(action ?? "") || overwriteConflict)) { + throw new CliUsageError("--local supports only client off or inherit", ROUTE_TO_USAGE); + } + if (action === "inherit" && device === undefined && !local) { + throw new CliUsageError("inherit requires --device or --local", ROUTE_TO_USAGE); + } + + // Detect Child before runtimeRequest: these operations must never enter its Home relay. + const child = (deps.readChildContext ?? readLinkChildContext)(); + let result: unknown; + let requestFailure: RuntimeApiError | undefined; + if (child) { + if (device !== undefined && device !== "this" && device !== child.linkId) { + throw new CliUsageError("A Child can target only --device this or its own link id", ROUTE_TO_USAGE); + } + if (client !== undefined && !local) { + const command = ["ocx route to", client, action, "--device", child.linkId, + ...(accounts === undefined ? [] : ["--accounts", Array.isArray(accounts) ? accounts.join(",") : accounts]), + ...(modelNames === undefined ? [] : ["--model-names", modelNames]), + ...(overwriteConflict ? ["--overwrite-conflict"] : [])].join(" "); + throw new RuntimeApiError(`${linkChildRefusal(child)}\nRun on the Home: ${command}`, 409, null); + } + const api = await (deps.loadLocal ?? loadLocal)(); + result = read ? await api.readLocalRouteToStatus(child) + : sync ? await api.syncLocalRouteTo(child, overwriteConflict) + : await api.setLocalRouteToOff(child, client!, action === "off"); + } else { + if (local) throw new CliUsageError("--local requires a Remote Link Child", ROUTE_TO_USAGE); + const body = { ...(device === undefined ? {} : { device }), + ...(overwriteConflict ? { overwriteConflict: true } : {}) }; + try { + result = read ? await runtimeRequest(`/api/route-to${device === undefined ? "" : `?device=${encodeURIComponent(device)}`}`, { redirect: "error" }, deps) + : await runtimeRequest(sync ? "/api/route-to/sync" : "/api/route-to", { + method: sync ? "POST" : "PUT", redirect: "error", + headers: { "content-type": "application/json" }, + body: JSON.stringify(sync ? body : { client, action, ...body, + ...(accounts === undefined ? {} : { accounts }), ...(modelNames === undefined ? {} : { modelNames }) }), + }, deps); + } catch (error) { + if (!(error instanceof RuntimeApiError) || !error.body || typeof error.body !== "object" + || !("schema" in error.body) || error.body.schema !== "ocx.route-to/1") throw error; + requestFailure = error; + result = error.body; + } + } + const status = result as RouteToStatus; + if (!status || status.schema !== "ocx.route-to/1" || !Array.isArray(status.devices) || status.devices.length === 0 + || (status.deferred !== undefined && (!status.deferred || status.deferred.reason !== "intent_or_catalog_pending" + || !Array.isArray(status.deferred.clients) || status.deferred.clients.some(client => !isRouteClientId(client)))) + || (status.syncs !== undefined && (!Array.isArray(status.syncs) || status.syncs.some(row => !row || typeof row.id !== "string" + || !["synced", "failed", "pending", "unsupported"].includes(row.state) || (row.reason !== undefined && typeof row.reason !== "string")))) + || status.devices.some(row => !row || typeof row.id !== "string" || !Array.isArray(row.clients) + || (row.routeIntent === undefined ? row.clients.length === 0 + : row.routeIntent !== "unsupported" || row.clients.length !== 0 || typeof row.reason !== "string") + || (row.alias !== undefined && typeof row.alias !== "string") + || row.clients.some(client => !client || !isRouteClientId(client.client) + || (client.intent !== "on" && client.intent !== "off") + || !["builtin", "default", "device", "native", "local"].includes(client.source) + || (client.applied !== null && !["current", "stale", "not_installed", "conflict", "failed"].includes(client.applied)) + || (client.target !== undefined && typeof client.target !== "string")))) { + throw new RuntimeApiError("Invalid route-to status response; outcome is unverified. Read status before retrying.", 502, null); + } + if (!read) { + const requested = child?.linkId ?? (device === undefined || device === "this" ? "home" : device); + const matching = status.devices.filter(row => row.id === requested || row.alias === requested); + if ((requested !== "all" && matching.length !== 1) + || (client !== undefined && !matching[0]?.clients.some(row => row.client === client))) { + throw new RuntimeApiError("Invalid route-to status response; requested device/client is unverified. Read status before retrying.", 502, null); + } + } + printData(status, json, routeToStatusLines(status)); + if (requestFailure) throw requestFailure; + if (status.syncs?.some(row => row.state === "failed")) { + throw new RuntimeApiError("Route sync failed for one or more devices. Read status before retrying.", 502, null); + } + if (sync && status.deferred) { + throw new RuntimeApiError("Local route sync is deferred; not all client configurations were reconciled. Retry when the Home and Child catalog are available.", 503, null); + } + const failedClients = status.devices.flatMap(row => row.clients.filter(entry => entry.applied === "failed" || entry.applied === "conflict")); + if (!read && failedClients.some(entry => client === undefined || entry.client === client)) { + throw new RuntimeApiError("Route intent returned unresolved client failures or conflicts. Read status before retrying.", 409, null); + } + if (!read && client !== undefined && failedClients.length > 0) { + console.error("Route status includes unresolved failures or conflicts for other clients; review their status."); + } +} + +export function handleRouteToCommand(argv: string[], deps: RouteToDeps = {}): Promise { + return runCliAction(() => routeToAction(argv, deps)); +} diff --git a/src/cli/sync-command.ts b/src/cli/sync-command.ts index 1aec4298005..dfaa0deb1d0 100644 --- a/src/cli/sync-command.ts +++ b/src/cli/sync-command.ts @@ -50,7 +50,8 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { if (expectation || clientState.kind === "connected") { try { const { syncConnectedClient } = await import("../client/connect"); - const result = await syncConnectedClient(expectation ? { restartCodex, expect: expectation } : { restartCodex }); + const result = await syncConnectedClient({ restartCodex, ...(expectation ? { expect: expectation } : {}), + ...(syncArgs.includes("--overwrite-conflict") ? { overwriteConflict: true, ...(flags.overwriteClient ? { overwriteClient: flags.overwriteClient } : {}) } : {}) }); if (expectation) { // The root preflight skipped the shim repair for a pinned sync; the identity held, so // run it now, as an unpinned sync would have. @@ -75,6 +76,13 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { for (const line of describeHomeCodexSettings(settings.document.settings)) console.log(` ${terminalSafeText(line)}`); } } + if (result.routeIntent) { + for (const row of result.routeIntent.outcomes) { + if (row.outcome === "current") continue; + console.error(`Route to ${row.client}: ${row.outcome}.`); + if (row.outcome !== "not_installed") process.exitCode = 1; + } + } await handleConnectedSyncCatalogWrite(result, restartScope); if (result.applyRecordFailed) { console.error("The Home's Codex settings were applied, but the apply record could not be written; the client runtime was not restarted. Run 'ocx sync' again."); @@ -191,6 +199,18 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`); } } + if (synced.status !== "refused") { + const { homeRouteIntentInUse, readRouteIntentState } = await import("../routing/route-intent-state"); + if (homeRouteIntentInUse(readRouteIntentState())) { + const { syncHomeRouteTo } = await import("../routing/route-to-local"); + const { packageVersion } = await import("../lib/package-version"); + const outcomes = await syncHomeRouteTo({ ...deps.loadConfig(), ...(live ? { port: live.port } : {}) }, packageVersion("0.0.0"), syncArgs.includes("--overwrite-conflict"), flags.overwriteClient, + undefined, { onAsideUnresolved: message => console.warn(message) }); + for (const row of outcomes) { + if (row.outcome !== "current" && row.outcome !== "not_installed") { console.error(`${row.client}: ${row.outcome}`); code = 1; } + } + } + } return code || (typeof process.exitCode === "number" ? process.exitCode : 0); }; let code: number; @@ -216,7 +236,10 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { return { state: "failed", reason: "platform-probe-failed", exit: null }; } try { - const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), argv: remotePinnedSyncArgv(link.id, platform) }), { timeoutMs: Math.max(0, deadline - Date.now()) }); + // An ephemeral ocx-child gets the plain catalog sync: it never reads route intent, so it + // has nothing to overwrite and refuses the flags. + const overwrite = link.childKind !== "ephemeral" && deps.args.includes("--overwrite-conflict"); + const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), argv: remotePinnedSyncArgv(link.id, platform, overwrite, overwrite ? flags.overwriteClient : undefined) }), { timeoutMs: Math.max(0, deadline - Date.now()) }); return { ...classifySyncResult(result), exit: result.code }; } catch (error) { if (error instanceof FanoutProcessError) return { state: error.reason === "deadline-exceeded" || error.reason === "termination-unconfirmed" ? "timed-out" : "failed", reason: error.reason, exit: null }; diff --git a/src/cli/sync-fanout.ts b/src/cli/sync-fanout.ts index bbbcdf487a5..587d8257424 100644 --- a/src/cli/sync-fanout.ts +++ b/src/cli/sync-fanout.ts @@ -2,6 +2,7 @@ import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver"; import type { LinkRecord, LinkStore } from "../link/store"; import type { LinkStatusDto } from "../link/status-projection"; import { LinkStatusError } from "./sync-link-status"; +import { ROUTE_OVERWRITE_MIN_CHILD_VERSION } from "../link/remote-windows"; export const REMOTE_LINK_SYNC_MESSAGE = "Remote Link Home catalog synchronized."; export const STALE_REMOTE_CATALOG_MESSAGE = "Home unavailable; retained and applied the last-known-good remote catalog (stale)."; @@ -50,6 +51,9 @@ export function classifySyncResult(result: { code: number; stdout: string; stder if (code === 121) { if (last === "ocx-fanout:child-ocx-lacks-expect-link") return skipped("child-ocx-lacks-expect-link"); if (last === "ocx-fanout:child-ocx-version-unknown") return skipped("child-ocx-version-unknown"); + // --overwrite-conflict was refused before any sync: an older ocx would ignore it. + if (last === "ocx-fanout:child-ocx-lacks-overwrite") return skipped("child-ocx-lacks-overwrite"); + if (last === "ocx-fanout:child-ephemeral-no-overwrite") return skipped("child-ephemeral-no-overwrite"); return failure("child-sync-failed"); } const markers = lines.map(line => /^ocx-fanout:selected:(.+)$/.exec(line)).filter(match => match !== null); @@ -87,6 +91,8 @@ export type FanoutDocument = { schema: "ocx.sync.fanout/1"; home: HomeResult; fa 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-ocx-lacks-overwrite") return `Update OpenCodex on ${alias} to >= ${ROUTE_OVERWRITE_MIN_CHILD_VERSION} to sync with --overwrite-conflict, or sync without it.`; + if (reason === "child-ephemeral-no-overwrite") return `${alias} runs the ephemeral ocx-child, which has no route intent to overwrite; sync without --overwrite-conflict.`; 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."; diff --git a/src/cli/sync-flags.ts b/src/cli/sync-flags.ts index 845b5e8068d..5dec1fd6260 100644 --- a/src/cli/sync-flags.ts +++ b/src/cli/sync-flags.ts @@ -1,10 +1,16 @@ -export type SyncFanoutFlags = { ok: true; localOnly: boolean; json: boolean } | { ok: false; message: string }; +import { isRouteClientId, type RouteClientId } from "../link/route-intent"; +export type SyncFanoutFlags = { ok: true; localOnly: boolean; json: boolean; overwriteClient?: RouteClientId } | { ok: false; message: string }; export function parseSyncFanoutFlags(args: readonly string[]): SyncFanoutFlags { - for (const flag of ["--local-only", "--json"]) { + for (const flag of ["--local-only", "--json", "--overwrite-conflict"]) { if (args.filter(arg => arg === flag).length > 1 || args.some(arg => arg.startsWith(`${flag}=`))) { return { ok: false, message: `ocx sync accepts ${flag} once without a value.` }; } } - return { ok: true, localOnly: args.includes("--local-only"), json: args.includes("--json") }; + const selectors = args.flatMap((arg, index) => arg === "--overwrite-client" ? [args[index + 1]] : arg.startsWith("--overwrite-client=") ? [arg.slice(19)] : []); + if (selectors.length > 1 || (selectors.length && (!args.includes("--overwrite-conflict") || !isRouteClientId(selectors[0])))) { + return { ok: false, message: "--overwrite-client needs one supported client and --overwrite-conflict." }; + } + return { ok: true, localOnly: args.includes("--local-only"), json: args.includes("--json"), + ...(selectors.length ? { overwriteClient: selectors[0] as RouteClientId } : {}) }; } diff --git a/src/client/connect.ts b/src/client/connect.ts index af6bf711eaf..4bf34ec57e2 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -2,10 +2,12 @@ import { createHash } from "node:crypto"; import { existsSync, lstatSync, + mkdirSync, readFileSync, unlinkSync, } from "node:fs"; import { hostname } from "node:os"; +import { dirname } from "node:path"; import { atomicWriteFile, loadConfig, withConfigMutationLockSync } from "../config"; import { claudeDesktopIntegrationEnabledNow } from "../codex/desired-state"; import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; @@ -28,9 +30,11 @@ import { type CodexRoutingTarget, } from "../codex/inject"; import { + JOURNAL_PATH, journalOwner, restoreJournalState, } from "../codex/journal"; +import { readCodexHomeJournal } from "../codex/codex-home-owner"; import { DEFAULT_CATALOG_PATH, getCodexHome } from "../codex/paths"; import { readServiceApiTokenState, @@ -96,7 +100,15 @@ import { import { hubStateCachePath } from "./hub-state"; import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state"; import { isLinkPort } from "../link/ports"; -import { clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime"; +import { assertRouteIntentPort, clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime"; +import { prepareConnectedRouteIntent, initialConnectRouteIntent, connectedRouteSelectedClients, applyConnectedRouteIntent } from "../integrations/route-intent-connected"; +import { clearRouteIntentReceipt, readRouteIntentReceiptStrict } from "../integrations/route-intent-state"; +import type { RouteIntentReceipt } from "../integrations/route-intent-contract"; +import { clearHomeRouteIntentRecord } from "./link-home"; +import { clearInitialRouteIntent, recordInitialRouteIntent } from "../integrations/route-intent-initial"; +import { cleanupConnectedRouteIntent } from "../integrations/route-intent-disconnect"; +import { nativeIntentOriginalsPath, readNativeIntentOriginals, restoreNativeIntentOriginals } from "./route-intent-native-originals"; +import { clearFileIntentOriginals } from "./route-intent-file-originals"; class RotationRecoveryRequiredError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -110,6 +122,8 @@ export interface ConnectOptions { managementUrl?: string; credential: OneTimeConnectCredential | LinkClientCredential; selectedClients: OcxConnectedClientId[]; + /** Provisioning selection retained until Home revises that client's device policy. */ + selectedClientsExplicit?: boolean; managementTransport: "direct" | "relay"; transport?: "hub" | "link"; link?: { tunnelPort: number; linkId: string }; @@ -605,7 +619,9 @@ async function connectClientUnlocked( let priorCatalog: CatalogSnapshot | null = null; let writtenCatalogFingerprint: string | null = null; let injectionCommitted = false; + let restoreNativeIntent = false; let committed = false; + let initialSelectionWritten = false; // Before any catalog, journal, client-state or credential write: `CODEX_HOME` is shared with the // live proxy a sibling instance runs beside, and connecting re-points it (`sibling-start.ts`). if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage()); @@ -636,6 +652,7 @@ async function connectClientUnlocked( // Only link mode needs the local port before the hub is contacted; hub mode keeps reading config // after the catalog download, so its stderr order (catalog refusal first) is unchanged. const earlyConfig = linkMode ? loadConfig() : undefined; + if (earlyConfig) assertRouteIntentPort(earlyConfig.port); if (earlyConfig && (!Number.isInteger(earlyConfig.port) || earlyConfig.port < 1 || earlyConfig.port > 65535)) { throw new Error("link mode requires a valid local config port"); } @@ -721,37 +738,62 @@ async function connectClientUnlocked( assertClientCatalogCompatible(catalog.body, deps.catalogCompatibility); writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertActiveConnectingState(persisted.fingerprint); + mkdirSync(dirname(DEFAULT_CATALOG_PATH), { recursive: true }); atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body); return sha256(catalog.body); }), deps.lifecycleLockDeps); const config = earlyConfig ?? loadConfig(); + const routeIntent = initialConnectRouteIntent(linkMode && linkMetadata + ? await prepareConnectedRouteIntent({ transport: "link", link: linkMetadata, apiKeyId }, admissionToken, deps.linkHome, + () => assertActiveConnectingState(persisted.fingerprint)) : undefined, + options.selectedClients, options.selectedClientsExplicit); + const selectedClients = connectedRouteSelectedClients(routeIntent, options.selectedClients); const target = routingTarget(serverUrl, linkMode ? config.port : undefined, config); // A link Child writes the Home's OpenCodex-owned Codex settings, not its own (#6957). A read // that fails here keeps the local values; the next `ocx sync` applies the Home's. // Read only: the record is written after the connection commits, so a rollback leaves none. - const homeSettings = linkMode && linkMetadata && options.selectedClients.includes("codex") + const homeSettings = linkMode && linkMetadata && selectedClients.includes("codex") ? await fetchHomeCodexSettings({ transport: "link", link: linkMetadata, apiKeyId }, admissionToken, deps.linkHome) : undefined; const injectConfig = { ...(homeSettings?.kind === "fresh" ? overlayHomeCodexSettings(config, homeSettings.document.settings) : config), syncResumeHistory: false, }; - const preflight = await injectCodexConfig(config.port, injectConfig, { + const beforeCodexWrite = () => { + assertActiveConnectingState(persisted.fingerprint); + if (routeIntent && (!connectedRouteSelectedClients(routeIntent, options.selectedClients).includes("codex") + || loadConfig().port !== config.port)) throw new Error("Codex route intent or runtime changed; retry connect"); + }; + if (!options.noSync && routeIntent && selectedClients.includes("codex") && existsSync(getCodexHome())) { + const { persistNativeRouteIntent } = await import("./route-intent-apply"); + // Armed before publication: the write-ahead originals record can exist even when the + // config write or its bookkeeping throws, and rollback must still put the field back. + restoreNativeIntent = true; + withClientLifecycleSync(() => withConfigMutationLockSync(() => { + beforeCodexWrite(); + // Home intent owns a selected installed client, including a standalone OFF. Keep + // compensation field-scoped: a failed enrollment restores the recorded standalone + // value, keeps a later edit by the person, and keeps disjoint config changes. + persistNativeRouteIntent("codex", true, beforeCodexWrite, injectConfig, true); + }), deps.lifecycleLockDeps); + } + const preflight = !selectedClients.includes("codex") || (routeIntent && !existsSync(getCodexHome())) + ? { success: true, message: "Codex is not selected or installed" } : await injectCodexConfig(config.port, injectConfig, { validateOnly: true, routingTarget: target, catalogPath: DEFAULT_CATALOG_PATH, journalOwner: { kind: "client", apiKeyId }, - beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint), + beforeClientWrite: beforeCodexWrite, }); if (!preflight.success) throw new Error(preflight.message); - if (!options.noSync && options.selectedClients.includes("codex")) { + if (!options.noSync && selectedClients.includes("codex") && (!routeIntent || existsSync(getCodexHome()))) { const injected = await injectCodexConfig(config.port, injectConfig, { routingTarget: target, catalogPath: DEFAULT_CATALOG_PATH, journalOwner: { kind: "client", apiKeyId }, - beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint), + beforeClientWrite: beforeCodexWrite, }); if (!injected.success || injected.status === "skipped") throw new Error(injected.message); injectionCommitted = true; @@ -763,7 +805,7 @@ async function connectClientUnlocked( serverUrl, managementUrl, managementTransport: options.managementTransport, - selectedClients: [...options.selectedClients], + selectedClients: [...selectedClients], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId, tokenFingerprint: persisted.fingerprint, @@ -779,6 +821,10 @@ async function connectClientUnlocked( }; withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertActiveConnectingState(persisted.fingerprint); + if (linkMetadata && options.selectedClientsExplicit) { + recordInitialRouteIntent(linkMetadata.linkId, routeIntent?.document, options.selectedClients); + initialSelectionWritten = true; + } clearClientConnectPending(persisted.fingerprint); commitClientConnection(connection); committed = true; @@ -795,6 +841,21 @@ async function connectClientUnlocked( console.warn("Warning: the Home's Codex settings were applied, but the apply record was not written; run 'ocx sync' to record it."); } } + if (!options.noSync && routeIntent) { + const appliedConnection = withClientLifecycleSync(() => { + const current = readClientConnectionState(); + if (current.kind !== "connected" || !Bun.deepEquals(current.value, connection, true)) throw new Error("client_connection_changed"); + return current.value; + }, deps.lifecycleLockDeps); + try { await applyConnectedRouteIntent(connection, admissionToken, routeIntent, { + codexApplied: injectionCommitted, lifecycleLockDeps: deps.lifecycleLockDeps, beforeWrite: () => { + deps.signal?.throwIfAborted(); assertClientConnectionUnchanged(appliedConnection); + const token = readServiceApiTokenState(); + if (token.kind !== "present" || token.fingerprint !== connection.tokenFingerprint) throw new Error("client_token_changed"); + }, + }, deps.linkHome); } + catch { console.warn("Warning: local route intent needs reconciliation; run 'ocx route to sync'."); } + } return connection; } catch (error) { const rollbackFailures: string[] = []; @@ -805,10 +866,25 @@ async function connectClientUnlocked( try { withClientLifecycleSync(() => withConfigMutationLockSync(() => { assertNoClientDisconnectPending(); + if (initialSelectionWritten && !committed && linkMetadata) { + try { clearInitialRouteIntent(linkMetadata.linkId); } + catch { rollbackFailures.push("initial route selection rollback unavailable"); } + } + let nativeRestoreFailed = false; + if (restoreNativeIntent && !committed && pendingConnectFingerprint) { + const fingerprint = pendingConnectFingerprint; + try { restoreNativeIntentOriginals(() => assertConnectingState(fingerprint)); } + catch { + // Like disconnect, keep the token (and the marker proving it is this attempt's) until the + // standalone switches are back; ocx disconnect finishes this rollback. + nativeRestoreFailed = true; + rollbackFailures.push(`Standalone native switches were not restored, so the enrollment token was kept. Repair the config or ${nativeIntentOriginalsPath()}, then run ocx disconnect to finish the rollback.`); + } + } if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) { rollbackFailures.push("catalog rollback did not match the written artifact"); } - if (pendingConnectFingerprint) { + if (pendingConnectFingerprint && !nativeRestoreFailed) { const removed = removeServiceApiTokenFileIfOwned(pendingConnectFingerprint); if (removed === "changed") rollbackFailures.push("service token changed during rollback"); // Final commit may fail after this attempt already cleared its marker under the same lock. @@ -839,6 +915,7 @@ async function connectClientUnlocked( } export interface ConnectedSyncResult { + routeIntent?: RouteIntentReceipt; catalogWritten: boolean; cacheSynced: boolean; injected: boolean; @@ -858,6 +935,8 @@ export interface ConnectedSyncResult { export interface ConnectedSyncOptions { restartCodex?: boolean; + overwriteConflict?: boolean; + overwriteClient?: import("../link/route-intent").RouteClientId; /** * #7694: refuse unless this is the connection found under the lock. A mismatch throws * `ClientSyncExpectationError` before any request or write. @@ -930,17 +1009,21 @@ async function syncConnectedClientUnlocked( // with one it cannot. Refusing leaves the connection and the existing catalog exactly as // they were, which is the known-good state. if (downloaded) assertClientCatalogCompatible(downloaded.body, deps.catalogCompatibility); + const routeIntent = await prepareConnectedRouteIntent(initial.connection, initial.token.token, deps.linkHome, () => { + initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(initial.connection); + }); const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => { initial.assertRuntimeCurrent(); assertClientConnectionUnchanged(initial.connection); const token = readServiceApiTokenState(); if (token.kind !== "present" || token.fingerprint !== initial.token.fingerprint) throw new Error("client_token_changed"); - if (!downloaded) { validLocalCatalog(); return initial.connection; } - atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); + if (!downloaded) validLocalCatalog(); + else atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); const updated = { ...initial.connection, - catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"), - catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString(), + selectedClients: [...connectedRouteSelectedClients(routeIntent, initial.connection.selectedClients)], + ...(downloaded ? { catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"), + catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString() } : {}), }; commitClientConnection(updated); return updated; @@ -948,6 +1031,7 @@ async function syncConnectedClientUnlocked( // Read-only: injection invokes this while N/C are held. Acquiring L here would invert C→L. const beforeClientWrite = () => { initial.assertRuntimeCurrent(); + if (isLinkConnection(next)) assertRouteIntentPort(loadConfig().port); assertClientConnectionUnchanged(next); const token = readServiceApiTokenState(); if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed"); @@ -957,7 +1041,13 @@ async function syncConnectedClientUnlocked( let homeSettings: HomeCodexSettingsSource | undefined; let appliedRecord: HomeCodexSettingsRecord | undefined; let applyRecordFailed = false; - if (next.selectedClients.includes("codex")) { + if (next.selectedClients.includes("codex") && (!routeIntent || existsSync(getCodexHome()))) { + const beforeCodexWrite = () => { + beforeClientWrite(); + if (routeIntent && !connectedRouteSelectedClients(routeIntent, next.selectedClients).includes("codex")) { + throw new Error("Codex route intent changed; retry sync"); + } + }; let config = loadConfig(); // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog. if (isLinkConnection(next)) { @@ -971,9 +1061,15 @@ async function syncConnectedClientUnlocked( withClientWrite(() => withdrawChangingHomeCodexSettingsRecord(linkId, document)); } } + // Off persisted false. Reconcile the effective Home intent before the injector rereads + // that durable gate, retaining local opt-outs and the owner/runtime/token commit fences. + if (routeIntent) { + const { persistNativeRouteIntent } = await import("./route-intent-apply"); + withClientLifecycleSync(() => persistNativeRouteIntent("codex", true, beforeCodexWrite, config, true), deps.lifecycleLockDeps); + } const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, { routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH, - journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite, + journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite: beforeCodexWrite, }); if (!result.success || result.status === "skipped") throw new Error(result.message); injected = true; @@ -992,6 +1088,9 @@ async function syncConnectedClientUnlocked( } } } + const routeReceipt = await applyConnectedRouteIntent(next, initial.token.token, routeIntent, + { beforeWrite: beforeClientWrite, codexApplied: injected, lifecycleLockDeps: deps.lifecycleLockDeps, + overwriteConflict: options.overwriteConflict, overwriteClient: options.overwriteClient }, deps.linkHome); const cacheSynced = withClientLifecycleSync(() => { // Acquire K before the guarded C commit; never acquire K from inside C. const owningHome = getCodexHome(); @@ -1001,10 +1100,11 @@ async function syncConnectedClientUnlocked( return result.kind === "completed" && result.value; }, deps.lifecycleLockDeps); return { - catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, + catalogWritten: downloaded !== undefined, cacheSynced, injected, stale: stale || routeReceipt?.catalogPending === true || routeIntent?.stale === true, ...(homeSettings ? { homeSettings } : {}), ...(appliedRecord ? { appliedRecord } : {}), ...(applyRecordFailed ? { applyRecordFailed } : {}), + ...(routeReceipt ? { routeIntent: routeReceipt } : {}), connection: next, }; } @@ -1069,28 +1169,101 @@ function verifyDisconnectCatalog(receipt: DesktopDisconnectReceipt): void { } } -function restoreConnectedCodex(connection: OcxClientConnectionConfig): void { - if (!connection.selectedClients.includes("codex")) return; - const owner = journalOwner(); +function restoreConnectedCodex(connection: OcxClientConnectionConfig, receipt: DesktopDisconnectReceipt, recoverConfig = false): string | undefined { + const inspection = readCodexHomeJournal(JOURNAL_PATH); + const owner = journalOwner({ readOnly: true }); + const explicitOwner = inspection.kind === "read" ? inspection.journal.owner as Record | undefined : undefined; + const invalidOwner = explicitOwner !== undefined && (!explicitOwner || typeof explicitOwner !== "object" || Array.isArray(explicitOwner) + || !(explicitOwner.kind === "client" && typeof explicitOwner.apiKeyId === "string" && explicitOwner.apiKeyId.length > 0 + || explicitOwner.kind === "process" && typeof explicitOwner.pid === "number" && Number.isSafeInteger(explicitOwner.pid) && explicitOwner.pid > 0)); + if (inspection.kind === "unknown" || invalidOwner || (inspection.kind === "read" && owner === null)) { + throw new Error("disconnect refused: Codex journal ownership is unknown; journal and connection credentials were retained"); + } + // Deselection records desired state, not completed cleanup. A failed off still leaves + // this client's journal to drain; an unrelated process journal grants no such authority. + if (!connection.selectedClients.includes("codex") && owner?.kind !== "client") { + if (inspection.kind === "missing" && isCodexRoutingInjected()) { + throw new Error("disconnect refused: Codex routing is injected but no journal records the original state"); + } + return; + } if (owner && owner.kind === "client" && owner.apiKeyId !== connection.apiKeyId) { throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key"); } if (owner !== null) { - if (!restoreJournalState().complete) throw new Error("disconnect refused: Codex journal restore was partial"); + const restored = restoreJournalState({ preserveConfigEdits: true, + ...(recoverConfig ? { recoverConfigForClient: connection.apiKeyId } : {}), + beforeWrite: () => { + const state = readClientConnectionState(), currentReceipt = readDesktopDisconnectReceipt(); + const token = readServiceApiTokenState(); + if (state.kind !== "connected" || JSON.stringify(state.value) !== JSON.stringify(connection) + || currentReceipt.kind !== "valid" || JSON.stringify(currentReceipt.value) !== JSON.stringify(receipt) + || token.kind !== "present" || token.fingerprint !== receipt.tokenFingerprint) throw new Error("client_disconnect_owner_changed"); + }, + }); + if (!restored.complete) throw new Error( + "disconnect refused: Codex journal restore was partial; journal and connection credentials were retained. " + + "Retry 'ocx disconnect --recover-config --yes' to privately back up current Codex config/profile and restore only evidenced OCX-owned fields (retain --keep-catalog if originally requested). " + + "Unknown ownership and edited or unverified profiles remain refused.", + ); + return restored.recoveryBackupDir; } else if (isCodexRoutingInjected()) { throw new Error("disconnect refused: Codex routing is injected but no journal records the original state"); } } +/** + * A disconnected machine with no unfinished disconnect can still hold the standalone native + * switches an interrupted or failed enrollment displaced, and the token that enrollment kept + * because it could not restore them. Plain `ocx disconnect` restores the switches, then removes + * that token only while the pending-connect marker proves it is the enrollment's own. + * `ocx disconnect --orphaned-token` never touches the record. + */ +function drainLoneNativeIntentOriginals(lifecycleLockDeps?: ClientLifecycleLockDeps): { + restored: true; tokenRemoved: boolean; catalogRemoved: false; catalogRestored: false; apiKeyId: string; restartRequired: false; nativeIntentOnly: true; +} | null { + const refused = (error: unknown) => new Error(`disconnect refused: the standalone native switches recorded in ${nativeIntentOriginalsPath()} were not restored (${error instanceof Error ? error.message : String(error)}). Repair the config or that record (remove it only after setting the switches by hand), then rerun ocx disconnect.`); + return withClientLifecycleSync(() => withConfigMutationLockSync(() => { + const read = readDesktopDisconnectReceipt(); + if (read.kind === "unsafe" || (read.kind === "valid" && read.value.phase !== "complete")) return null; + if (readClientConnectionState().kind !== "disconnected") return null; + let present: boolean; + try { present = readNativeIntentOriginals() !== null; } catch (error) { throw refused(error); } + if (!present) return null; + try { + restoreNativeIntentOriginals(() => { + assertNoClientDisconnectPending(); + if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed"); + }); + } catch (error) { throw refused(error); } + const token = readServiceApiTokenState(); + let tokenRemoved = false; + if (token.kind === "present" && pendingClientConnectMayOwnToken(token.fingerprint)) { + const removed = removeServiceApiTokenFileIfOwned(token.fingerprint); + if (removed === "changed") throw new Error("client_token_changed"); + tokenRemoved = removed === "removed"; + clearClientConnectPending(token.fingerprint); + } + return { restored: true, tokenRemoved, catalogRemoved: false, catalogRestored: false, apiKeyId: "", restartRequired: false, nativeIntentOnly: true }; + }), lifecycleLockDeps); +} + export async function disconnectClient( - options: { keepCatalog?: boolean; expectedOwner?: DesktopRemoteOwner } = {}, + options: { keepCatalog?: boolean; expectedOwner?: DesktopRemoteOwner; recoverConfig?: boolean } = {}, deps: Pick = {}, ): Promise<{ restored: boolean; tokenRemoved: boolean; catalogRemoved: boolean; catalogRestored: boolean; apiKeyId: string; desktopRestoration?: "owned_projection" | "standard_fallback" | "selection_preserved"; restartRequired: boolean; + recoveryBackupDir?: string; + /** Only standalone native switches an interrupted enrollment displaced were restored. */ + nativeIntentOnly?: true; }> { if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage()); + if (!options.expectedOwner) { + const drained = drainLoneNativeIntentOriginals(deps.lifecycleLockDeps); + if (drained) return drained; + } const keepCatalog = options.keepCatalog === true; const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => { const read = readDesktopDisconnectReceipt(); @@ -1130,11 +1303,14 @@ export async function disconnectClient( return { receipt, connection }; }), deps.lifecycleLockDeps); + // This sibling step owns only file integrations; failures retain the prepared receipt/token. + if (prepared.connection?.transport === "link" && !disconnectAtLeast(prepared.receipt, "desktop_restored")) { + await cleanupConnectedRouteIntent(prepared.connection, prepared.receipt, deps.lifecycleLockDeps, options.recoverConfig === true); + } // The prepared receipt blocks even a sync queued for its actual N/C injection commit. // Codex-only restore MUST stay outside L; it never invokes Desktop cleanup. - if (prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored")) { - restoreConnectedCodex(prepared.connection); - } + const recoveryBackupDir = prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored") + ? restoreConnectedCodex(prepared.connection, prepared.receipt, options.recoverConfig === true) : undefined; return withClientLifecycleSync(held => withConfigMutationLockSync(() => { const read = readDesktopDisconnectReceipt(); @@ -1185,6 +1361,17 @@ export async function disconnectClient( } advance("catalog_settled", { catalogAfter: catalogAfterState() }); } else verifyDisconnectCatalog(receipt); + if (connection?.transport === "link" && !disconnectAtLeast(receipt, "removing_token")) { + if (connection.link) clearInitialRouteIntent(connection.link.linkId); + clearHomeRouteIntentRecord(); + // Its clients were restored by the route cleanup above, which phase gating never reruns. + clearFileIntentOriginals(); + if (readRouteIntentReceiptStrict()?.linkId === connection.link?.linkId) clearRouteIntentReceipt(); + } + // Standalone native switches come back before the token that displaced them is removed. + // The record is machine-scoped, so any transport drains one a crashed enrollment left. + // The locks and the receipt checked above already fence this; a retry is idempotent. + if (!disconnectAtLeast(receipt, "removing_token")) restoreNativeIntentOriginals(() => {}); if (!disconnectAtLeast(receipt, "removing_token")) advance("removing_token"); const tokenRemoval = removeServiceApiTokenFileIfOwned(receipt.tokenFingerprint); if (tokenRemoval === "changed") throw new Error("client_token_changed"); @@ -1208,6 +1395,7 @@ export async function disconnectClient( requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner)); if (receipt.phase !== "complete") advance("complete"); return { + ...(recoveryBackupDir ? { recoveryBackupDir } : {}), restored: true, tokenRemoved: tokenRemoval === "removed", catalogRemoved: !keepCatalog, catalogRestored: !keepCatalog && receipt.catalogAfter?.kind === "file", apiKeyId: receipt.owner.apiKeyId, restartRequired: desktop.restartRequired, diff --git a/src/client/link-home.ts b/src/client/link-home.ts index 3452e602ee1..fede84eb9d7 100644 --- a/src/client/link-home.ts +++ b/src/client/link-home.ts @@ -7,7 +7,8 @@ * process that took the tunnel port never sees the key. */ import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { parseHomeRouteIntentDocument, parseRouteClientOutcomes, ROUTE_CLIENT_IDS, routeIntentDigest, routeClientOutcomesForPeer, type HomeRouteIntentDocument, type RouteClientOutcome } from "../link/route-intent"; import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; import { readBoundedResponseBytes } from "../lib/bounded-body"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; @@ -20,6 +21,7 @@ import { import { LINK_CHILD_PARITY_PATH, type LinkChildParityReport } from "../link/child-parity"; import { LINK_CODEX_SETTINGS_PATH, + LINK_ROUTE_INTENT_PATH, LINK_MANAGEMENT_PREFIX, linkManagementReadAllowed, } from "../link/management-routes"; @@ -336,19 +338,22 @@ export function recordHomeCodexSettings( /** * Tell the Home what this Child last applied and when its runtime started (OCX-6). Resolves to - * `"accepted"`, `"unsupported"` for a Home that predates the route, or `"failed"`. + * `"accepted"`, `"unsupported"` for a Home that predates the route, `"schema-rejected"` + * for a payload/schema refusal, or `"failed"` for every other refusal/transport failure. */ export async function sendLinkChildParityReport( connection: LinkHomeConnection, admissionKey: string, report: LinkChildParityReport, deps: LinkHomeDeps = {}, -): Promise<"accepted" | "unsupported" | "failed"> { +): Promise<"accepted" | "unsupported" | "schema-rejected" | "failed"> { let response: Response; try { const target = linkTarget(connection, admissionKey); const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_CHILD_PARITY_PATH}`; - const body = JSON.stringify(report); + const body = JSON.stringify({ ...report, ...(report.routeClientOutcomes === undefined ? {} : { + routeClientOutcomes: routeClientOutcomesForPeer(report.routeClientOutcomes), + }) }); const request: RequestInit = { method: "POST", headers: { @@ -367,5 +372,88 @@ export async function sendLinkChildParityReport( } try { await response.body?.cancel(); } catch { /* best effort */ } if (response.status === 404) return "unsupported"; + if ([400, 413, 422].includes(response.status)) return "schema-rejected"; return response.status === 204 ? "accepted" : "failed"; } + +export interface HomeRouteIntentRecord { + linkId: string; + fetchedAt: string; + appliedAt: string; + document: HomeRouteIntentDocument; + appliedRouteIntentDigest: string | null; + routeClientOutcomes: RouteClientOutcome[]; +} +export function homeRouteIntentPath(): string { return join(linkDir(), "home-route-intent.json"); } +function completedRouteIntentDigest(document: HomeRouteIntentDocument, outcomes: RouteClientOutcome[]): string | null { + return outcomes.length === ROUTE_CLIENT_IDS.length && new Set(outcomes.map(row => row.client)).size === ROUTE_CLIENT_IDS.length + && outcomes.every(row => row.outcome === "current" && !(row.source === "local" && document.clients[row.client].enabled)) + ? routeIntentDigest(document) : null; +} +function parseHomeRouteIntentRecord(value: unknown, linkId: string): HomeRouteIntentRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const raw = value as Record; + const fields = ["linkId", "fetchedAt", "appliedAt", "document", "appliedRouteIntentDigest", "routeClientOutcomes"]; + if (Object.keys(raw).length !== fields.length || Object.keys(raw).some(key => !fields.includes(key)) + || raw.linkId !== linkId || !/^lnk_[0-9a-f]{16}$/.test(linkId) || !isIsoTimestamp(raw.fetchedAt) || !isIsoTimestamp(raw.appliedAt)) return null; + const document = parseHomeRouteIntentDocument(raw.document); + const outcomes = parseRouteClientOutcomes(raw.routeClientOutcomes); + if (!document || !outcomes || raw.appliedRouteIntentDigest !== completedRouteIntentDigest(document, outcomes)) return null; + return { linkId, fetchedAt: raw.fetchedAt, appliedAt: raw.appliedAt, document, + appliedRouteIntentDigest: raw.appliedRouteIntentDigest as string | null, routeClientOutcomes: outcomes }; +} +export function readHomeRouteIntentRecord(linkId: string, path = homeRouteIntentPath()): HomeRouteIntentRecord | null { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return null; + return parseHomeRouteIntentRecord(JSON.parse(readFileSync(path, "utf8")), linkId); + } catch { return null; } +} +export function writeHomeRouteIntentRecord(record: HomeRouteIntentRecord, path = homeRouteIntentPath()): void { + const parsed = parseHomeRouteIntentRecord(record, record.linkId); + if (!parsed) throw new Error("invalid Home route intent receipt"); + const dir = dirname(path); + assertNotRealHomeUnderTest(dir); + const body = `${JSON.stringify(parsed, null, 2)}\n`; + if (Buffer.byteLength(body) > RECORD_MAX_BYTES) throw new Error("Home route intent receipt too large"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform === "win32") hardenSecretDir(dir, { required: true }); + else chmodSync(dir, 0o700); + atomicWriteFile(path, body); + if (process.platform !== "win32") chmodSync(path, 0o600); +} +export function clearHomeRouteIntentRecord(path = homeRouteIntentPath()): void { + assertNotRealHomeUnderTest(dirname(path)); + try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; } +} +/** Call only after the materializer has collected the outcomes; partial apply never claims the digest. */ +export function homeRouteIntentReceipt( + linkId: string, document: HomeRouteIntentDocument, fetchedAt: string, appliedAt: string, routeClientOutcomes: RouteClientOutcome[], + previous: HomeRouteIntentRecord | null = readHomeRouteIntentRecord(linkId), +): HomeRouteIntentRecord { + const digest = completedRouteIntentDigest(document, routeClientOutcomes); + const unchanged = digest !== null && previous?.linkId === linkId && previous.appliedRouteIntentDigest === digest; + return { linkId, document, fetchedAt, appliedAt: unchanged ? previous.appliedAt : appliedAt, + appliedRouteIntentDigest: digest, routeClientOutcomes }; +} +export type HomeRouteIntentFetch = { kind: "fresh"; document: HomeRouteIntentDocument } | { kind: "unsupported" } | { kind: "failed"; reason: string }; +export async function fetchHomeRouteIntent(connection: LinkHomeConnection, admissionKey: string, deps: LinkHomeDeps = {}): Promise { + let response: Response; + try { + const target = linkTarget(connection, admissionKey); + const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_ROUTE_INTENT_PATH}`; + const request: RequestInit = { method: "GET", headers: { accept: "application/json", authorization: `Bearer ${admissionKey}` }, + redirect: "manual", signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS) }; + response = deps.fetchImpl ? await deps.fetchImpl(destination, request) : await fetchBoundLinkRelay(target, destination, request); + } catch { return { kind: "failed", reason: "the Home route intent is unreachable" }; } + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* best effort */ } + return response.status === 404 ? { kind: "unsupported" } : { kind: "failed", reason: `the Home answered HTTP ${response.status}` }; + } + try { + const read = await readBoundedResponseBytes(response, { maxBytes: SETTINGS_MAX_BYTES, signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS) }); + if (read.oversized) return { kind: "failed", reason: "the Home route intent exceeded the allowed size" }; + const document = parseHomeRouteIntentDocument(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(read.bytes))); + return document ? { kind: "fresh", document } : { kind: "failed", reason: "the Home returned an unreadable route intent document" }; + } catch { return { kind: "failed", reason: "the Home returned an unreadable route intent document" }; } +} diff --git a/src/client/link-join.ts b/src/client/link-join.ts index 83c6163fc7a..f70425d965f 100644 --- a/src/client/link-join.ts +++ b/src/client/link-join.ts @@ -316,14 +316,21 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string const thisAlias = localAlias(deps); let result: SshRunResult; try { - result = await deps.runner.run( + const issue = (selectedClients?: OcxConnectedClientId[]) => deps.runner.run( buildExecArgv({ alias: input.alias, - argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--json"]), + argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), ...(selectedClients ? ["--clients", selectedClients.join(",")] : []), "--json"]), knownHostsFile: deps.knownHostsFile, }), { timeoutMs: JOIN_REVOKE_TIMEOUT_MS }, ); + result = await issue(deps.selectedClients); + // The older CLI rejects unknown arguments before issuing a key. Retry only that + // definite no-write result; keep the selection for the Child's existing fallback. + if (deps.selectedClients && result.code === 2 && !result.stdout.trim() + && /^(?:Error: )?Unexpected argument\(s\): --clients(?:\s|$)/m.test(result.stderr)) { + result = await issue(); + } } catch (error) { throw new ClientLinkJoinError("join_issue_failed", undefined, sshRunnerErrorHint(error)); } @@ -383,6 +390,7 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string transport: "link", link: { tunnelPort, linkId: issued.linkId }, selectedClients: deps.selectedClients ?? ["codex", "claude"], + selectedClientsExplicit: deps.selectedClients !== undefined, managementTransport: "direct", }, { fetchImpl: deps.fetchImpl, diff --git a/src/client/link-parity-reporter.ts b/src/client/link-parity-reporter.ts index ff4d4e1f437..7fa14024e88 100644 --- a/src/client/link-parity-reporter.ts +++ b/src/client/link-parity-reporter.ts @@ -8,9 +8,12 @@ * predates the route (404) is not asked again. Timers are unref'd and never keep the process alive. */ import { codexSettingsDigest } from "../link/codex-settings"; +import { routeClientOutcomesForPeer, routeIntentDigest, type RouteClientId } from "../link/route-intent"; +import type { RouteIntentReceipt } from "../integrations/route-intent-contract"; +import { effectiveRouteIntent, readLocalRouteIntentOff, readRouteIntentReceiptStrict } from "../integrations/route-intent-state"; import type { LinkChildParityReport } from "../link/child-parity"; import type { OcxClientConnectionConfig } from "../types"; -import { readHomeCodexSettingsRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord } from "./link-home"; +import { readHomeCodexSettingsRecord, readHomeRouteIntentRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord, type HomeRouteIntentRecord } from "./link-home"; export const LINK_PARITY_RETRY_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const; export const LINK_PARITY_PERIOD_MS = 60_000; @@ -25,6 +28,7 @@ function nextReportSequence(): number { export interface LinkParityReporter { start(): void; stop(): void; + requestReport(): void; } export interface LinkParityReporterDeps { @@ -33,6 +37,9 @@ export interface LinkParityReporterDeps { /** ISO start time of this runtime. */ runtimeStartedAt: string; readRecord?: (linkId: string) => HomeCodexSettingsRecord | null; + readRouteRecord?: (linkId: string) => HomeRouteIntentRecord | null; + readCommonReceipt?: () => RouteIntentReceipt | null; + readLocalOff?: () => RouteClientId[]; send?: typeof sendLinkChildParityReport; setTimer?: (callback: () => void, ms: number) => unknown; clearTimer?: (timer: unknown) => void; @@ -44,18 +51,24 @@ export function linkChildParityReport( runtimeStartedAt: string, record: HomeCodexSettingsRecord | null, sequence: number = nextReportSequence(), + routeRecord?: HomeRouteIntentRecord | null, ): LinkChildParityReport | null { const linkId = connection.link?.linkId; const started = Date.parse(runtimeStartedAt); if (!linkId || !Number.isFinite(started)) return null; const applied = record ? Date.parse(record.appliedAt) : Number.NaN; - return Number.isFinite(applied) + const report: LinkChildParityReport = Number.isFinite(applied) ? { linkId, lastApplyAt: applied, runtimeStartedAt: started, appliedCodexSettingsDigest: codexSettingsDigest(record!.document.settings), sequence } : { linkId, lastApplyAt: null, runtimeStartedAt: started, appliedCodexSettingsDigest: null, sequence }; + return routeRecord?.linkId === linkId ? { ...report, appliedRouteIntentDigest: routeRecord.appliedRouteIntentDigest, + routeClientOutcomes: routeClientOutcomesForPeer(routeRecord.routeClientOutcomes) } : report; } export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkParityReporter { const readRecord = deps.readRecord ?? readHomeCodexSettingsRecord; + const readRouteRecord = deps.readRouteRecord ?? readHomeRouteIntentRecord; + const readCommonReceipt = deps.readCommonReceipt ?? readRouteIntentReceiptStrict; + const readLocalOff = deps.readLocalOff ?? readLocalRouteIntentOff; const send = deps.send ?? sendLinkChildParityReport; const setTimer = deps.setTimer ?? ((callback: () => void, ms: number) => { const timer = setTimeout(callback, ms); @@ -66,6 +79,8 @@ export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkPari let timer: unknown; let stopped = true; let failures = 0; + let inFlight = false; + let rerun = false; const schedule = (ms: number) => { if (stopped) return; @@ -75,20 +90,51 @@ export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkPari const tick = async (): Promise => { timer = undefined; if (stopped) return; + if (inFlight) { rerun = true; return; } + inFlight = true; + let delay: number = LINK_PARITY_PERIOD_MS; + try { const key = deps.linkKey(); const linkId = deps.connection.link?.linkId; let record: HomeCodexSettingsRecord | null = null; try { record = linkId ? readRecord(linkId) : null; } catch { record = null; } - const report = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record); - const outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed"; + let routeRecord: HomeRouteIntentRecord | null = null; + try { + routeRecord = linkId ? readRouteRecord(linkId) : null; + const common = readCommonReceipt(); + const effective = common ? effectiveRouteIntent(common.document, undefined, readLocalOff()) : null; + if (!routeRecord || routeRecord.linkId !== linkId || !common || common.linkId !== linkId || common.catalogPending + || routeIntentDigest(common.document) !== routeIntentDigest(routeRecord.document) + || common.appliedRouteIntentDigest !== routeRecord.appliedRouteIntentDigest + || !Bun.deepEquals(common.clients, effective?.clients, true) || !Bun.deepEquals(common.sources, effective?.sources, true) + || !Bun.deepEquals(routeClientOutcomesForPeer(common.outcomes), routeClientOutcomesForPeer(routeRecord.routeClientOutcomes), true)) { + routeRecord = null; + } + } catch { routeRecord = null; } + const base = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record, nextReportSequence(), routeRecord); + // Omission is not a withdrawal for peers retaining their last route confirmation. + const report = base && !routeRecord ? { ...base, appliedRouteIntentDigest: null, routeClientOutcomes: [] } : base; + let outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed"; + if (key && report && outcome === "schema-rejected" && report.routeClientOutcomes !== undefined) { + const { appliedRouteIntentDigest: _digest, routeClientOutcomes: _outcomes, ...baseReport } = report; + outcome = await send(deps.connection, key, baseReport).catch(() => "failed" as const); + } if (outcome === "unsupported") { stopped = true; return; } if (outcome === "accepted") { failures = 0; - schedule(LINK_PARITY_PERIOD_MS); return; } failures = Math.min(failures + 1, LINK_PARITY_RETRY_MS.length - 1); - schedule(LINK_PARITY_RETRY_MS[failures]!); + delay = LINK_PARITY_RETRY_MS[failures]!; + } catch { + failures = Math.min(failures + 1, LINK_PARITY_RETRY_MS.length - 1); + delay = LINK_PARITY_RETRY_MS[failures]!; + } finally { + inFlight = false; + const immediate = rerun; + rerun = false; + schedule(immediate ? 0 : delay); + } }; return { @@ -102,6 +148,14 @@ export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkPari stopped = true; if (timer !== undefined) clearTimer(timer); timer = undefined; + rerun = false; + }, + requestReport() { + if (stopped) return; + if (timer !== undefined) clearTimer(timer); + timer = undefined; + if (inFlight) rerun = true; + else void tick(); }, }; } diff --git a/src/client/route-intent-apply.ts b/src/client/route-intent-apply.ts new file mode 100644 index 00000000000..51338048cae --- /dev/null +++ b/src/client/route-intent-apply.ts @@ -0,0 +1,293 @@ +/** Production native adapters for the common Home/Child local intent controller. */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ExportModel } from "../clients/config-export"; +import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config"; +import { readConfigForAdmittedMutation } from "../config/persisted-mutation"; +import { commitClaudeCodeBlock } from "../claude/claude-code-block"; +import { localInferenceDestination } from "../lib/local-destinations"; +import { persistCommittedDesktopGateway } from "../claude/desktop-gateway-state"; +import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../claude/desktop-applied-marker"; +import { localClientSyncAllowed } from "../codex/desired-state"; +import { materializeRouteIntent, type RouteClientOutcome, type RouteIntentApplyInput, type RouteIntentReceipt } from "../integrations/route-intent"; +import type { HomeRouteIntentDocument, RouteClientId, RouteClientIntent } from "../link/route-intent"; +import type { OcxConfig } from "../types"; +import { assertNoClientDisconnectPending, readClientConnectionState, sameClientConnectionOwner } from "./state"; +import { withClientLifecycle, withClientLifecycleSync, type ClientLifecycleLockDeps } from "./lifecycle-lock"; +import { assertRouteIntentPort } from "./sync-runtime"; +import { resolveApiSurfaceSettings } from "../protocols/settings"; +import { nativeIntentFields, prepareNativeIntentWrite, publishNativeIntentWrite } from "./route-intent-native-originals"; + +export interface LocalRouteIntentOptions extends Pick { + /** Connect/sync already applied the Home's Codex settings through the existing injector. */ + codexApplied?: boolean; + lifecycleLockDeps?: ClientLifecycleLockDeps; + /** The native client a Home write is deciding; only it may change a Home native switch. */ + decidedClient?: RouteClientId; +} + +/** + * The Home's own native switch, read rather than decided by this apply (a sync, or a write for + * another client). It is never rewritten here; only the client's owned artifacts follow it. + */ +function liveHomeSwitch(document: HomeRouteIntentDocument, client: RouteClientId, options: LocalRouteIntentOptions): boolean { + return document.sources[client] === "native" && options.decidedClient !== client && readClientConnectionState().kind !== "connected"; +} + +/** + * An enabled Home Grok or Desktop switch nobody is deciding right now gets exactly the startup + * behavior: Grok follows the startup gate and Desktop only refreshes a gateway profile OpenCodex + * already owns. The outcome is "current": the switch is satisfied without creating anything. + */ +function implicitHomeIntent(document: HomeRouteIntentDocument, client: RouteClientId, intent: RouteClientIntent, options: LocalRouteIntentOptions): boolean { + return intent.enabled && liveHomeSwitch(document, client, options); +} +const IMPLICIT_DESKTOP_MESSAGE = "No OpenCodex-owned Claude Desktop profile to refresh; choose ocx route to claude-desktop on to create one"; + +/** + * First-party Desktop already routes through OpenCodex, so an enabled intent is satisfied without + * a write. Turning it off changes the Desktop mode, which only its own transaction may do. + */ +function firstPartyDesktopOutcome(enabled: boolean, target?: string): RouteClientOutcome { + const base = { client: "claude-desktop" as const, ...(target === undefined ? {} : { target }) }; + if (enabled) return { ...base, outcome: "current", message: "Claude Desktop first-party mode already routes through OpenCodex" }; + const command = readClientConnectionState().kind === "connected" + ? "switch modes with ocx claude desktop apply --gateway, then run ocx route to sync" + : "turn it off with ocx integrations native claude-desktop off"; + return { ...base, outcome: "conflict", message: `Claude Desktop is in first-party mode; ${command}` }; +} + +/** + * Caller holds the lifecycle fence; the config coordinator rechecks guard before each commit. + * `recordStandalone` is true only for a Child's link write: it records the standalone value + * write-ahead so disconnect or a failed enrollment can restore it. Home writes are the + * operator's own decisions and are not recorded. + */ +export function persistNativeRouteIntent(client: "codex" | "claude" | "claude-desktop" | "grok", enabled: boolean, guard: () => void, snapshot: OcxConfig, + recordStandalone: boolean): OcxConfig { + const now = new Date(); + const apply = (config: OcxConfig): boolean => { + const previous = client === "claude" ? config.claudeCode?.enabled !== false : config.clientIntegrations?.[client] !== false; + if (previous === enabled) return false; + if (client === "claude") { + // The legacy flag also supplied the shared Messages API default. A local opt-out + // must preserve that preexisting exposure for other devices and Desktop clients. + if (!enabled && resolveApiSurfaceSettings(config).messages.source === "claude-code-legacy") { + config.apiSurfaces = { ...config.apiSurfaces, messages: { enabled: resolveApiSurfaceSettings(config).messages.enabled } }; + } + commitClaudeCodeBlock(config, { ...config.claudeCode, enabled }, now); + } + else config.clientIntegrations = { ...config.clientIntegrations, [client]: enabled }; + return true; + }; + const result = withConfigMutationLockSync(() => { + let recorded = false; + if (recordStandalone) { + guard(); + const base = readConfigForAdmittedMutation(), planned = structuredClone(base); + if (apply(planned)) recorded = prepareNativeIntentWrite(nativeIntentFields(base), nativeIntentFields(planned)); + } + const outcome = mutatePersistedConfig(config => { + guard(); + return { changed: apply(config), value: config }; + }); + if (outcome.status !== "unavailable" && recorded) publishNativeIntentWrite(nativeIntentFields(outcome.value)); + return outcome; + }); + if (result.status === "unavailable") throw new Error(`Local native route intent could not be persisted (${result.reason})`); + if (client === "claude") { + snapshot.claudeCode = { ...snapshot.claudeCode, enabled, + ...(result.value.claudeCode?.authModeMigratedAt ? { authModeMigratedAt: result.value.claudeCode.authModeMigratedAt } : {}) }; + if (result.value.apiSurfaces?.messages !== undefined) snapshot.apiSurfaces = { ...snapshot.apiSurfaces, messages: { ...result.value.apiSurfaces.messages } }; + } + else if (result.value.clientIntegrations) snapshot.clientIntegrations = { ...result.value.clientIntegrations }; + else delete snapshot.clientIntegrations; + return result.value; +} + +async function applyNative( + input: RouteIntentApplyInput, options: LocalRouteIntentOptions, client: "codex" | "claude" | "claude-desktop" | "grok", intent: RouteClientIntent, +): Promise { + const guard = input.beforeWrite!; + const home = input.home ?? homedir(); + const env = input.env ?? process.env; + const desktopMode = client === "claude-desktop" ? await import("../claude/desktop-first-party") : undefined; + const homeSwitch = liveHomeSwitch(input.document, client, options); + // The committed flag reaches the caller's working snapshot too. The management owner mirrors + // these individual flags onto its live config; a copied request config cannot do that for it. + const committedConfig = withClientLifecycleSync(() => withConfigMutationLockSync(() => { + guard(); + if (desktopMode) { + const current = loadConfig(); + if (desktopMode.resolveClaudeDesktopMode(current, desktopMode.observeClaudeDesktopMode(current)) === "first-party") return null; + } + if (homeSwitch) return loadConfig(); + // Only a connected Child's write displaces a standalone value; the Home owns its own flags. + return persistNativeRouteIntent(client, intent.enabled, guard, input.config, readClientConnectionState().kind === "connected"); + }), options.lifecycleLockDeps); + if (committedConfig === null) return firstPartyDesktopOutcome(intent.enabled); + if (client === "codex") { + const { getCodexHome, CODEX_CONFIG_PATH, DEFAULT_CATALOG_PATH } = await import("../codex/paths"); + const { injectCodexConfig, restoreNativeCodexAsync, currentExternalCodexModelProvider, isCodexRoutingInjected } = await import("../codex/inject"); + const target = CODEX_CONFIG_PATH; + guard(); + if (!existsSync(getCodexHome())) return { client, target, outcome: intent.enabled ? "not_installed" : "current" }; + if (intent.enabled && options.codexApplied) return { client, target, outcome: "current" }; + const externalProvider = currentExternalCodexModelProvider(); + if (externalProvider) { + const { approvedCodexProviders } = await import("../codex/provider-routing"); + if (!approvedCodexProviders().includes(externalProvider)) return { client, target, outcome: intent.enabled ? "conflict" : "current", message: "Codex has an external provider" }; + } + if (!intent.enabled && !isCodexRoutingInjected()) return { client, target, outcome: "current" }; + const config = committedConfig; + if (!intent.enabled) { + const result = await restoreNativeCodexAsync({ withWriteGuard: work => withConfigMutationLockSync(() => { guard(); return work(); }) }); + return { client, target, outcome: result.success ? "current" : "failed", message: result.message }; + } + const state = readClientConnectionState(); + const result = await injectCodexConfig(input.config.port, { ...config, syncResumeHistory: false }, { + catalogPath: DEFAULT_CATALOG_PATH, beforeClientWrite: guard, + ...(state.kind === "connected" ? { journalOwner: { kind: "client" as const, apiKeyId: state.value.apiKeyId }, + routingTarget: { baseUrl: `http://127.0.0.1:${input.config.port}/v1`, requiresAdmissionToken: false, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const } } : {}), + }); + return { client, target, outcome: result.success && result.status !== "skipped" ? "current" : "failed", message: result.message }; + } + if (client === "claude") { + const target = env.CLAUDE_CONFIG_DIR || join(home, ".claude"); + if (!existsSync(target)) return { client, target, outcome: intent.enabled ? "not_installed" : "current" }; + if (!intent.enabled) { + const { injectClaudeAgentDefs } = await import("../claude/agents-inject"); + const cleaned = withClientLifecycleSync(() => { guard(); return injectClaudeAgentDefs(committedConfig, {}, target); }, options.lifecycleLockDeps); + if (cleaned === null) return { client, target, outcome: "failed", message: "Owned Claude agent definitions could not be removed" }; + } + // Claude Code uses the existing launcher and selectedClients gate; no foreign config is copied. + return { client, target, outcome: "current" }; + } + if (client === "grok") { + const { injectGrokConfig, stripGrokConfig } = await import("../grok/inject"); + const { inspectGrokConfig } = await import("../grok/inspect"); + const grokHome = env.GROK_HOME || join(home, ".grok"); + const target = join(grokHome, "config.toml"); + if (implicitHomeIntent(input.document, client, intent, options) && !localClientSyncAllowed(input.config)) { + return { client, target, outcome: "current", message: "Grok sync is gated on this Home, as it is at startup" }; + } + return withClientLifecycleSync(() => withConfigMutationLockSync(() => { + guard(); + const state = inspectGrokConfig({ grokHome }); + if (state.kind === "not_installed") return { client, target, outcome: intent.enabled ? "not_installed" : "current" }; + if (state.kind === "orphaned_marker") return { client, target, outcome: "conflict", message: "Grok managed block is incomplete" }; + if (!intent.enabled && state.kind === "absent") return { client, target, outcome: "current" }; + const destination = localInferenceDestination(input.config, input.config.port); + if (intent.enabled && destination.requiresAdmissionToken) return { client, target, outcome: "failed", message: "Grok requires a credential-free local listener" }; + const result = intent.enabled ? injectGrokConfig(destination.port, input.models.map(model => ({ + id: model.namespaced, name: model.displayName, contextWindow: model.contextWindow, + reasoningEfforts: model.reasoningEfforts, defaultReasoningEffort: model.defaultReasoningEffort, + })), { grokHome, hostname: "127.0.0.1" }) : stripGrokConfig({ grokHome }); + return { client, target, outcome: result.ok && !result.skippedReason ? "current" : "failed", message: result.message }; + }), options.lifecycleLockDeps); + } + const desktop = await import("../claude/desktop-3p"); + guard(); + // The durable marker: a caller's snapshot can predate a Desktop apply by another process. + const inspected = desktop.inspectDesktop3pConfigLibrary({ appliedFingerprint: committedConfig.claudeCode?.desktopProfile?.appliedFingerprint }); + const target = inspected.selectedProfilePath ?? inspected.libraryPath; + if (inspected.kind === "not_installed") return { client, target, outcome: intent.enabled ? "not_installed" : "current" }; + const state = readClientConnectionState(); + // Connected profiles carry their own owner/projection evidence, not the standalone marker. + if (!intent.enabled && state.kind === "connected") { + guard(); + const result = desktop.removeDesktop3pStandardPivot({ lifecycleLockDeps: options.lifecycleLockDeps }); + return { client, target, outcome: result.ok ? "current" : result.reason === "conflict" ? "conflict" : "failed" }; + } + const firstParty = (config: OcxConfig) => desktopMode!.resolveClaudeDesktopMode(config, desktopMode!.observeClaudeDesktopMode(config)) === "first-party"; + const modeConflict: RouteClientOutcome = { client, target, outcome: "conflict", message: "Claude Desktop first-party mode needs its existing mode-switch transaction" }; + if (intent.enabled && state.kind === "connected") { + const { applyRemoteDesktopStore } = await import("../claude/desktop-remote-store"); + const { readServiceApiTokenState } = await import("../lib/service-secrets"); + const models = desktop.generateDesktop3pModels(input.models.filter(model => model.native).map(model => model.id), + input.models.filter(model => !model.native).map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow }))); + return withClientLifecycleSync(held => withConfigMutationLockSync(() => { + guard(); + if (firstParty(loadConfig())) return firstPartyDesktopOutcome(true, target); + const token = readServiceApiTokenState(); + if (token.kind !== "present") return { client, target, outcome: "failed", message: "Child service credential is unavailable" }; + const result = applyRemoteDesktopStore(held, { + owner: { serverUrl: state.value.serverUrl, apiKeyId: state.value.apiKeyId, connectedAt: state.value.connectedAt }, + expectedTokenFingerprint: token.fingerprint, baseUrl: `http://127.0.0.1:${input.config.port}`, + apiKey: "opencodex-loopback", mode: "static", models, localListener: true, overwriteConflict: options.overwriteConflict, + }); + return { client, target: result.ok ? result.path ?? target : target, outcome: result.ok ? "current" : result.reason === "conflict" ? "conflict" : "failed" }; + }), options.lifecycleLockDeps); + } + if (!intent.enabled && inspected.kind === "no_owned_state") return { client, target, outcome: "current" }; + const appliedFingerprint = committedConfig.claudeCode?.desktopProfile?.appliedFingerprint; + const refreshOnly = implicitHomeIntent(input.document, client, intent, options); + const changedProfile = ["unsafe", "broken", "gateway_drifted"].includes(inspected.kind); + if (changedProfile && refreshOnly) { + // Only an OpenCodex-owned profile can have changed under a refresh. An unreadable library or one + // selecting another app's profile was never ours, so the refresh has nothing to do there. + const owned = inspected.ownedProfileActive === true || (inspected.ownedProfileActive === null && !!appliedFingerprint); + if (!owned) return { client, target, outcome: "current", message: IMPLICIT_DESKTOP_MESSAGE }; + // A refresh never takes over a changed profile, even with overwrite: that is a decided write. + return { client, target, outcome: "conflict", + message: "Claude Desktop ownership or profile changed; replace it with ocx route to claude-desktop on --device home --overwrite-conflict" }; + } + if (changedProfile && !options.overwriteConflict) return { client, target, outcome: "conflict", message: "Claude Desktop ownership or profile changed" }; + // Only an absent or untouched profile satisfies a refresh-only switch. + if (refreshOnly && (inspected.kind !== "gateway_ours" || !appliedFingerprint)) return { client, target, outcome: "current", message: IMPLICIT_DESKTOP_MESSAGE }; + if (!intent.enabled) { + guard(); + const result = desktop.removeDesktop3pStandardPivot({ appliedFingerprint: committedConfig.claudeCode?.desktopProfile?.appliedFingerprint, lifecycleLockDeps: options.lifecycleLockDeps }); + return { client, target, outcome: result.ok ? "current" : "failed" }; + } + const { getClaudePickerController } = await import("../claude/intercept/runtime"); + const { runDesktopTransition } = await import("../claude/desktop-picker"); + // Mode switches share picker exclusion in-process and L→C across CLI/server writers. + // Keep the file write and marker together, reusing the existing writer with this lease. + return runDesktopTransition(getClaudePickerController(), async () => withClientLifecycleSync(held => withConfigMutationLockSync(() => { + guard(); + const config = loadConfig(); + if (firstParty(config)) return firstPartyDesktopOutcome(true, target); + const profile = config.claudeCode?.desktopProfile; + const result = desktop.writeDesktop3pConfig(input.config.port, + input.models.filter(model => model.native).map(model => model.id), + input.models.filter(model => !model.native).map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })), + undefined, "static", profile, undefined, options.lifecycleLockDeps, + refreshOnly ? { appliedFingerprint: appliedFingerprint!, admit: () => !firstParty(loadConfig()) } : undefined, held); + if (refreshOnly && result.reason === "desktop_refresh_only_skipped") return { client, target, outcome: "current", message: IMPLICIT_DESKTOP_MESSAGE }; + if (!result.written) return { client, target, outcome: "failed", message: result.reason ?? "Claude Desktop writer refused" }; + guard(); + if (firstParty(loadConfig())) return modeConflict; + if (refreshOnly) { + // A refresh keeps the existing mode; only the applied marker follows the new bytes. + const marked = commitDesktopAppliedMarker(captureDesktopAppliedMarker(profile), result.fingerprint!); + return { client, target, outcome: marked.status !== "unavailable" && marked.value ? "current" : "failed" }; + } + const recorded = persistCommittedDesktopGateway(config, config.claudeCode?.desktopProfile, result.fingerprint, current => !firstParty(current)); + return { client, target, outcome: recorded.ok ? "current" : "failed" }; + }), options.lifecycleLockDeps), { config: committedConfig }); +} + +export function materializeLocalRouteIntent( + config: OcxConfig, document: HomeRouteIntentDocument, models: readonly ExportModel[], options: LocalRouteIntentOptions = {}, +): Promise { + const expected = readClientConnectionState(); + const configuredPort = loadConfig().port; + const beforeWrite = () => { + options.beforeWrite?.(); + assertRouteIntentPort(config.port); + if (loadConfig().port !== configuredPort) throw new Error("client_runtime_changed"); + assertNoClientDisconnectPending(); + const current = readClientConnectionState(); + if (expected.kind === "connected" ? current.kind !== "connected" || !sameClientConnectionOwner(current.value, expected.value) + : current.kind !== expected.kind || current.kind !== "disconnected") throw new Error("client_connection_changed"); + }; + const input: RouteIntentApplyInput = { config, document: structuredClone(document), models: structuredClone([...models]), ...options, beforeWrite, + ...(expected.kind === "connected" && expected.value.link ? { linkId: expected.value.link.linkId } : {}), + localOff: expected.kind === "connected" && expected.value.transport === "link" ? undefined : [] }; + return materializeRouteIntent({ ...input, withFileWrites: work => withClientLifecycle(work, options.lifecycleLockDeps), + withReceiptWrite: write => withClientLifecycleSync(() => { beforeWrite(); write(); }, options.lifecycleLockDeps), + applyNative: (client, intent) => applyNative(input, { ...options, overwriteConflict: options.overwriteConflict === true && (options.overwriteClient === undefined || options.overwriteClient === client) }, client, intent) }); +} diff --git a/src/client/route-intent-file-originals.ts b/src/client/route-intent-file-originals.ts new file mode 100644 index 00000000000..ee0b1c0fa9f --- /dev/null +++ b/src/client/route-intent-file-originals.ts @@ -0,0 +1,97 @@ +/** + * File integrations a linked Child owned before linking. + * + * A Child applying its Home's route intent disables, refreshes or creates file-integration blocks. + * Without this record, disconnect cannot tell a block the link created from one the person had + * set up on their own, and removed both. At the first link apply, before any client changes, the + * Child records which file clients had an ownership record (with their model-name policy). The + * record is private, bound to the link that captured it, and carries no paths or secrets. + * + * Disconnect restores those clients to their standalone state: a client the link left owned is + * kept as is, a client the link disabled is applied again, and a client the person changed in the + * meantime (a local opt-out, or a block of their own) is left alone. Clients the link created keep + * the ordinary cleanup. Aside profiles are not recorded and keep that cleanup too. + * + * Callers hold the client lifecycle lock; reads are strict, so a damaged record refuses the + * disconnect instead of silently dropping a standalone integration. + */ +import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { linkDir } from "../link/paths"; +import { ROUTE_CLIENT_IDS, type RouteClientId } from "../link/route-intent"; + +type ModelNames = "alias" | "routed"; +export interface FileIntentOriginals { + version: 1; + linkId: string; + clients: Partial>; +} + +const RECORD_MAX_BYTES = 16 * 1024; +const NOT_FILE_CLIENTS: readonly RouteClientId[] = ["codex", "claude", "claude-desktop", "grok", "aside"]; +const FILE_CLIENTS = ROUTE_CLIENT_IDS.filter(client => !NOT_FILE_CLIENTS.includes(client)); +const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); + +export function fileIntentOriginalsPath(): string { return join(linkDir(), "file-intent-originals.json"); } + +const unsafe = (): never => { + throw new Error(`File integration originals record is unreadable; inspect or remove ${fileIntentOriginalsPath()} and retry`); +}; + +function parse(value: unknown): FileIntentOriginals { + if (!isRecord(value) || value.version !== 1 || typeof value.linkId !== "string" || !/^lnk_[0-9a-f]{16}$/.test(value.linkId) + || !isRecord(value.clients) || Object.keys(value).length !== 3) return unsafe(); + for (const [client, entry] of Object.entries(value.clients)) { + if (!(FILE_CLIENTS as readonly string[]).includes(client) || !isRecord(entry) + || Object.keys(entry).some(key => key !== "modelNames") + || (entry.modelNames !== undefined && entry.modelNames !== "alias" && entry.modelNames !== "routed")) return unsafe(); + } + return value as unknown as FileIntentOriginals; +} + +/** Missing reads as null; a present but malformed record refuses. */ +export function readFileIntentOriginals(): FileIntentOriginals | null { + let stat; + try { stat = lstatSync(fileIntentOriginalsPath()); } catch (error) { if (isMissingPathError(error)) return null; throw error; } + if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return unsafe(); + let value: unknown; + try { value = JSON.parse(readFileSync(fileIntentOriginalsPath(), "utf8")); } catch { return unsafe(); } + return parse(value); +} + +function writeRecord(record: FileIntentOriginals): void { + parse(record); + const dir = linkDir(); + assertNotRealHomeUnderTest(join(dir, "..")); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform === "win32") hardenSecretDir(dir, { required: true }); + else chmodSync(dir, 0o700); + atomicWriteFile(fileIntentOriginalsPath(), `${JSON.stringify(record, null, 2)}\n`); + if (process.platform !== "win32") chmodSync(fileIntentOriginalsPath(), 0o600); +} + +export function clearFileIntentOriginals(): void { + assertNotRealHomeUnderTest(join(linkDir(), "..")); + try { unlinkSync(fileIntentOriginalsPath()); } catch (error) { if (!isMissingPathError(error)) throw error; } +} + +/** + * Write-ahead capture for a link's first apply. `firstApply` is false once a route receipt bound + * to a link exists, so clients a link created are never recorded as standalone. A record from + * another link is stale and is replaced. + */ +export function captureFileIntentOriginals(linkId: string, firstApply: boolean, + owners: Partial>): void { + const existing = readFileIntentOriginals(); + if (existing?.linkId === linkId || !firstApply) return; + const clients: FileIntentOriginals["clients"] = {}; + for (const client of FILE_CLIENTS) { + const owner = owners[client]; + if (owner) clients[client] = owner.modelNames ? { modelNames: owner.modelNames } : {}; + } + if (Object.keys(clients).length === 0) { if (existing) clearFileIntentOriginals(); return; } + writeRecord({ version: 1, linkId, clients }); +} diff --git a/src/client/route-intent-native-originals.ts b/src/client/route-intent-native-originals.ts new file mode 100644 index 00000000000..2e73e1001d8 --- /dev/null +++ b/src/client/route-intent-native-originals.ts @@ -0,0 +1,201 @@ +/** + * Standalone native originals for a linked Child. + * + * A Child applying its Home's route intent writes the Child's own native switches + * (clientIntegrations.codex|grok|claude-desktop, claudeCode.enabled, and as side effects + * claudeCode.authModeMigratedAt and apiSurfaces.messages). This private, machine-scoped + * record remembers, per field, the value that existed before route intent first changed it + * (absent is a value: null), plus the last value this writer published and any value it was + * about to publish (pending). It is captured once and never overwritten by later syncs or + * connects, and it is not bound to a link: it means "the standalone value before route intent + * touched it", which stays true after a crashed connect followed by a different link. + * + * Callers hold the client lifecycle lock; every function here takes the config mutation lock, + * preserving the documented lifecycle -> config order. Writes are write-ahead: the original and + * pending value reach this file before the atomic config write, and the value is marked + * published after it. Restore (disconnect, failed enrollment) puts the original back only for a + * field whose current value this writer published or had pending; any other value is the + * person's own edit and is kept. The record is removed only after the restored config is read + * back and verified, so an interrupted restore is retried safely by the next disconnect. + * + * Limit (ABA): a person who sets a field to exactly the value this writer published cannot be + * told apart from the writer, and that field is restored. The Child's local opt-out list is the + * person's own choice and is not part of this record. + */ +import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { mutatePersistedConfig, readConfigForAdmittedMutation } from "../config/persisted-mutation"; +import { deleteConfigObjectChildKey } from "../config/rebase-provenance"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { linkDir } from "../link/paths"; +import type { OcxConfig } from "../types"; + +export const NATIVE_INTENT_FIELDS = ["codex", "grok", "claude-desktop", "claude", "claudeMigratedAt", "messages"] as const; +export type NativeIntentField = typeof NATIVE_INTENT_FIELDS[number]; +/** null means the key is absent; anything else is the exact persisted value. */ +export type NativeIntentSlot = boolean | string | Record | null; +export type NativeIntentFields = Record; +type Entry = { original: NativeIntentSlot; published?: NativeIntentSlot; pending?: NativeIntentSlot }; +export type NativeIntentOriginals = { version: 1; fields: Partial> }; + +const RECORD_MAX_BYTES = 16 * 1024; +const unsafe = (): never => { + throw new Error(`Native intent originals record is unreadable; inspect or remove ${nativeIntentOriginalsPath()} and retry`); +}; +const isRecord = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); +const sameSlot = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +export function nativeIntentOriginalsPath(): string { return join(linkDir(), "native-intent-originals.json"); } + +const integrationField = (field: NativeIntentField): field is "codex" | "grok" | "claude-desktop" => + field === "codex" || field === "grok" || field === "claude-desktop"; +const parentKey = (field: NativeIntentField) => integrationField(field) ? "clientIntegrations" as const + : field === "messages" ? "apiSurfaces" as const : "claudeCode" as const; +const childKey = (field: NativeIntentField): string => integrationField(field) ? field + : field === "messages" ? "messages" : field === "claude" ? "enabled" : "authModeMigratedAt"; + +export function readNativeIntentField(config: OcxConfig, field: NativeIntentField): NativeIntentSlot { + const parent = (config as unknown as Record)[parentKey(field)]; + if (!isRecord(parent) || !Object.hasOwn(parent, childKey(field)) || parent[childKey(field)] === undefined) return null; + return structuredClone(parent[childKey(field)]) as NativeIntentSlot; +} + +export function nativeIntentFields(config: OcxConfig): NativeIntentFields { + return Object.fromEntries(NATIVE_INTENT_FIELDS.map(field => [field, readNativeIntentField(config, field)])) as NativeIntentFields; +} + +function writeNativeIntentField(config: OcxConfig, field: NativeIntentField, value: NativeIntentSlot): void { + const key = parentKey(field); + if (value === null) { deleteConfigObjectChildKey(config, key, childKey(field)); return; } + const record = config as unknown as Record; + const parent = isRecord(record[key]) ? record[key] as Record : {}; + record[key] = { ...parent, [childKey(field)]: structuredClone(value) }; +} + +function validSlot(field: NativeIntentField, value: unknown): value is NativeIntentSlot { + if (value === null) return true; + if (field === "claudeMigratedAt") return typeof value === "string" && value.length <= 64; + if (field === "messages") return isRecord(value) && Object.values(value).every(item => item === null || ["boolean", "string", "number"].includes(typeof item)); + return typeof value === "boolean"; +} + +function parse(value: unknown): NativeIntentOriginals { + if (!isRecord(value) || value.version !== 1 || !isRecord(value.fields) || Object.keys(value).length !== 2) return unsafe(); + for (const [field, entry] of Object.entries(value.fields)) { + if (!(NATIVE_INTENT_FIELDS as readonly string[]).includes(field) || !isRecord(entry) || !Object.hasOwn(entry, "original")) return unsafe(); + for (const [key, slot] of Object.entries(entry)) { + if (!["original", "published", "pending"].includes(key) || !validSlot(field as NativeIntentField, slot)) return unsafe(); + } + } + return value as NativeIntentOriginals; +} + +/** Missing reads as null. A present but unsafe or malformed record refuses rather than losing originals. */ +export function readNativeIntentOriginals(): NativeIntentOriginals | null { + const path = nativeIntentOriginalsPath(); + let stat; + try { stat = lstatSync(path); } catch (error) { if (isMissingPathError(error)) return null; throw error; } + if (!stat.isFile() || stat.size > RECORD_MAX_BYTES) return unsafe(); + let value: unknown; + try { value = JSON.parse(readFileSync(path, "utf8")); } catch { return unsafe(); } + return parse(value); +} + +function writeRecord(record: NativeIntentOriginals): void { + parse(record); + const path = nativeIntentOriginalsPath(), dir = linkDir(); + assertNotRealHomeUnderTest(join(dir, "..")); + const body = `${JSON.stringify(record, null, 2)}\n`; + if (Buffer.byteLength(body) > RECORD_MAX_BYTES) unsafe(); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform === "win32") hardenSecretDir(dir, { required: true }); + else chmodSync(dir, 0o700); + atomicWriteFile(path, body); + if (process.platform !== "win32") chmodSync(path, 0o600); +} + +function clearRecord(): void { + assertNotRealHomeUnderTest(join(linkDir(), "..")); + try { unlinkSync(nativeIntentOriginalsPath()); } catch (error) { if (!isMissingPathError(error)) throw error; } +} + +/** True when the value is one this writer published or was about to publish. */ +function ownedByWriter(entry: Entry, current: NativeIntentSlot): boolean { + return (Object.hasOwn(entry, "published") && sameSlot(entry.published, current)) + || (Object.hasOwn(entry, "pending") && sameSlot(entry.pending, current)); +} + +/** + * Write-ahead half: call under the config mutation lock before the atomic config write. Only + * fields that actually change are recorded, so a no-op toggle claims nothing. The first capture + * of a field keeps its original forever; a pending value a crash left behind and the config + * shows is promoted to published first. + */ +export function prepareNativeIntentWrite(before: NativeIntentFields, after: NativeIntentFields): boolean { + return withConfigMutationLockSync(() => { + const changed = NATIVE_INTENT_FIELDS.filter(field => !sameSlot(before[field], after[field])); + if (!changed.length) return false; + const record = readNativeIntentOriginals() ?? { version: 1 as const, fields: {} }; + for (const field of changed) { + const entry: Entry = record.fields[field] ?? { original: structuredClone(before[field]) }; + if (Object.hasOwn(entry, "pending") && sameSlot(entry.pending, before[field])) entry.published = entry.pending; + entry.pending = structuredClone(after[field]); + record.fields[field] = entry; + } + writeRecord(record); + return true; + }); +} + +/** Completion half: mark each pending value the committed config now shows as published. */ +export function publishNativeIntentWrite(actual: NativeIntentFields): void { + withConfigMutationLockSync(() => { + const record = readNativeIntentOriginals(); + if (!record) return; + let changed = false; + for (const field of NATIVE_INTENT_FIELDS) { + const entry = record.fields[field]; + if (!entry || !Object.hasOwn(entry, "pending") || !sameSlot(entry.pending, actual[field])) continue; + entry.published = entry.pending; + delete entry.pending; + changed = true; + } + if (changed) writeRecord(record); + }); +} + +/** + * Restore standalone values and remove the record once the restored config is verified. + * Idempotent: a retry after an interrupted restore finds the original or the person's own + * value and only removes the record. Returns whether a record existed. + */ +export function restoreNativeIntentOriginals(guard: () => void): boolean { + return withConfigMutationLockSync(() => { + guard(); + const record = readNativeIntentOriginals(); + if (!record) return false; + const fields = Object.entries(record.fields) as [NativeIntentField, Entry][]; + const result = mutatePersistedConfig(config => { + guard(); + let changed = false; + for (const [field, entry] of fields) { + const current = readNativeIntentField(config, field); + if (!ownedByWriter(entry, current) || sameSlot(current, entry.original)) continue; + writeNativeIntentField(config, field, entry.original); + changed = true; + } + return { changed, value: config }; + }); + if (result.status === "unavailable") throw new Error(`Standalone native intent could not be restored (${result.reason})`); + const verified = nativeIntentFields(readConfigForAdmittedMutation()); + if (fields.some(([field, entry]) => ownedByWriter(entry, verified[field]) && !sameSlot(verified[field], entry.original))) { + throw new Error("Standalone native intent restore was not verified; rerun the operation"); + } + guard(); + clearRecord(); + return true; + }); +} diff --git a/src/client/route-intent-watch.ts b/src/client/route-intent-watch.ts new file mode 100644 index 00000000000..e5119332bb3 --- /dev/null +++ b/src/client/route-intent-watch.ts @@ -0,0 +1,141 @@ +/** Child-owned watch: authenticated reads only; local reconciliation stays with the runtime scheduler. */ +import type { OcxClientConnectionConfig } from "../types"; +import { LINK_ROUTE_INTENT_PATH } from "../link/management-routes"; +import { parseHomeRouteIntentDocument, routeIntentDigest, ROUTE_INTENT_DIGEST_PATTERN, type HomeRouteIntentDocument } from "../link/route-intent"; +import { ROUTE_INTENT_AFTER_HEADER, ROUTE_INTENT_WAKE_HEADER, ROUTE_INTENT_WATCH_HEADER } from "../link/route-intent-watch"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; +import { signalWithTimeout } from "../lib/abort"; +import { fetchBoundLinkRelay } from "./link-relay-transport"; + +const WATCH_READ_TIMEOUT_MS = 40_000; // Home wait (30 s), relay proof and bounded body share one budget. +const WATCH_MAX_BYTES = 64 * 1024; +const WATCH_MAX_HEADER_BYTES = 8 * 1024; +const WATCH_RETRY_MS = 5_000; +export type RouteIntentWatchKey = string | (() => string | null); +export interface RouteIntentWatchReadDeps { + /** Trusted complete-send test seam; production always uses the connection-bound relay. */ + fetchImpl?: typeof fetch; + signal?: AbortSignal; + timeoutMs?: number; +} +export type RouteIntentWatchResult = { kind: "fresh"; document: HomeRouteIntentDocument; watchSupported: boolean; wakeReason?: "changed" | "notified" | "timeout" } + | { kind: "unsupported" } | { kind: "cancelled" } | { kind: "failed"; reason: "route_intent_watch_unavailable" }; + +function cancel(response: Response): void { + try { void response.body?.cancel().catch(() => {}); } catch { /* no untrusted cleanup can extend the deadline */ } +} +/** No selectors, redirects, management writes, cached fallback or durable receipt writes. */ +export async function watchHomeRouteIntent( + connection: OcxClientConnectionConfig, key: RouteIntentWatchKey, afterDigest?: string, deps: RouteIntentWatchReadDeps = {}, +): Promise { + const failed = (): RouteIntentWatchResult => ({ kind: "failed", reason: "route_intent_watch_unavailable" }); + if (deps.signal?.aborted) return { kind: "cancelled" }; + if (connection.transport !== "link" || !connection.link || !connection.apiKeyId + || (afterDigest !== undefined && !ROUTE_INTENT_DIGEST_PATTERN.test(afterDigest))) return failed(); + const budget = deps.timeoutMs ?? WATCH_READ_TIMEOUT_MS; + if (!Number.isInteger(budget) || budget < 1 || budget > WATCH_READ_TIMEOUT_MS) return failed(); + const deadline = signalWithTimeout(budget, deps.signal); + let response: Response | undefined; + try { + // A long-lived runtime survives key rotation. Never retain the resolved credential across GETs. + const admissionKey = typeof key === "function" ? key() : key; + if (!admissionKey) return failed(); + const target = { tunnelPort: connection.link.tunnelPort, linkId: connection.link.linkId, apiKeyId: connection.apiKeyId, admissionKey }; + const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_ROUTE_INTENT_PATH}`; + const init: RequestInit = { method: "GET", redirect: "manual", signal: deadline.signal, + headers: { accept: "application/json", authorization: `Bearer ${admissionKey}`, [ROUTE_INTENT_WATCH_HEADER]: "1", + ...(afterDigest === undefined ? {} : { [ROUTE_INTENT_AFTER_HEADER]: afterDigest }) } }; + response = deps.fetchImpl ? await deps.fetchImpl(destination, init) : await fetchBoundLinkRelay(target, destination, init); + if (response.status === 404) return { kind: "unsupported" }; + if (response.status !== 200) return failed(); + let headerBytes = 0; + for (const [name, value] of response.headers) headerBytes += Buffer.byteLength(name) + Buffer.byteLength(value) + 4; + const support = response.headers.get(ROUTE_INTENT_WATCH_HEADER); + const wakeReason = response.headers.get(ROUTE_INTENT_WAKE_HEADER); + const declared = response.headers.get("content-length"); + if (headerBytes > WATCH_MAX_HEADER_BYTES || (support !== null && support !== "1") + || (wakeReason !== null && wakeReason !== "changed" && wakeReason !== "notified" && wakeReason !== "timeout") + || response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json" + || (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > WATCH_MAX_BYTES))) return failed(); + const read = await readBoundedResponseBytes(response, { maxBytes: WATCH_MAX_BYTES, signal: deadline.signal }); + if (read.oversized) return failed(); + const document = parseHomeRouteIntentDocument(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(read.bytes))); + return document ? { kind: "fresh", document, watchSupported: support === "1", ...(wakeReason === null ? {} : { wakeReason }) } : failed(); + } catch { return deps.signal?.aborted ? { kind: "cancelled" } : failed(); } + finally { if (response) cancel(response); deadline.cleanup(); } +} + +export interface RouteIntentRuntimeWatchDeps extends Omit { + /** Share this with the periodic tick. Resolve after apply/report; reject busy/failure to retain the event. */ + requestSync: () => Promise; + setTimer?: (callback: () => void, ms: number) => unknown; + clearTimer?: (timer: unknown) => void; +} +export interface RouteIntentRuntimeWatch { stop(): void } +const liveWatches = new Map(); + +/** One GET per live link; one in-flight scheduler request plus one coalesced pending event. */ +export function startRouteIntentWatch( + connection: OcxClientConnectionConfig, key: RouteIntentWatchKey, deps: RouteIntentRuntimeWatchDeps, +): RouteIntentRuntimeWatch { + if (connection.transport !== "link" || !connection.link) return { stop() {} }; + const linkId = connection.link.linkId; + const existing = liveWatches.get(linkId); + if (existing) return existing; + // Do not let caller mutation redirect a long-lived watch onto a different connection. + const owner = structuredClone(connection); + const abort = new AbortController(); + const set = deps.setTimer ?? ((cb, ms) => { const timer = setTimeout(cb, ms); timer.unref(); return timer; }); + const clear = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType)); + let timer: unknown, retry: unknown; + let stopped = false, syncing = false, pending = false; + let after: string | undefined; + const drain = async () => { + if (stopped || syncing) return; + syncing = true; + try { + while (pending && !stopped) { + pending = false; + try { await deps.requestSync(); } + catch { + pending = true; + if (!stopped) retry = set(() => { retry = undefined; void drain(); }, WATCH_RETRY_MS); + break; + } + } + } finally { syncing = false; } + }; + const request = () => { + pending = true; + if (retry === undefined) void drain(); + }; + const schedule = (ms: number) => { if (!stopped) timer = set(() => { timer = undefined; void poll(); }, ms); }; + const poll = async () => { + if (stopped) return; + const started = Date.now(); + const result = await watchHomeRouteIntent(owner, key, after, { ...deps, signal: abort.signal }); + if (stopped || result.kind === "cancelled") return; + // A pre-watch Home answers immediately forever. Its existing periodic sync remains authoritative. + if (result.kind === "unsupported" || (result.kind === "fresh" && !result.watchSupported)) return; + if (result.kind === "failed") { schedule(WATCH_RETRY_MS); return; } + const next = routeIntentDigest(result.document); + const unchanged = after === next; + after = next; + if (!unchanged || result.wakeReason !== "timeout") request(); + // Broken peers advertising support but never waiting cannot make a tight loop. + schedule(unchanged ? Math.max(0, 1_000 - (Date.now() - started)) : 0); + }; + const handle: RouteIntentRuntimeWatch = { stop() { + if (stopped) return; + stopped = true; pending = false; + abort.abort(); + if (timer !== undefined) clear(timer); + if (retry !== undefined) clear(retry); + timer = retry = undefined; + liveWatches.delete(linkId); + // An already-entered local apply is owned/fenced by requestSync, never cancelled mid-write here. + } }; + liveWatches.set(linkId, handle); + schedule(0); + return handle; +} diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 75e3462f147..1d1044f51ed 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -34,11 +34,15 @@ import { clientRestartRollbackAction, clientRestartSupervisor, createClientResta import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter"; import { restoreLinkClientRouting } from "./stop-routing"; import { markClientRuntimeStopping, markClientRuntimeStoppingWithRetry } from "./sync-runtime"; +import { startRouteIntentTick, type RouteIntentTick } from "../integrations/route-intent-tick"; +import { startRouteIntentWatch, type RouteIntentRuntimeWatch } from "./route-intent-watch"; let activeServer: Server | null = null; let activePort: number | null = null; let activeSupervisor: ClientLinkSupervisor | null = null; let activeReporter: LinkParityReporter | null = null; +let activeRouteWatch: RouteIntentRuntimeWatch | null = null; +let activeRouteSync: RouteIntentTick | null = null; let recycleScheduled = false; let recycleTimer: ReturnType | undefined; let recycleFlight: Promise | null = null; @@ -167,6 +171,9 @@ export async function recycleStandalone( const replacementEnv = port && process.env.OCX_SERVICE !== "1" ? withSiblingMarker(standaloneRecycleEnv(process.env, disconnectedTokenFingerprint), issueSiblingHandoff) : null; + activeReporter?.stop(); activeReporter = null; + activeRouteWatch?.stop(); activeRouteWatch = null; + activeRouteSync?.stop(); activeRouteSync = null; try { await activeSupervisor?.stop(); } catch (error) { @@ -377,6 +384,9 @@ export async function startClientRuntime( if (linkMode && state.value.link && linkKey) { activeReporter = createLinkParityReporter({ connection: state.value, linkKey, runtimeStartedAt: CLIENT_RUNTIME_STARTED_AT }); activeReporter.start(); + const scheduler = startRouteIntentTick(state.value, { onApplied: () => activeReporter?.requestReport() }); + activeRouteSync = scheduler; + activeRouteWatch = startRouteIntentWatch(state.value, linkKey, { requestSync: () => scheduler.requestSync() }); } }; if (!rollback) { @@ -408,6 +418,8 @@ export async function startClientRuntime( credentialObservation = null; activeReporter?.stop(); activeReporter = null; + activeRouteWatch?.stop(); activeRouteWatch = null; + activeRouteSync?.stop(); activeRouteSync = null; try { await activeSupervisor?.stop(); } catch (error) { @@ -487,6 +499,8 @@ export async function startClientRuntime( credentialObservation = null; activeReporter?.stop(); activeReporter = null; + activeRouteWatch?.stop(); activeRouteWatch = null; + activeRouteSync?.stop(); activeRouteSync = null; try { await activeSupervisor?.stop(); } catch (error) { diff --git a/src/client/sync-runtime.ts b/src/client/sync-runtime.ts index 883b013b6e9..c11d93824fe 100644 --- a/src/client/sync-runtime.ts +++ b/src/client/sync-runtime.ts @@ -35,6 +35,13 @@ export function clientSyncRuntimeGuard(): () => void { }; } +/** Offline enrollment keeps the configured port. A published runtime must agree before apply. */ +export function assertRouteIntentPort(port: number): void { + const runtime = readSyncRuntime(); + if (runtime?.clientStopping) throw new ClientSyncRuntimeError("client_runtime_stopping"); + if (runtime && runtime.port !== port) throw new ClientSyncRuntimeError("client_runtime_changed"); +} + /** The same C transaction excludes every sync commit; never mark a replacement runtime. */ export function markClientRuntimeStopping(expected: Required>): void { withConfigMutationLockSync(() => { diff --git a/src/codex/journal-config-restore.ts b/src/codex/journal-config-restore.ts new file mode 100644 index 00000000000..5f928131ee0 --- /dev/null +++ b/src/codex/journal-config-restore.ts @@ -0,0 +1,217 @@ +import { isDeepStrictEqual } from "node:util"; +import { chmodSync, closeSync, fsyncSync, mkdtempSync, openSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; +import { isOcxRoutingMarkerLine } from "./injected-marker"; +import { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "./subagent-defaults"; +import { rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "./toml-source-lines"; + +const ROOT_KEYS = new Set(["openai_base_url", "experimental_realtime_ws_base_url", "model_catalog_json", "model_provider", "web_search"]); +type Field = { start: number; end: number; text: string; value: unknown }; +export type JournalConfigFields = Record; + +/** Only injection-owned fields are captured; hooks, projects and paths never enter this map. */ +function fields(content: string): Map { + const { lines, rootEnd } = rootSourceLines(content); + // Inline/dotted tables have no independently replaceable owned block. Detect + // them before restoring any root key; leaving them behind would lose recovery + // evidence while the injected provider still exists. Decode key paths as TOML, + // including quoted keys, without parsing unrelated (possibly wide) integers. + let header = ""; + const keyToken = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; + const assignment = new RegExp(`^\\s*(${keyToken}(?:\\s*\\.\\s*${keyToken})*)\\s*=`); + for (let at = 0; at < lines.length; at++) { + const line = lines[at]!; + if (!line.structural) continue; + if (/^\s*\[/.test(line.text)) { header = line.text; continue; } + const prefix = assignment.exec(line.text)?.[1]; + if (!prefix) continue; + const context = Bun.TOML.parse(header) as Record; + // Explicit owned blocks are handled losslessly by the block scan below. + if (["model_providers", "profiles"].some(key => { + const table = context[key]; + return table && typeof table === "object" && Object.hasOwn(table, "opencodex"); + })) continue; + const shape = Bun.TOML.parse(`${header}\n${prefix} = {}`) as Record; + for (const key of ["model_providers", "profiles"]) { + const table = shape[key]; + if (!table || typeof table !== "object") continue; + if (Object.hasOwn(table, "opencodex")) throw new Error("unsupported dotted owned Codex table"); + if (Object.keys(table).length) continue; // an unrelated provider/profile path + let end = at + 1; + while (end < lines.length && !lines[end]!.structural) end++; + const value = Bun.TOML.parse(`${header}\n${sourceText(lines.slice(at, end))}`) as Record; + const actual = value[key]; + if (actual && typeof actual === "object" && Object.hasOwn(actual, "opencodex")) { + throw new Error("unsupported inline or dotted owned Codex table"); + } + } + } + const found = new Map(); + const put = (key: string, value: Field) => { + if (found.has(key)) throw new Error("duplicate owned Codex field"); + found.set(key, value); + }; + for (let at = 0; at < rootEnd; at++) { + if (!lines[at]!.structural) continue; + const key = rootAssignmentKey(lines[at]!.text); + if (!key || !ROOT_KEYS.has(key)) continue; + const span = sourceAssignment(lines, at, rootEnd); + if (!span) throw new Error("invalid owned Codex field"); + put(key, span); + } + for (let at = rootEnd; at < lines.length; at++) { + const line = lines[at]!; + if (!line.structural || !/^\s*\[/.test(line.text)) continue; + const header = Bun.TOML.parse(line.text) as Record; + const target = ["model_providers", "profiles"].find(key => { + const table = header[key]; + return table && typeof table === "object" && Object.hasOwn(table, "opencodex"); + }); + let end = at + 1; + while (end < lines.length && !(lines[end]!.structural && /^\s*\[/.test(lines[end]!.text))) end++; + // A marker adjacent to the next header belongs to that next block, never this one. + if (end < lines.length && lines[end - 1]?.structural && isOcxRoutingMarkerLine(lines[end - 1]!.text)) end--; + if (target) { + const table = (header[target] as Record).opencodex; + if (!table || typeof table !== "object" || Object.keys(table).length) throw new Error("unsupported owned child table"); + const text = sourceText(lines.slice(at, end)); + // Child tables are refused rather than accidentally treating them as the parent block. + const value = Bun.TOML.parse(text); + put(`${target}.opencodex`, { start: at, end, text, value }); + } + at = end - 1; + } + return found; +} + +export function captureJournalConfigFields(content: string, ownership: Ownership): JournalConfigFields { + const captured: JournalConfigFields = {}; + for (const [key, span] of fields(content)) { + const recorded = recordedValues(ownership).get(key); + if (isDeepStrictEqual(span.value, recorded) || key === "model_provider" && span.value === "opencodex" + || key === "model_providers.opencodex" || key === "profiles.opencodex") captured[key] = span.text; + } + // Injection removes the root selection and old inline OCX profiles in URL-only mode. + for (const key of ["model_provider", "model_providers.opencodex", "profiles.opencodex"]) captured[key] ??= null; + return captured; +} + +interface Ownership { + injectedConfigFields?: JournalConfigFields; + injectedOpenaiBaseUrl?: string | null; + injectedRealtimeWsBaseUrl?: string | null; + injectedCatalogPath?: string | null; + injectedRootWebSearch?: string | null; + replacedRootWebSearch?: string | null; + injectedSubagentDefaults?: WrittenSubagentDefaults | null; +} + +function recordedValues(journal: Ownership): Map { + const values = new Map(); + for (const [key, value] of [ + ["openai_base_url", journal.injectedOpenaiBaseUrl], + ["experimental_realtime_ws_base_url", journal.injectedRealtimeWsBaseUrl], + ["model_catalog_json", journal.injectedCatalogPath], + ["web_search", journal.injectedRootWebSearch], + ] as const) if (typeof value === "string") values.set(key, value); + return values; +} + +/** Three-way restore: an edited preference stays; an edited routing field blocks hand-back. */ +export function restoreJournalConfigFields(current: string, original: string, journal: Ownership, recoverOwnedEdits = false): string | null { + try { + const defaults = transformManagedSubagentDefaults(current, null, journal.injectedSubagentDefaults); + if (!defaults.ok) return null; + current = defaults.content; + const now = fields(current), before = fields(original); + if (journal.replacedRootWebSearch) { + const replaced = fields(journal.replacedRootWebSearch).get("web_search"); + if (!replaced) return null; + before.set("web_search", replaced); + } + const expected = new Map(); + if (journal.injectedConfigFields) { + for (const [key, text] of Object.entries(journal.injectedConfigFields)) { + if (text === null) { expected.set(key, undefined); continue; } + const parsed = fields(text); + const field = parsed.get(key); + if (!field) return null; + expected.set(key, field.value); + } + } else { + // Version-1 journals predate field snapshots. Only their explicit value evidence + // authorizes removal; a whole-file hash alone never authorizes a guessed URL. + for (const [key, value] of recordedValues(journal)) expected.set(key, value); + if (now.get("model_provider")?.value === "opencodex") expected.set("model_provider", "opencodex"); + } + const { bom, lines } = rootSourceLines(current); + if (recoverOwnedEdits) { + // Legacy journals lack field snapshots. An exact structural marker is evidence + // only for its adjacent owned span; loopback-looking values alone prove nothing. + for (const [key, span] of now) { + const marker = lines[span.start - 1]; + if (key !== "web_search" && !expected.has(key) && marker?.structural && isOcxRoutingMarkerLine(marker.text)) expected.set(key, span.value); + } + } + const replacements = new Map(); + const prepend: string[] = []; + const append: string[] = []; + const eol = lines.find(line => line.eol)?.eol || "\n"; + for (const key of new Set([...now.keys(), ...expected.keys()])) { + const present = now.get(key), prior = before.get(key); + if (isDeepStrictEqual(present?.value, prior?.value)) continue; + if (!expected.has(key)) { + if (key === "model" || key.startsWith("agents.") || key === "web_search") continue; + return null; + } + const injected = expected.get(key); + if (isDeepStrictEqual(injected, prior?.value)) continue; // never owned + if (!isDeepStrictEqual(present?.value, injected)) { + if (key === "model" || key.startsWith("agents.") || key === "web_search") continue; + if (!recoverOwnedEdits) return null; + } + const replacement = prior ? prior.text.replace(/\r?\n/g, eol).replace(/(?:\r?\n)?$/, eol) : ""; + if (present) { + let start = present.start; + if (start > 0 && lines[start - 1]!.structural && isOcxRoutingMarkerLine(lines[start - 1]!.text)) start--; + replacements.set(start, { end: present.end, text: replacement }); + } else if (replacement) { + // A removed root value can safely return before the first table. Missing + // non-root spans require a containing table, so refuse ambiguous placement. + if (key.startsWith("agents.")) return null; + if (key.includes(".")) append.push(replacement); + else prepend.push(replacement); + } + } + let out = bom + prepend.join(""); + for (let at = 0; at < lines.length;) { + const replace = replacements.get(at); + if (replace) { out += replace.text; at = replace.end; } + else { out += sourceText([lines[at]!]); at++; } + } + return append.length ? `${out}${out.endsWith("\n") ? "" : eol}${append.join("")}` : out; + } catch { return null; } +} + +/** Private, exclusive, durable preimage. Never registered as an uninstall-owned artifact. */ +export function snapshotDisconnectRecovery(home: string, config: string | null, profile: string | null, journal: unknown): string { + const dir = mkdtempSync(join(home, ".ocx-disconnect-recovery-")); + chmodSync(dir, 0o700); + hardenSecretDir(dir, { required: true }); + const path = join(dir, "snapshot.json"); + const fd = openSync(path, "wx", 0o600); + try { + // Harden the empty file before placing possibly sensitive profile/config bytes in it. + hardenSecretPath(path, { required: true }); + writeFileSync(fd, JSON.stringify({ version: 1, config, profile, journal })); + fsyncSync(fd); + } finally { closeSync(fd); } + if (process.platform !== "win32") { + for (const entry of [dir, home]) { + const directory = openSync(entry, "r"); + try { fsyncSync(directory); } finally { closeSync(directory); } + } + } + return dir; +} diff --git a/src/codex/journal.ts b/src/codex/journal.ts index fb8311a551e..0836cbca561 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -10,6 +10,7 @@ import { configWriteLockFailureMessage, withConfigWriteLockHeld } from "./config import type { LockHandle } from "./config-write-lock"; import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; import { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "./subagent-defaults"; +import { captureJournalConfigFields, restoreJournalConfigFields, snapshotDisconnectRecovery, type JournalConfigFields } from "./journal-config-restore"; /** * Exported so that anything reasoning ABOUT the journal points at the journal. @@ -31,6 +32,7 @@ interface Journal { originalConfig: string; originalProfile: string | null; injectedConfigHash?: string; + injectedConfigFields?: JournalConfigFields; injectedProfileHash?: string | null; /** * The exact root `openai_base_url` this injection wrote, when it wrote one. @@ -94,6 +96,7 @@ interface Journal { } export interface RestoreJournalResult { + recoveryBackupDir?: string; /** The shared config write lock was held by another opencodex writer; restore did not run. */ lockBusy?: true; lockUnsafe?: string; @@ -247,6 +250,7 @@ export function markJournalInjectedState( // hashing those newer bytes and restoring the first snapshot would delete those edits. // Keep the first hash so changed/reinjected configs take the owned-field fallback path. journal.injectedConfigHash ??= sha256(config) ?? undefined; + journal.injectedConfigFields = captureJournalConfigFields(config, ownership); // The profile file is wholly generated by OpenCodex, so its latest exact hash remains safe // to refresh and lets restore remove the latest generated profile after a port change. journal.injectedProfileHash = sha256(profile); @@ -354,7 +358,7 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner } export function restoreJournalState( - options: { heldConfigWriteLock?: LockHandle } = {}, + options: { heldConfigWriteLock?: LockHandle; preserveConfigEdits?: boolean; recoverConfigForClient?: string; beforeWrite?: () => void } = {}, ): RestoreJournalResult { if (codexHomeIsAbsent(CODEX_HOME)) return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, @@ -368,6 +372,7 @@ export function restoreJournalState( configChanged: false, profileChanged: false, complete: false, unverified: true, ownershipRefusal: error.reason }; } beginCodexWriteSection(held); + options.beforeWrite?.(); const journal = readJournal(); if (!journal) { return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, @@ -377,9 +382,12 @@ export function restoreJournalState( const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; const comparison = compareJournalState(journal, currentConfig, currentProfile); const { configUnchanged, profileUnchanged } = comparison; + const recovery = options.recoverConfigForClient !== undefined; + if (recovery && (!options.recoverConfigForClient || journal.owner?.kind !== "client" + || journal.owner.apiKeyId !== options.recoverConfigForClient)) throw new Error("disconnect recovery refused: Codex journal must belong to the connected client key"); // A legacy record or interruption before markJournalInjectedState is not proof that // later bytes belong to OpenCodex. Keep the whole pair and its recovery evidence intact. - if (comparison.unverified) { + if ((!recovery && comparison.unverified) || (recovery && !profileUnchanged)) { return { configRestored: comparison.configAlreadyOriginal, profileRestored: comparison.profileAlreadyOriginal, @@ -388,10 +396,18 @@ export function restoreJournalState( configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete: false, - unverified: true, + unverified: comparison.unverified, }; } + const fieldRestore = (recovery || options.preserveConfigEdits && !configUnchanged) && currentConfig !== null + ? restoreJournalConfigFields(currentConfig, comparison.originalConfig, journal, recovery) : null; + if ((recovery || options.preserveConfigEdits && !configUnchanged) && fieldRestore === null) { + return { configRestored: false, profileRestored: comparison.profileAlreadyOriginal, + configRewritten: false, profileRewritten: false, configChanged: true, + profileChanged: !profileUnchanged, complete: false, unverified: false }; + } + // A matching byte hash does not authorize following a newly substituted profile link. // Refuse the complete pair before restoring config or deleting its recovery journal. let profileIsSymlink = false; @@ -408,12 +424,16 @@ export function restoreJournalState( }; } assertCodexHomeOwner(CODEX_HOME); + options.beforeWrite?.(); + const recoveryBackupDir = recovery + ? snapshotDisconnectRecovery(CODEX_HOME, currentConfig, currentProfile, journal) : undefined; let configRestored = comparison.configAlreadyOriginal; let profileRestored = comparison.profileAlreadyOriginal; let configRewritten = false; let profileRewritten = false; - if (configUnchanged && !configRestored) { - publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalConfig, undefined, hooks)); + if ((configUnchanged || fieldRestore !== null) && !configRestored) { + options.beforeWrite?.(); + publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, fieldRestore ?? comparison.originalConfig, undefined, hooks)); configRestored = true; configRewritten = true; } @@ -445,6 +465,7 @@ export function restoreJournalState( catch { complete = false; } } return { + ...(recoveryBackupDir ? { recoveryBackupDir } : {}), configRestored, profileRestored, configRewritten, diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 5df3213e410..38a1a021c01 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1,4 +1,5 @@ import * as z from "zod/v4"; +import { EXPORT_CLIENT_IDS } from "../../clients/client-ids"; import { accountAutoPausePolicySchema, accountPauseDetailsSchema } from "./account-auto-pause"; import { anthropicAccountPoolSchema } from "./anthropic-account-pool"; import { join } from "node:path"; @@ -957,7 +958,7 @@ export const remoteGuiConfigSchema = z.object({ allowInsecureHttp: z.boolean().optional(), }).strict(); -const connectedClientIdSchema = z.enum(["codex", "claude"]); +const connectedClientIdSchema = z.enum(["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS]); const clientTimestampSchema = z.string().datetime({ offset: true }); const clientTransportSchema = z.enum(["hub", "link"]); const linkTransportSchema = z.object({ @@ -980,7 +981,7 @@ export const clientConnectionSchema = z.object({ managementTransport: z.enum(["direct", "relay"]), transport: clientTransportSchema.optional(), link: linkTransportSchema.optional(), - selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => { + selectedClients: z.array(connectedClientIdSchema).superRefine((clients, ctx) => { if (new Set(clients).size !== clients.length) { ctx.addIssue({ code: "custom", message: "must contain unique client ids" }); } diff --git a/src/integrations/route-intent-catalog.ts b/src/integrations/route-intent-catalog.ts new file mode 100644 index 00000000000..161f1c5df09 --- /dev/null +++ b/src/integrations/route-intent-catalog.ts @@ -0,0 +1,26 @@ +/** Passive routing facts only. No catalog discovery, persistence, targets, or credentials. */ +import { comboPublicModelId, isValidComboId } from "../combos/identifiers"; +import type { OcxConfig } from "../types"; + +export const MAX_ROUTE_CATALOG_BYTES = 1024 * 1024; +export interface RouteCatalogFacts { version: 1; combos: Array<{ publicId: string; comboId: string }> } +export function parseRouteCatalogFacts(raw: unknown): RouteCatalogFacts | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).sort().join(",") !== "combos,version" + || !("version" in raw) || raw.version !== 1 || !("combos" in raw) || !Array.isArray(raw.combos) || raw.combos.length > 4096) return null; + const seen = new Set(); + const combos: RouteCatalogFacts["combos"] = []; + for (const row of raw.combos) { + if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).sort().join(",") !== "comboId,publicId" + || typeof row.comboId !== "string" || !isValidComboId(row.comboId) || typeof row.publicId !== "string" + || !row.publicId || row.publicId.length > 512 || /[\x00-\x1f\x7f]/.test(row.publicId) || seen.has(row.publicId)) return null; + seen.add(row.publicId); combos.push({ publicId: row.publicId, comboId: row.comboId }); + } + return { version: 1, combos }; +} +export function projectRouteCatalogFacts(config: OcxConfig): RouteCatalogFacts { + const facts = parseRouteCatalogFacts({ version: 1, combos: Object.entries(config.combos ?? {}).map(([comboId, combo]) => ({ + publicId: comboPublicModelId(comboId, combo), comboId, + })) }); + if (!facts || Buffer.byteLength(JSON.stringify(facts)) > MAX_ROUTE_CATALOG_BYTES) throw new Error("Route catalog facts are invalid or too large"); + return facts; +} diff --git a/src/integrations/route-intent-connected.ts b/src/integrations/route-intent-connected.ts new file mode 100644 index 00000000000..e2669673eac --- /dev/null +++ b/src/integrations/route-intent-connected.ts @@ -0,0 +1,167 @@ +/** Read-only Home fetches, followed by local materialization under the caller's sync fence. */ +import type { ExportModel } from "../clients/config-export"; +import { loadConfig } from "../config"; +import { withClientLifecycleSync } from "../client/lifecycle-lock"; +import { nativeOpenAiSlugs } from "../codex/catalog/metadata"; +import { COMBO_NAMESPACE } from "../combos/identifiers"; +import type { LocalRouteIntentOptions } from "../client/route-intent-apply"; +import { clearHomeRouteIntentRecord, fetchHomeRouteIntent, homeRouteIntentReceipt, linkBoundFetch, readHomeRouteIntentRecord, + writeHomeRouteIntentRecord, type LinkHomeConnection, type LinkHomeDeps } from "../client/link-home"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; +import { ROUTE_CLIENT_IDS, type HomeRouteIntentDocument } from "../link/route-intent"; +import type { OcxClientConnectionConfig } from "../types"; +import type { RouteIntentReceipt } from "./route-intent-contract"; +import { effectiveRouteIntent, readLocalRouteIntentOff, readRouteIntentReceipt, writeRouteIntentReceipt } from "./route-intent-state"; +import { resolveInitialRouteIntent, initialRouteOutcomeMetadata } from "./route-intent-initial"; +import { LINK_ROUTE_CATALOG_PATH } from "../link/management-routes"; +import { MAX_ROUTE_CATALOG_BYTES, parseRouteCatalogFacts } from "./route-intent-catalog"; + +export interface PreparedConnectedRouteIntent { document: HomeRouteIntentDocument; fetchedAt: string; stale: boolean; localOnly?: true } + +/** Provisioning preview; connect records its enabled-only choices after enrollment. */ +export function initialConnectRouteIntent( + prepared: PreparedConnectedRouteIntent | undefined, selected: OcxClientConnectionConfig["selectedClients"], explicit = false, +): PreparedConnectedRouteIntent | undefined { + if (!prepared || !explicit) return prepared; + const document = structuredClone(prepared.document); + for (const client of ROUTE_CLIENT_IDS) { + const enabled = selected.includes(client); + if (client === "codex" || client === "claude" || enabled) { + document.clients[client] = { ...document.clients[client], enabled }; + document.sources[client] = "device"; + } + } + return { ...prepared, document }; +} + +export async function prepareConnectedRouteIntent( + connection: LinkHomeConnection, token: string, deps?: LinkHomeDeps, beforeWrite?: () => void, +): Promise { + if (connection.transport !== "link" || !connection.link) return undefined; + const fetched = await fetchHomeRouteIntent(connection, token, deps); + if (fetched.kind === "fresh") return { document: resolveInitialRouteIntent(connection.link.linkId, fetched.document, beforeWrite), fetchedAt: new Date().toISOString(), stale: false }; + const previous = fetched.kind === "failed" ? readHomeRouteIntentRecord(connection.link.linkId) : null; + return previous ? { document: resolveInitialRouteIntent(connection.link.linkId, previous.document, beforeWrite), fetchedAt: previous.fetchedAt, stale: true } : undefined; +} + +export function connectedRouteSelectedClients(prepared: PreparedConnectedRouteIntent | undefined, fallback: OcxClientConnectionConfig["selectedClients"]) { + const off = new Set(readLocalRouteIntentOff()); + return prepared ? effectiveRouteIntent(prepared.document).selectedClients : fallback.filter(id => !off.has(id)); +} + +/** Read the exact /v1/models rows this Child relays, preserving ids byte-for-byte. */ +export async function loadConnectedRouteModels(connection: LinkHomeConnection, token: string, deps: LinkHomeDeps = {}): Promise { + if (!connection.link) throw new Error("A Link connection is required for the local served roster"); + const fetchImpl = deps.fetchImpl ?? linkBoundFetch(connection, token); + const response = await fetchImpl(`http://127.0.0.1:${connection.link.tunnelPort}/v1/models`, { + method: "GET", headers: { authorization: `Bearer ${token}` }, redirect: "manual", signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000), + }); + if (!response.ok) { await response.body?.cancel(); throw new Error("The Child served model roster is unavailable"); } + const body = await readBoundedResponseBytes(response, { maxBytes: 8 * 1024 * 1024, signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000) }); + if (body.oversized) throw new Error("The Child served model roster is too large"); + const raw: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body.bytes)); + if (!raw || typeof raw !== "object" || !("data" in raw) || !Array.isArray(raw.data)) throw new Error("Invalid Child served model roster"); + const seen = new Set(); + const nativeIds = new Set(nativeOpenAiSlugs()); + const comboIdentity = new Map(); + if (raw.data.some(row => row && typeof row === "object" && row.is_combo === true)) { + // is_combo describes the inbound adapter in owned_by. This separate passive projection + // contains only public selectors and canonical combo keys, never model discovery. + const response = await fetchImpl(`http://127.0.0.1:${connection.link.tunnelPort}${LINK_ROUTE_CATALOG_PATH}`, { + method: "GET", headers: { authorization: `Bearer ${token}` }, redirect: "manual", signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000), + }); + if (!response.ok) { await response.body?.cancel(); throw new Error("Combo route identity is unavailable"); } + const body = await readBoundedResponseBytes(response, { maxBytes: MAX_ROUTE_CATALOG_BYTES, signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000) }); + if (body.oversized) throw new Error("Combo metadata is too large"); + const facts = parseRouteCatalogFacts(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body.bytes))); + if (!facts) throw new Error("Invalid combo route metadata"); + for (const row of facts.combos) comboIdentity.set(row.publicId, row.comboId); + } + return raw.data.map((row: unknown): ExportModel => { + if (!row || typeof row !== "object" || !("id" in row) || typeof row.id !== "string" || !row.id || seen.has(row.id)) { + throw new Error("Invalid or duplicate Child served model identifier"); + } + seen.add(row.id); + const owner = "owned_by" in row ? row.owned_by : undefined; + if ("is_combo" in row && row.is_combo === true) { + const id = comboIdentity.get(row.id); + if (!id) throw new Error("The served combo has no verified route identity"); + return { namespaced: row.id, provider: COMBO_NAMESPACE, id }; + } + const canonical = "native_id" in row ? row.native_id : row.id; + if (typeof canonical !== "string" || !canonical || canonical !== canonical.trim() || canonical.length > 4096 || /[\x00-\x1f\x7f]/.test(canonical)) { + throw new Error("Invalid canonical served model identity"); + } + const slash = canonical.indexOf("/"); + const native = slash < 0 && owner !== COMBO_NAMESPACE && (nativeIds.has(canonical) || owner === "openai"); + if (slash < 0 && !native) throw new Error("The served alias has no verified canonical route identity"); + if (!native && (slash === 0 || slash === canonical.length - 1)) throw new Error("Invalid canonical routed model identity"); + return { namespaced: row.id, provider: native ? "openai" : canonical.slice(0, slash), + id: native ? canonical : canonical.slice(slash + 1), ...(native ? { native: true } : {}) }; + }); +} + +export async function applyConnectedRouteIntent( + connection: OcxClientConnectionConfig, token: string, prepared: PreparedConnectedRouteIntent | undefined, + options: LocalRouteIntentOptions, deps: LinkHomeDeps = {}, +): Promise { + if (!connection.link) return undefined; + const localOff = readLocalRouteIntentOff(options.statePath); + if (!prepared && localOff.length > 0) { + const previous = readRouteIntentReceipt(options.statePath); + const document: HomeRouteIntentDocument = previous?.document ?? { version: 1, home: { name: "Home", version: "unavailable" }, + clients: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, { enabled: connection.selectedClients.includes(id) }])) as HomeRouteIntentDocument["clients"], + sources: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, "builtin"])) as HomeRouteIntentDocument["sources"] }; + prepared = { document, stale: true, fetchedAt: new Date().toISOString(), localOnly: true }; + options = { ...options, onlyClients: localOff }; + } + if (!prepared) return undefined; + const { materializeLocalRouteIntent } = await import("../client/route-intent-apply"); + const intent = prepared; + const linkId = connection.link.linkId; + const guard = options.beforeWrite ?? (() => {}); + const previousModels = readRouteIntentReceipt(options.statePath)?.models ?? []; + const local = loadConfig(); + const config = { ...local, hostname: "127.0.0.1", unauthenticatedLoopbackListener: undefined }; + const previousHomeRecord = readHomeRouteIntentRecord(linkId); + const { createIntegrationStateStore } = await import("./store"); + const { captureFileIntentOriginals } = await import("../client/route-intent-file-originals"); + const store = options.store ?? createIntegrationStateStore(); + withClientLifecycleSync(() => { + guard(); + // Write-ahead, before any client changes: which file integrations were standalone. + captureFileIntentOriginals(linkId, readRouteIntentReceipt(options.statePath)?.linkId === undefined, store.readRecordsStrict()); + // A failed materialization must not leave the separately reportable Home proof current. + clearHomeRouteIntentRecord(); + }, options.lifecycleLockDeps); + const persist = (receipt: RouteIntentReceipt) => { + receipt.outcomes = receipt.outcomes.map(initialRouteOutcomeMetadata(linkId, intent.document)); + withClientLifecycleSync(() => { + guard(); + writeRouteIntentReceipt(receipt, options.statePath); + if (intent.localOnly) { clearHomeRouteIntentRecord(); return; } + const record = homeRouteIntentReceipt(linkId, intent.document, intent.fetchedAt, receipt.appliedAt, receipt.outcomes, previousHomeRecord); + try { writeHomeRouteIntentRecord(record); } + catch (error) { clearHomeRouteIntentRecord(); throw error; } + }, options.lifecycleLockDeps); + }; + // Disable and opt-out need no catalog. Their proven outcomes survive an offline Home; + // every enabled client stays stale until its local served model roster is available. + const effective = effectiveRouteIntent(intent.document, options.statePath); + const off = ROUTE_CLIENT_IDS.filter(id => !effective.clients[id].enabled && (!options.onlyClients || options.onlyClients.includes(id))); + const receipt = await materializeLocalRouteIntent(config, intent.document, previousModels, { ...options, onlyClients: off }); + persist(receipt); + if (intent.localOnly) { receipt.catalogPending = true; persist(receipt); return receipt; } + if (off.length === ROUTE_CLIENT_IDS.length) return receipt; + let models: ExportModel[]; + try { models = await loadConnectedRouteModels(connection, token, deps); } + catch { + guard(); + receipt.catalogPending = true; + persist(receipt); + return receipt; + } + const applied = await materializeLocalRouteIntent(config, intent.document, models, options); + persist(applied); + return applied; +} diff --git a/src/integrations/route-intent-contract.ts b/src/integrations/route-intent-contract.ts new file mode 100644 index 00000000000..1c56a98bef2 --- /dev/null +++ b/src/integrations/route-intent-contract.ts @@ -0,0 +1,55 @@ +import type { ExportModel } from "../clients/config-export"; +import type { HomeRouteIntentDocument, RouteClientId, RouteClientIntent, RouteClientOutcome } from "../link/route-intent"; +import type { OcxConfig } from "../types"; +import type { IntegrationStateStore } from "./store"; + +export type { RouteClientOutcome } from "../link/route-intent"; + +export interface RouteIntentReceipt { + version: 1; + linkId?: string; + /** Last local served roster, retained for offline owned-conflict recovery. No credentials. */ + models?: readonly ExportModel[]; + document: HomeRouteIntentDocument; + clients: Record; + sources: Record; + selectedClients: RouteClientId[]; + outcomes: RouteClientOutcome[]; + /** Null if any client failed, conflicts, or is not installed. Never a partial success. */ + appliedRouteIntentDigest: string | null; + appliedAt: string; + /** Off intents are proved, while enabled clients await the local served model roster. */ + catalogPending?: true; +} + +export interface RouteIntentApplyInput { + config: OcxConfig; + linkId?: string; + document: HomeRouteIntentDocument; + /** The local served roster, never Home-generated client contributions. */ + models: readonly ExportModel[]; + home?: string; + env?: NodeJS.ProcessEnv; + store?: IntegrationStateStore; + /** Isolates local opt-outs and receipts; defaults to the local config directory. */ + statePath?: string; + /** Home passes an empty list; undefined reads this Child's persistent local opt-outs. */ + localOff?: readonly RouteClientId[]; + overwriteConflict?: boolean; + /** When a client toggle grants takeover, other clients retain ordinary conflict checks. */ + overwriteClient?: RouteClientId; + /** Rechecks connection/runtime ownership immediately before each writer commits. */ + beforeWrite?: () => void; + /** Reconcile only explicit local opt-outs when no Home document is available. */ + onlyClients?: readonly RouteClientId[]; + /** Clients skipped by onlyClients keep the previous receipt outcome for the same client intent. */ + retainSkippedOutcomes?: boolean; + /** Explicit disconnect recovery only: snapshot an owned conflicting block, then remove it. */ + recoverConflicts?: boolean; + /** Lifecycle exclusion around async file writers; native adapters manage their own lock order. */ + withFileWrites?: (work: () => Promise) => Promise; + withReceiptWrite?: (write: () => void) => void; + now?: () => Date; + /** Native adapters retain their existing ownership/journal implementations. */ + applyNative?: (client: "codex" | "claude" | "claude-desktop" | "grok", intent: RouteClientIntent) => Promise; +} diff --git a/src/integrations/route-intent-disconnect.ts b/src/integrations/route-intent-disconnect.ts new file mode 100644 index 00000000000..2ec9d6e288d --- /dev/null +++ b/src/integrations/route-intent-disconnect.ts @@ -0,0 +1,102 @@ +/** File clients drain before disconnect removes its connection and service credential. */ +import { loadConfig } from "../config"; +import { lstatSync } from "node:fs"; +import { readClientConnectionState, sameClientConnectionOwner } from "../client/state"; +import { withClientLifecycle, withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { readDesktopDisconnectReceipt, type DesktopDisconnectReceipt } from "../claude/desktop-remote-store"; +import { readServiceApiTokenState } from "../lib/service-secrets"; +import { readLocalRouteIntentOff, readRouteIntentReceiptStrict } from "./route-intent-state"; +import { homeRouteIntentPath } from "../client/link-home"; +import type { OcxClientConnectionConfig } from "../types"; + +/** Droid's empty-roster contribution has no selectors, so its generic classifier cannot prove absence. */ +async function droidRoutingAbsent(port: number): Promise { + const { INTEGRATION_CLIENTS } = await import("./registry"); + const { defaultIntegrationIO, loadTarget } = await import("./config-io"); + const { createIntegrationStateStore } = await import("./store"); + const target = loadTarget(defaultIntegrationIO(createIntegrationStateStore()), INTEGRATION_CLIENTS.droid.configPath()); + if (!target.ok) return false; + if (target.before === null) return true; + try { + const value: unknown = JSON.parse(target.before); + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const rows = (value as Record).customModels; + if (rows === undefined) return true; + if (!Array.isArray(rows)) return false; + return rows.every(row => { + if (!row || typeof row !== "object" || Array.isArray(row)) return false; + if (typeof row.displayName === "string" && row.displayName.startsWith("OpenCodex:")) return false; + if (typeof row.baseUrl !== "string") return false; + const url = new URL(row.baseUrl); + return !(["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.port === String(port)); + }); + } catch { return false; } +} + +export async function cleanupConnectedRouteIntent( + connection: OcxClientConnectionConfig, expected: DesktopDisconnectReceipt, lifecycleLockDeps?: ClientLifecycleLockDeps, recoverConflicts = false, +): Promise { + const receipt = readRouteIntentReceiptStrict(); + if (!receipt) { + let homeReceiptPresent = false; + try { lstatSync(homeRouteIntentPath()); homeReceiptPresent = true; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (connection.link) { + const { createIntegrationStateStore } = await import("./store"); + const { isIntegrationClientId } = await import("./registry"); + const { readIntegrationState } = await import("./state"); + const { listAsideProfileStores } = await import("./aside-profile-context"); + const store = createIntegrationStateStore(); + const owned = Object.keys(store.readRecordsStrict()).length > 0 + || listAsideProfileStores(store).some(child => Object.keys(child.store.readRecordsStrict()).length > 0); + const config = loadConfig(); + // Older Homes and --no-sync may never invoke the controller. Only disk absence, + // not selectedClients alone, proves those file clients have nothing to drain. + const unproved = connection.selectedClients.some(id => id !== "codex" && id !== "claude" + && (!isIntegrationClientId(id) || readIntegrationState({ clientId: id, config, port: config.port, models: [], store }).state !== "absent")); + const droidUnproved = connection.selectedClients.includes("droid") && !await droidRoutingAbsent(config.port); + if (homeReceiptPresent || owned || unproved || droidUnproved) { + throw new Error("Route intent cleanup receipt is missing; preserve the connection and repair its route ownership evidence before disconnect"); + } + } + return; + } + // A valid unbound receipt is standalone Home state, not evidence of a Child apply. + if (receipt.linkId === undefined) return; + if (!connection.link || receipt.linkId !== connection.link.linkId) throw new Error("Route intent cleanup receipt belongs to another context; preserve it before disconnect"); + const guard = () => { + const pending = readDesktopDisconnectReceipt(); + const current = readClientConnectionState(); + const token = readServiceApiTokenState(); + if (pending.kind !== "valid" || JSON.stringify(pending.value) !== JSON.stringify(expected) + || current.kind !== "connected" || !sameClientConnectionOwner(current.value, connection) + || current.value.pendingOperation || token.kind !== "present" || token.fingerprint !== connection.tokenFingerprint) { + throw new Error("client_disconnect_owner_changed"); + } + }; + const local = loadConfig(); + const { cleanupRouteIntent } = await import("./route-intent"); + const { stripGrokConfig } = await import("../grok/inject"); + const { injectClaudeAgentDefs } = await import("../claude/agents-inject"); + const config = { ...local, hostname: "127.0.0.1", unauthenticatedLoopbackListener: undefined }; + // Integrations the Child owned before this link come back instead of being removed; a local + // opt-out is the person's own later choice and wins. + const { readFileIntentOriginals } = await import("../client/route-intent-file-originals"); + const originals = readFileIntentOriginals(); + const optedOut = new Set(readLocalRouteIntentOff()); + const standalone = originals?.linkId === connection.link.linkId + ? Object.fromEntries(Object.entries(originals.clients).filter(([client]) => !optedOut.has(client))) : {}; + const outcomes = await cleanupRouteIntent({ config, linkId: connection.link.linkId, document: receipt.document, models: receipt.models ?? [], beforeWrite: guard, recoverConflicts, + withReceiptWrite: write => withClientLifecycleSync(() => { guard(); write(); }, lifecycleLockDeps), + withFileWrites: work => withClientLifecycle(work, lifecycleLockDeps) }, standalone); + if (outcomes.some(row => row.outcome !== "current")) throw new Error("Route intent cleanup was partial; ownership and disconnect receipts retained. Repair the conflicting client and rerun ocx disconnect."); + withClientLifecycleSync(() => { + guard(); + const grok = stripGrokConfig(); + if (!grok.ok) throw new Error("Grok route cleanup was partial; rerun ocx disconnect after repair"); + const config = loadConfig(); + const agents = injectClaudeAgentDefs({ ...config, claudeCode: { ...config.claudeCode, enabled: false } }, {}); + if (agents === null) throw new Error("Claude route cleanup was partial; rerun ocx disconnect after repair"); + guard(); + }, lifecycleLockDeps); +} diff --git a/src/integrations/route-intent-initial.ts b/src/integrations/route-intent-initial.ts new file mode 100644 index 00000000000..73cd8030a07 --- /dev/null +++ b/src/integrations/route-intent-initial.ts @@ -0,0 +1,64 @@ +/** Provisioning choices are local enabled-only overrides, never writes to Home policy. */ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { isRouteClientId, ROUTE_CLIENT_IDS, type HomeRouteIntentDocument, type RouteClientId, type RouteClientOutcome } from "../link/route-intent"; +import { routeIntentStatePath } from "./route-intent-state"; + +type InitialSelection = Partial>; +function recordPath(linkId: string, root: string): string { + if (!/^lnk_[0-9a-f]{16}$/.test(linkId)) throw new Error("Invalid initial route intent link identity"); + return join(root, `initial-${linkId}.json`); +} +export function readInitialRouteIntent(linkId: string, root = routeIntentStatePath()): InitialSelection { + let raw: unknown; + try { raw = JSON.parse(readBoundedRegularFileSync(recordPath(linkId, root), 64 * 1024).toString("utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error("Initial route selection is unreadable"); } + if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("version" in raw) || raw.version !== 1 + || !("linkId" in raw) || raw.linkId !== linkId || !("clients" in raw) || !raw.clients || typeof raw.clients !== "object" || Array.isArray(raw.clients)) { + throw new Error("Invalid initial route selection"); + } + const clients: InitialSelection = {}; + for (const [id, entry] of Object.entries(raw.clients)) { + if (!isRouteClientId(id) || !entry || typeof entry !== "object" || Array.isArray(entry) + || Object.keys(entry).sort().join(",") !== "enabled,revision" || !("enabled" in entry) || typeof entry.enabled !== "boolean" + || !("revision" in entry) || !Number.isSafeInteger(entry.revision) || Number(entry.revision) < 0) throw new Error("Invalid initial route selection client"); + clients[id] = { enabled: entry.enabled, revision: Number(entry.revision) }; + } + return clients; +} +function writeInitial(linkId: string, clients: InitialSelection, root: string): void { + assertNotRealHomeUnderTest(root); mkdirSync(root, { recursive: true, mode: 0o700 }); + atomicWriteFileNoFollow(recordPath(linkId, root), JSON.stringify({ version: 1, linkId, clients })); +} +export function recordInitialRouteIntent(linkId: string, document: HomeRouteIntentDocument | undefined, selected: readonly RouteClientId[], root = routeIntentStatePath()): void { + const clients: InitialSelection = {}; + for (const id of ROUTE_CLIENT_IDS) if (id === "codex" || id === "claude" || selected.includes(id)) { + clients[id] = { enabled: selected.includes(id), revision: document?.clientRevisions?.[id] ?? 0 }; + } + writeInitial(linkId, clients, root); +} +export function resolveInitialRouteIntent(linkId: string, document: HomeRouteIntentDocument, beforeWrite: () => void = () => {}, root = routeIntentStatePath()): HomeRouteIntentDocument { + const clients = readInitialRouteIntent(linkId, root); + const next = structuredClone(document); + let removed = false; + for (const id of ROUTE_CLIENT_IDS) { + const initial = clients[id]; if (!initial) continue; + if ((document.clientRevisions?.[id] ?? 0) !== initial.revision) { delete clients[id]; removed = true; continue; } + next.clients[id] = { ...next.clients[id], enabled: initial.enabled }; + next.sources[id] = "device"; + } + if (removed) { beforeWrite(); writeInitial(linkId, clients, root); } + return next; +} +export function initialRouteOutcomeMetadata(linkId: string, document: HomeRouteIntentDocument, root = routeIntentStatePath()) { + const initial = readInitialRouteIntent(linkId, root); + return (row: RouteClientOutcome): RouteClientOutcome => { + const entry = initial[row.client]; + return entry && entry.revision === (document.clientRevisions?.[row.client] ?? 0) + ? { ...row, initialIntent: { enabled: entry.enabled }, initialRevision: entry.revision } : row; + }; +} +export function clearInitialRouteIntent(linkId: string, root = routeIntentStatePath()): void { writeInitial(linkId, {}, root); } diff --git a/src/integrations/route-intent-state.ts b/src/integrations/route-intent-state.ts new file mode 100644 index 00000000000..83cae967ba6 --- /dev/null +++ b/src/integrations/route-intent-state.ts @@ -0,0 +1,86 @@ +import { mkdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { ROUTE_CLIENT_IDS, isRouteClientId, parseHomeRouteIntentDocument, parseRouteClientIntent, type HomeRouteIntentDocument, type RouteClientId } from "../link/route-intent"; +import type { RouteIntentReceipt } from "./route-intent-contract"; + +export function routeIntentStatePath(): string { return join(getConfigDir(), "route-intent"); } + +function readJson(path: string, maxBytes = 16 * 1024 * 1024): unknown { + try { return JSON.parse(readBoundedRegularFileSync(path, maxBytes).toString("utf8")); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new Error("Local route intent state is unreadable; preserve it for recovery"); + } +} + +export function readLocalRouteIntentOff(statePath = routeIntentStatePath()): RouteClientId[] { + const raw = readJson(join(statePath, "local-off.json"), 64 * 1024); + if (raw === undefined) return []; + if (!raw || typeof raw !== "object" || !("version" in raw) || raw.version !== 1 || !("clients" in raw) + || !Array.isArray(raw.clients) || raw.clients.some(id => !ROUTE_CLIENT_IDS.includes(id)) + || new Set(raw.clients).size !== raw.clients.length) throw new Error("Invalid local route intent opt-outs"); + return raw.clients; +} + +export function effectiveRouteIntent(document: HomeRouteIntentDocument, statePath = routeIntentStatePath(), localOff?: readonly RouteClientId[]) { + const clients = structuredClone(document.clients); + const sources: RouteIntentReceipt["sources"] = { ...document.sources }; + for (const id of localOff ?? readLocalRouteIntentOff(statePath)) { + clients[id] = { ...clients[id], enabled: false }; + sources[id] = "local"; + } + return { clients, sources, selectedClients: ROUTE_CLIENT_IDS.filter(id => clients[id].enabled) }; +} + +export function writeRouteIntentReceipt(receipt: RouteIntentReceipt, statePath = routeIntentStatePath()): void { + assertNotRealHomeUnderTest(statePath); + mkdirSync(statePath, { recursive: true, mode: 0o700 }); + const body = JSON.stringify(receipt); + if (Buffer.byteLength(body) > 16 * 1024 * 1024) throw new Error("Local route intent receipt is too large"); + atomicWriteFileNoFollow(join(statePath, "receipt.json"), body); +} + +export function readRouteIntentReceipt(statePath = routeIntentStatePath()): RouteIntentReceipt | null { + try { return readRouteIntentReceiptStrict(statePath); } catch { return null; } +} + +/** Cleanup must distinguish absence from corrupt, unsafe, or incomplete ownership evidence. */ +export function readRouteIntentReceiptStrict(statePath = routeIntentStatePath()): RouteIntentReceipt | null { + const raw = readJson(join(statePath, "receipt.json")) as RouteIntentReceipt | undefined; + if (raw === undefined) return null; + if (!raw || raw.version !== 1 || !parseHomeRouteIntentDocument(raw.document, { allowNative: true }) || !Array.isArray(raw.outcomes) + || raw.outcomes.length !== ROUTE_CLIENT_IDS.length || new Set(raw.outcomes.map(row => row?.client)).size !== ROUTE_CLIENT_IDS.length + || !raw.clients || !raw.sources || !Array.isArray(raw.selectedClients) || new Set(raw.selectedClients).size !== raw.selectedClients.length + || raw.selectedClients.some(id => !isRouteClientId(id)) || typeof raw.appliedAt !== "string" || !Number.isFinite(Date.parse(raw.appliedAt)) + || (raw.linkId !== undefined && !/^lnk_[0-9a-f]{16}$/.test(raw.linkId)) + || (raw.models !== undefined && (!Array.isArray(raw.models) || raw.models.some(row => !row || typeof row.namespaced !== "string" || typeof row.provider !== "string" || typeof row.id !== "string"))) + || !(raw.appliedRouteIntentDigest === null || (typeof raw.appliedRouteIntentDigest === "string" && /^[0-9a-f]{64}$/.test(raw.appliedRouteIntentDigest))) + || raw.outcomes.some(row => !row || !isRouteClientId(row.client) || !["current", "stale", "not_installed", "conflict", "failed"].includes(row.outcome)) + || ROUTE_CLIENT_IDS.some(id => !parseRouteClientIntent(raw.clients[id], id) || !["builtin", "default", "device", "native", "local"].includes(raw.sources[id]) + || raw.selectedClients.includes(id) !== raw.clients[id].enabled)) throw new Error("Route intent receipt is malformed; preserve it and repair before disconnect"); + return raw; +} + +export function clearRouteIntentReceipt(statePath = routeIntentStatePath()): void { + assertNotRealHomeUnderTest(statePath); + try { unlinkSync(join(statePath, "receipt.json")); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } +} + +/** true opts out; false drops the override and inherits the Home again. Never enables locally. */ +export function setLocalRouteIntentOff(client: RouteClientId, off: boolean, statePath = routeIntentStatePath()): void { + if (!ROUTE_CLIENT_IDS.includes(client) || typeof off !== "boolean") throw new Error("Invalid local route intent override"); + const clients = new Set(readLocalRouteIntentOff(statePath)); + if (off) clients.add(client); else clients.delete(client); + assertNotRealHomeUnderTest(statePath); + mkdirSync(statePath, { recursive: true, mode: 0o700 }); + // Invalidate before publishing the override: failure cannot leave an old success claim. + const receipt = readRouteIntentReceiptStrict(statePath); + if (receipt) writeRouteIntentReceipt({ ...receipt, appliedRouteIntentDigest: null, + outcomes: receipt.outcomes.map(row => row.client === client ? { ...row, outcome: "stale" } : row) }, statePath); + atomicWriteFileNoFollow(join(statePath, "local-off.json"), JSON.stringify({ version: 1, clients: [...clients].sort() })); +} diff --git a/src/integrations/route-intent-tick.ts b/src/integrations/route-intent-tick.ts new file mode 100644 index 00000000000..3469351c00f --- /dev/null +++ b/src/integrations/route-intent-tick.ts @@ -0,0 +1,65 @@ +import type { OcxClientConnectionConfig } from "../types"; + +export interface RouteIntentTickDeps { + sync?: () => Promise; + onApplied?: () => void | Promise; + setTimer?: (callback: () => void, ms: number) => unknown; + clearTimer?: (timer: unknown) => void; +} +export interface RouteIntentTick { stop(): void; requestSync(): Promise } + +/** Periodic and Home-requested applies share one queue and the existing sync ownership fences. */ +export function startRouteIntentTick(connection: OcxClientConnectionConfig, deps: RouteIntentTickDeps = {}): RouteIntentTick { + if (connection.transport !== "link" || !connection.link) return { stop() {}, requestSync: async () => { throw new Error("not_a_link_child"); } }; + const linkId = connection.link.linkId; + const sync = deps.sync ?? (async () => { + const { syncConnectedClient } = await import("../client/connect"); + return syncConnectedClient({ expect: { kind: "link", linkId } }); + }); + const set = deps.setTimer ?? ((callback, ms) => { const timer = setTimeout(callback, ms); timer.unref(); return timer; }); + const clear = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType)); + let stopped = false, running = false, queued = false; + let timer: unknown; + const waiters: Array<{ resolve: (result: unknown) => void; reject: (error: unknown) => void }> = []; + const schedule = (ms: number) => { + if (!stopped) timer = set(() => { timer = undefined; queued = true; void drain(); }, ms); + }; + const drain = async () => { + if (stopped || running) return; + running = true; + let delay = 60_000; + try { + do { + queued = false; + const batch = waiters.splice(0); + try { + const result = await sync(); + await deps.onApplied?.(); + for (const waiter of batch) waiter.resolve(result); + delay = 60_000; + } catch (error) { + for (const waiter of batch) waiter.reject(error); + delay = 5_000; + } + } while (queued && !stopped); + } finally { running = false; schedule(delay); } + }; + schedule(2_000); + return { + requestSync() { + if (stopped) return Promise.reject(new Error("client_runtime_stopping")); + if (timer !== undefined) clear(timer); + timer = undefined; + queued = true; + const result = new Promise((resolve, reject) => waiters.push({ resolve, reject })); + void drain(); + return result; + }, + stop() { + stopped = true; queued = false; + if (timer !== undefined) clear(timer); + timer = undefined; + for (const waiter of waiters.splice(0)) waiter.reject(new Error("client_runtime_stopping")); + }, + }; +} diff --git a/src/integrations/route-intent.ts b/src/integrations/route-intent.ts new file mode 100644 index 00000000000..5f61eecc898 --- /dev/null +++ b/src/integrations/route-intent.ts @@ -0,0 +1,209 @@ +/** Device-local intent controller. All client mutations go through the existing writers. */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { routeClientIntentDigest, routeIntentDigest, ROUTE_CLIENT_IDS, type RouteClientId, type RouteClientIntent } from "../link/route-intent"; +import { isIntegrationClientId, type IntegrationClientId } from "./registry"; +import { readIntegrationState } from "./state"; +import { createIntegrationStateStore } from "./store"; +import { applyIntegrationCoordinated, disableIntegrationCoordinated, overwriteIntegrationCoordinated, refreshIntegrationCoordinated, + type IntegrationWriteInput, type WriteOutcome } from "./writer"; +import { createAsideProfileContext, asideProfileScope, asideWriteInput, asideRootStore, listAsideProfileStores } from "./aside-profile-context"; +import { asideHomeDir } from "../clients/config-export"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; +import type { RouteClientOutcome, RouteIntentApplyInput, RouteIntentReceipt } from "./route-intent-contract"; +import { effectiveRouteIntent, readRouteIntentReceipt, routeIntentStatePath, writeRouteIntentReceipt } from "./route-intent-state"; +export { clearRouteIntentReceipt, effectiveRouteIntent, readLocalRouteIntentOff, readRouteIntentReceipt, readRouteIntentReceiptStrict, routeIntentStatePath, setLocalRouteIntentOff } from "./route-intent-state"; +export type { RouteClientOutcome, RouteIntentApplyInput, RouteIntentReceipt } from "./route-intent-contract"; + +const flights = new Set(); + +function project(client: RouteClientId, result: WriteOutcome, target: string): RouteClientOutcome { + return { client, target, outcome: result.ok ? "current" : result.reason === "not_installed" ? "not_installed" + : result.state === "conflict" ? "conflict" : "failed", message: result.message }; +} + +async function writeClient(input: RouteIntentApplyInput, bound: IntegrationWriteInput, intent: RouteClientIntent): Promise { + const state = readIntegrationState(bound); + // An off intent never adopts an unowned block, including foreign blocks. + if (!intent.enabled && !bound.store?.readRecords()[bound.clientId]) { + return { client: bound.clientId, outcome: "current", target: state.configPath }; + } + if (!intent.enabled && input.recoverConflicts && state.state === "conflict") { + // A durable record is mandatory above; this cannot adopt an unowned foreign block. + // Overwrite snapshots the edited preimage through the existing private journal. Unsafe + // paths, blocked containers and unrepresentable contributions retain their ordinary refusal. + const recovered = await overwriteIntegrationCoordinated(bound, { guard: () => { input.beforeWrite?.(); return null; } }); + if (!recovered.ok) return project(bound.clientId, recovered, state.configPath); + } + const operation = !intent.enabled ? disableIntegrationCoordinated + : input.overwriteConflict ? overwriteIntegrationCoordinated + : state.state === "absent" ? applyIntegrationCoordinated : refreshIntegrationCoordinated; + const result = await operation(bound, { guard: () => { input.beforeWrite?.(); return null; } }); + // A refresh of a deleted owned block returns absent. Intent is still on, so that is stale. + const outcome = project(bound.clientId, result, state.configPath); + if (intent.enabled && result.ok && result.state === "absent") outcome.outcome = "stale"; + return outcome; +} + +function manifestText(root: string): string { + return readBoundedRegularFileSync(join(root, "accounts.json"), 4 * 1024 * 1024).toString("utf8"); +} + +async function applyAside(input: RouteIntentApplyInput, intent: RouteClientIntent): Promise { + const root = asideHomeDir(input.env ?? process.env, input.home ?? homedir()); + const ownershipInput = { ...input, port: input.config.port }; + const rootStore = asideRootStore(ownershipInput); + const legacy = rootStore.readRecordsStrict().aside; + const owners = listAsideProfileStores(rootStore).flatMap(child => { + const records = child.store.readRecordsStrict(); + if (Object.keys(records).some(id => id !== "aside")) throw new Error("Aside profile storage names another client"); + return records.aside ? [{ profileId: child.profileId, record: records.aside }] : []; + }); + const owned = Boolean(legacy) || owners.length > 0; + // No owned route means off is already proved, even without a usable account manifest. + if (!intent.enabled && !owned) return { client: "aside", outcome: "current" }; + if (!existsSync(root)) { + if (owned) throw new Error("Aside installation is missing with retained route ownership"); + return { client: "aside", outcome: "not_installed" }; + } + const manifest = manifestText(root); + const ctx = createAsideProfileContext(ownershipInput); + // A removed manifest entry is not evidence that its owned file was cleaned up. + if ((legacy && !ctx.profiles.some(profile => profile.configPath === legacy.configPath)) + || owners.some(owner => !ctx.profiles.some(profile => profile.id === owner.profileId && profile.configPath === owner.record.configPath))) { + throw new Error("Aside route ownership names a profile outside the account manifest"); + } + if (manifestText(root) !== manifest) throw new Error("Aside account manifest changed during enumeration"); + const raw: unknown = JSON.parse(manifest); + // Enumeration intentionally hides userIds. Prove that its entire profile roster was + // derived from the manifest snapshot whose userIds this operation selects. + const snapshot = raw as { currentAccountId: number; accounts?: Array<{ id: number; name?: string }> }; + const roster = snapshot.accounts ?? [{ id: snapshot.currentAccountId }]; + if (roster.length !== ctx.profiles.length || ctx.profiles.some((profile, index) => { + const account = roster[index]; + return !account || account.id !== profile.id || (account.id === snapshot.currentAccountId) !== profile.current + || (typeof account.name === "string" ? account.name : undefined) !== profile.name; + })) throw new Error("Aside profile enumeration does not match the account identity snapshot"); + const ids = new Map(); + if (raw && typeof raw === "object" && "accounts" in raw && Array.isArray(raw.accounts)) { + for (const account of raw.accounts) { + if (account && typeof account === "object" && Number.isSafeInteger(account.id) && typeof account.userId === "string") { + ids.set(account.id, account.userId); + } + } + } + const accounts = intent.accounts ?? "all"; + const selected = ctx.profiles.filter(profile => accounts === "all" || (Array.isArray(accounts) && accounts.includes(ids.get(profile.id) ?? ""))); + const results: RouteClientOutcome[] = []; + for (const profile of ctx.profiles) { + const scope = asideProfileScope(ctx, profile); + const enabled = intent.enabled && selected.includes(profile) && accounts !== "none"; + if (!enabled && !scope.store.readRecords().aside) continue; + const bound = await asideWriteInput(ctx, scope); + results.push(await writeClient({ ...input, beforeWrite: () => { + input.beforeWrite?.(); scope.assertBoundary(); + if (manifestText(root) !== manifest) throw new Error("Aside account identity changed during route apply"); + } }, bound, { ...intent, enabled })); + } + const failure = results.find(row => row.outcome === "failed") ?? results.find(row => row.outcome === "conflict") + ?? results.find(row => row.outcome !== "current"); + if (failure) return { ...failure, client: "aside" }; + if (intent.enabled && accounts !== "none" && selected.length === 0) { + return { client: "aside", outcome: "not_installed", message: "No local Aside profile matches the requested userIds" }; + } + return { client: "aside", outcome: "current", target: selected.map(profile => `u${profile.id}`).join(",") }; +} + +async function applyClient(input: RouteIntentApplyInput, client: RouteClientId, intent: RouteClientIntent): Promise { + if (client === "aside") return input.withFileWrites ? input.withFileWrites(() => applyAside(input, intent)) : applyAside(input, intent); + if (isIntegrationClientId(client)) { + const write = () => writeClient(input, { clientId: client, config: input.config, port: input.config.port, models: input.models, + env: input.env, home: input.home, store: input.store, modelNames: intent.modelNames }, intent); + return input.withFileWrites ? input.withFileWrites(write) : write(); + } + if (input.applyNative) { input.beforeWrite?.(); return input.applyNative(client, intent); } + const previous = readRouteIntentReceipt(input.statePath); + return !intent.enabled && !previous?.clients[client]?.enabled + ? { client, outcome: "current" } + : { client, outcome: "failed", message: "This native client needs its existing local ownership adapter" }; +} + +/** Home and Child share the same local materializer; absent intent never calls this function. */ +export async function materializeRouteIntent(input: RouteIntentApplyInput): Promise { + const path = input.statePath ?? routeIntentStatePath(); + if (flights.has(path)) throw new Error("Local route intent apply is already running"); + flights.add(path); + try { + input = { ...input, document: structuredClone(input.document), config: structuredClone(input.config), models: structuredClone([...input.models]), + store: input.store ?? createIntegrationStateStore() }; + const effective = effectiveRouteIntent(input.document, path, input.localOff); + const previous = input.retainSkippedOutcomes ? readRouteIntentReceipt(path) : null; + const receipt: RouteIntentReceipt = { version: 1, models: input.models, ...(input.linkId ? { linkId: input.linkId } : {}), document: structuredClone(input.document), ...effective, + outcomes: ROUTE_CLIENT_IDS.map(client => ({ client, outcome: "stale" })), + appliedRouteIntentDigest: null, appliedAt: (input.now ?? (() => new Date()))().toISOString() }; + // Withdraw the previous digest before any client can change, including a failed/aborted write. + const saveReceipt = () => input.withReceiptWrite + ? input.withReceiptWrite(() => writeRouteIntentReceipt(receipt, path)) : writeRouteIntentReceipt(receipt, path); + saveReceipt(); + for (const client of ROUTE_CLIENT_IDS) { + if (input.onlyClients && !input.onlyClients.includes(client)) { + const kept = previous?.linkId === input.linkId + ? previous?.outcomes.find(row => row.client === client && row.intentDigest === routeClientIntentDigest(input.document, client)) : undefined; + if (kept) receipt.outcomes[ROUTE_CLIENT_IDS.indexOf(client)] = kept; + continue; + } + input.beforeWrite?.(); + try { + receipt.outcomes[ROUTE_CLIENT_IDS.indexOf(client)] = { ...await applyClient({ ...input, overwriteConflict: input.overwriteConflict === true && (input.overwriteClient === undefined || input.overwriteClient === client) }, client, effective.clients[client]), + intentDigest: routeClientIntentDigest(input.document, client), + ...(effective.sources[client] === "local" ? { source: "local" as const } : {}) }; + } catch { + input.beforeWrite?.(); // Ownership loss propagates instead of looking like a disk failure. + receipt.outcomes[ROUTE_CLIENT_IDS.indexOf(client)] = { client, outcome: "failed", intentDigest: routeClientIntentDigest(input.document, client), + ...(effective.sources[client] === "local" ? { source: "local" as const } : {}), message: "Local client could not be reconciled safely" }; + } + } + input.beforeWrite?.(); + // A local opt-out intentionally differs from the Home document; do not claim its digest. + const locallyChanged = ROUTE_CLIENT_IDS.some(id => effective.sources[id] === "local" && input.document.clients[id].enabled); + if (!locallyChanged && receipt.outcomes.every(row => row.outcome === "current")) receipt.appliedRouteIntentDigest = routeIntentDigest(input.document); + saveReceipt(); + return receipt; + } finally { flights.delete(path); } +} + +/** + * Put a standalone integration back: one still owned, or one the person changed (their own block), + * is kept as is; one the link disabled is applied again. An uninstalled client has nothing to do. + */ +async function restoreStandaloneClient(input: RouteIntentApplyInput, client: IntegrationClientId, modelNames?: "alias" | "routed"): Promise { + const bound: IntegrationWriteInput = { clientId: client, config: input.config, port: input.config.port, models: input.models, + env: input.env, home: input.home, store: input.store, modelNames }; + const state = readIntegrationState(bound); + if (input.store?.readRecords()[client] || state.state !== "absent") return { client, outcome: "current", target: state.configPath }; + const result = await applyIntegrationCoordinated(bound, { guard: () => { input.beforeWrite?.(); return null; } }); + return result.ok || result.reason === "not_installed" ? { client, outcome: "current", target: state.configPath } : project(client, result, state.configPath); +} + +/** + * Disconnect calls this while it holds its lifecycle fence, before clearing intent receipts. + * `standalone` names file clients the Child owned before linking; they are restored, not removed. + */ +export async function cleanupRouteIntent(input: RouteIntentApplyInput, + standalone: Partial> = {}): Promise { + const receipt = readRouteIntentReceipt(input.statePath); + if (!receipt) return []; + const document = structuredClone(receipt.document); + for (const id of ROUTE_CLIENT_IDS) document.clients[id].enabled = false; + const kept = ROUTE_CLIENT_IDS.filter((id): id is IntegrationClientId => isIntegrationClientId(id) && id !== "aside" && Object.hasOwn(standalone, id)); + const outcomes = (await materializeRouteIntent({ ...input, document, overwriteConflict: false, + onlyClients: ROUTE_CLIENT_IDS.filter(id => isIntegrationClientId(id) && !(kept as readonly string[]).includes(id)) })) + .outcomes.filter(row => isIntegrationClientId(row.client) && !(kept as readonly string[]).includes(row.client)); + const store = input.store ?? createIntegrationStateStore(); + for (const client of kept) { + const restore = () => restoreStandaloneClient({ ...input, store }, client, standalone[client]?.modelNames); + outcomes.push(input.withFileWrites ? await input.withFileWrites(restore) : await restore()); + } + return outcomes; +} diff --git a/src/link/child-parity.ts b/src/link/child-parity.ts index 220ffbc7f43..bd7cc4d6b53 100644 --- a/src/link/child-parity.ts +++ b/src/link/child-parity.ts @@ -14,11 +14,12 @@ */ import { CODEX_SETTINGS_DIGEST_PATTERN } from "./codex-settings"; import { LINK_MANAGEMENT_PREFIX } from "./management-routes"; +import { parseRouteClientOutcomes, ROUTE_INTENT_DIGEST_PATTERN, routeClientOutcomesForPeer, type RouteClientOutcome } from "./route-intent"; /** The one write a Child may send its Home over the link. */ export const LINK_CHILD_PARITY_PATH = `${LINK_MANAGEMENT_PREFIX}/child-parity`; /** A report is four short fields; anything larger is refused while it is still being read. */ -export const LINK_CHILD_PARITY_MAX_BYTES = 1024; +export const LINK_CHILD_PARITY_MAX_BYTES = 8192; const LINK_ID = /^lnk_[0-9a-f]{16}$/; /** 2001-09-09 .. 2286-11-20: any epoch-millisecond time a real clock reports. */ @@ -33,6 +34,8 @@ export interface LinkChildParityReport { runtimeStartedAt: number; /** `appliedCodexSettingsDigest` of the applied settings; null before any apply. */ appliedCodexSettingsDigest: string | null; + appliedRouteIntentDigest?: string | null; + routeClientOutcomes?: RouteClientOutcome[]; /** * Increases with every report this runtime process sends. With `runtimeStartedAt` it orders * reports, so a late POST from an older runtime or an earlier send never replaces a newer one. @@ -45,6 +48,8 @@ export interface LinkChildParity { lastApplyAt: number | null; runtimeStartedAt: number | null; appliedCodexSettingsDigest: string | null; + appliedRouteIntentDigest?: string | null; + routeClientOutcomes?: RouteClientOutcome[]; /** Epoch ms the Home received the report (the Home's clock; the other times are the Child's). */ reportedAt: number | null; } @@ -65,7 +70,9 @@ function exactKeys(value: unknown, keys: readonly string[]): value is Record = {}; + if (Object.hasOwn(value, "appliedRouteIntentDigest")) { + const routeDigest = value.appliedRouteIntentDigest; + if (routeDigest !== null && (typeof routeDigest !== "string" || !ROUTE_INTENT_DIGEST_PATTERN.test(routeDigest))) return null; + const outcomes = parseRouteClientOutcomes(value.routeClientOutcomes); + if (!outcomes) return null; + route = { appliedRouteIntentDigest: routeDigest, routeClientOutcomes: routeClientOutcomesForPeer(outcomes) }; + } + return { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence, ...route }; } export interface LinkChildParityStore { @@ -100,6 +115,10 @@ export function createLinkChildParityStore(now: () => number = Date.now): LinkCh lastApplyAt: report.lastApplyAt, runtimeStartedAt: report.runtimeStartedAt, appliedCodexSettingsDigest: report.appliedCodexSettingsDigest, + ...(report.routeClientOutcomes === undefined ? {} : { + appliedRouteIntentDigest: report.appliedRouteIntentDigest ?? null, + routeClientOutcomes: routeClientOutcomesForPeer(report.routeClientOutcomes), + }), reportedAt: now(), sequence: report.sequence, }); @@ -109,7 +128,7 @@ export function createLinkChildParityStore(now: () => number = Date.now): LinkCh const kept = reports.get(linkId); if (!kept) return { ...EMPTY_LINK_CHILD_PARITY }; const { sequence: _sequence, ...parity } = kept; - return parity; + return { ...parity, ...(parity.routeClientOutcomes === undefined ? {} : { routeClientOutcomes: routeClientOutcomesForPeer(parity.routeClientOutcomes) }) }; }, clear() { reports.clear(); }, }; diff --git a/src/link/management-routes.ts b/src/link/management-routes.ts index 98500320fca..c2b859f0a8c 100644 --- a/src/link/management-routes.ts +++ b/src/link/management-routes.ts @@ -16,6 +16,8 @@ export const LINK_MANAGEMENT_PREFIX = "/v1/link-management"; /** The Home's OpenCodex-owned Codex settings a Child applies when it syncs. */ export const LINK_CODEX_SETTINGS_PATH = `${LINK_MANAGEMENT_PREFIX}/codex-settings`; +export const LINK_ROUTE_INTENT_PATH = `${LINK_MANAGEMENT_PREFIX}/route-intent`; +export const LINK_ROUTE_CATALOG_PATH = `${LINK_MANAGEMENT_PREFIX}/route-catalog`; export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [ "/api/system/health", @@ -71,7 +73,7 @@ export function linkManagementTarget(url: URL): URL | null { /** Home side: may the hub-link listener dispatch this request to the link management handler? */ export function linkManagementRouteAllowed(url: URL, req: Request): boolean { if (req.headers.has("upgrade")) return false; - if (url.pathname === LINK_CODEX_SETTINGS_PATH) return req.method === "GET" && url.search === ""; + if (url.pathname === LINK_CODEX_SETTINGS_PATH || url.pathname === LINK_ROUTE_INTENT_PATH || url.pathname === LINK_ROUTE_CATALOG_PATH) return req.method === "GET" && url.search === ""; const target = linkManagementTarget(url); return target !== null && linkManagementReadAllowed(req.method, target.pathname, target.searchParams); } diff --git a/src/link/remote-windows.ts b/src/link/remote-windows.ts index ff6d84e6ed8..b50281cb55c 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -1,3 +1,4 @@ +import type { RouteClientId } from "./route-intent"; import { Buffer } from "node:buffer"; import { deflateRawSync, inflateRawSync } from "node:zlib"; @@ -210,16 +211,27 @@ Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{ `); } -export function windowsPinnedSyncArgv(linkId: string): string[] { - return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: ["sync", "--expect-link", linkId, "--json"] }) + OCX_CANDIDATES + String.raw` +/** + * The first OpenCodex release whose `ocx sync` understands --overwrite-conflict and + * --overwrite-client; an earlier one ignores both and reports a plain sync as success. The + * lightweight ocx-child supports neither and never reads route intent. + */ +export const ROUTE_OVERWRITE_MIN_CHILD_VERSION = "2.100.0"; + +export function windowsPinnedSyncArgv(linkId: string, overwriteConflict = false, overwriteClient?: RouteClientId): string[] { + // Overwrite needs a Child that honours it: refuse ocx-child and older ocx before syncing. + const minimum = overwriteConflict ? ROUTE_OVERWRITE_MIN_CHILD_VERSION.split(".").map(part => `'${part}'`).join(",") : "'2','93','0'"; + const lacks = overwriteConflict ? "ocx-fanout:child-ocx-lacks-overwrite" : "ocx-fanout:child-ocx-lacks-expect-link"; + const ephemeral = overwriteConflict ? String.raw`if(([IO.Path]::GetFileName($x)).ToLowerInvariant().StartsWith('ocx-child')){[Console]::Error.WriteLine('ocx-fanout:child-ephemeral-no-overwrite');exit 121};` : ""; + return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: ["sync", "--expect-link", linkId, "--json", ...(overwriteConflict ? ["--overwrite-conflict"] : []), ...(overwriteClient ? ["--overwrite-client", overwriteClient] : [])] }) + OCX_CANDIDATES + String.raw` foreach($x in ($c|select -Unique)){ - if(![IO.File]::Exists($x)){continue};$v=[OcxJob]::V($x);if($null -eq $v){continue};$v=$v.Trim();if($v.StartsWith('opencodex ')){$v=$v.Substring(10)} + if(![IO.File]::Exists($x)){continue};` + ephemeral + String.raw`$v=[OcxJob]::V($x);if($null -eq $v){continue};$v=$v.Trim();if($v.StartsWith('opencodex ')){$v=$v.Substring(10)} $m=[regex]::Match($v,'^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$') $bad=(!$m.Success -or $v.Length -gt 128);if(!$bad -and $m.Groups[4].Success){foreach($p in $m.Groups[4].Value.Split('.')){if(!$p -or ($p -match '^\d+$' -and $p.Length -gt 1 -and $p[0] -eq '0')){$bad=$true}}} if($bad){[Console]::Error.WriteLine('ocx-fanout:child-ocx-version-unknown');exit 121} - $a=@($m.Groups[1].Value,$m.Groups[2].Value,$m.Groups[3].Value);$b=@('2','93','0');$cmp=0 + $a=@($m.Groups[1].Value,$m.Groups[2].Value,$m.Groups[3].Value);$b=@(` + minimum + String.raw`);$cmp=0 for($i=0;$i -lt 3;$i++){if($a[$i].Length -ne $b[$i].Length){$cmp=$a[$i].Length-$b[$i].Length;break};$cmp=[string]::CompareOrdinal($a[$i],$b[$i]);if($cmp -ne 0){break}} - if($cmp -lt 0 -or ($cmp -eq 0 -and $m.Groups[4].Success)){[Console]::Error.WriteLine('ocx-fanout:child-ocx-lacks-expect-link');exit 121} + if($cmp -lt 0 -or ($cmp -eq 0 -and $m.Groups[4].Success)){[Console]::Error.WriteLine('` + lacks + String.raw`');exit 121} [Console]::Error.WriteLine('ocx-fanout:selected:'+$v);[Console]::Out.WriteLine('ocx-fanout:stdout-begin');Invoke-Native $x @($d.args) };exit 127 `); diff --git a/src/link/route-intent-watch.ts b/src/link/route-intent-watch.ts new file mode 100644 index 00000000000..1f740c5cca6 --- /dev/null +++ b/src/link/route-intent-watch.ts @@ -0,0 +1,41 @@ +/** Ephemeral Home wakeups. The admitted link id, never a caller selector, owns each slot. */ +export const ROUTE_INTENT_WATCH_HEADER = "x-ocx-route-intent-watch"; +export const ROUTE_INTENT_AFTER_HEADER = "x-ocx-route-intent-after"; +export const ROUTE_INTENT_WAKE_HEADER = "x-ocx-route-intent-wake"; +export const ROUTE_INTENT_WATCH_TIMEOUT_MS = 30_000; +export const ROUTE_INTENT_WATCH_MAX_LINKS = 256; +export type RouteIntentWake = "notified" | "timeout" | "cancelled" | "busy"; + +export class RouteIntentWatch { + private readonly waiters = new Map void>(); + constructor(private readonly timeoutMs = ROUTE_INTENT_WATCH_TIMEOUT_MS, private readonly maxLinks = ROUTE_INTENT_WATCH_MAX_LINKS) { + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > ROUTE_INTENT_WATCH_TIMEOUT_MS + || !Number.isInteger(maxLinks) || maxLinks < 1 || maxLinks > ROUTE_INTENT_WATCH_MAX_LINKS) throw new RangeError("invalid route intent watch bounds"); + } + get size(): number { return this.waiters.size; } + wait(linkId: string, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve("cancelled"); + if (this.waiters.has(linkId) || this.waiters.size >= this.maxLinks) return Promise.resolve("busy"); + return new Promise(resolve => { + const finish = (reason: RouteIntentWake) => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + this.waiters.delete(linkId); + resolve(reason); + }; + const abort = () => finish("cancelled"); + const timer = setTimeout(() => finish("timeout"), this.timeoutMs); + timer.unref(); + this.waiters.set(linkId, finish); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + } + /** False means no active watcher accepted the request; it is never an apply acknowledgment. */ + notify(linkId: string): boolean { + const wake = this.waiters.get(linkId); + wake?.("notified"); + return wake !== undefined; + } +} +export const homeRouteIntentWatch = new RouteIntentWatch(); diff --git a/src/link/route-intent.ts b/src/link/route-intent.ts new file mode 100644 index 00000000000..de1d578aedb --- /dev/null +++ b/src/link/route-intent.ts @@ -0,0 +1,209 @@ +/** Home policy only. No filesystem paths, listener facts, credentials or profile slots. */ +import { createHash } from "node:crypto"; +import { hostname } from "node:os"; +import { EXPORT_CLIENT_IDS } from "../clients/client-ids"; +import type { OcxConfig } from "../types"; + +export const ROUTE_CLIENT_IDS = ["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS] as const; +export type RouteClientId = (typeof ROUTE_CLIENT_IDS)[number]; +export interface RouteClientIntent { + enabled: boolean; + modelNames?: "alias" | "routed"; + /** Stable Aside user ids, never machine-local profile slots. */ + accounts?: "all" | "none" | string[]; +} +export interface HomeRouteIntentDocument { + version: 1; + home: { name: string; version: string }; + clients: Record; + /** + * `native` appears only in the Home's own document: the Home's persisted native switch governs + * that client there. Documents sent to a Child never carry it. + */ + sources: Record; + /** Device decisions (including inherit) invalidate matching enrollment overrides. */ + clientRevisions?: Partial>; +} +export interface RouteIntentState { + version: 1; + defaults: Partial>; + devices: Record>>; + revisions?: Record>>; +} +export interface RouteClientOutcome { + client: RouteClientId; + outcome: "current" | "stale" | "not_installed" | "conflict" | "failed"; + /** A Child's off-only opt-out; Home policy remains unchanged. */ + source?: "local"; + target?: string; + message?: string; + intentDigest?: string; + /** Display-only initial selection made during enrollment; never mutates Home policy. */ + initialIntent?: RouteClientIntent; + initialRevision?: number; +} +export const ROUTE_INTENT_DIGEST_PATTERN = /^[0-9a-f]{64}$/; +export function isRouteClientId(value: unknown): value is RouteClientId { + return typeof value === "string" && (ROUTE_CLIENT_IDS as readonly string[]).includes(value); +} +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function keys(value: unknown, required: readonly string[], optional: readonly string[] = []): value is Record { + return record(value) && required.every(k => Object.hasOwn(value, k)) + && Object.keys(value).every(k => required.includes(k) || optional.includes(k)); +} +function label(value: unknown, max: number): value is string { + return typeof value === "string" && value.length > 0 && value.length <= max && value === value.trim() + && !/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/u.test(value); +} +export function parseRouteClientIntent(value: unknown, client?: RouteClientId): RouteClientIntent | null { + if (!keys(value, ["enabled"], ["modelNames", "accounts"]) || typeof value.enabled !== "boolean") return null; + if (Object.hasOwn(value, "modelNames") && value.modelNames !== "alias" && value.modelNames !== "routed") return null; + const a = value.accounts; + if (Object.hasOwn(value, "accounts")) { + if (client !== undefined && client !== "aside") return null; + if (a !== "all" && a !== "none" && (!Array.isArray(a) || a.length > 128 || a.length === 0 + || !a.every(id => label(id, 128) && /^[A-Za-z0-9@._+-]+$/.test(id) && !/^u[0-9]+$/i.test(id)) + || new Set(a).size !== a.length)) return null; + } + return { enabled: value.enabled, ...(value.modelNames === undefined ? {} : { modelNames: value.modelNames as "alias" | "routed" }), + ...(a === undefined ? {} : { accounts: Array.isArray(a) ? [...a].sort() : a as "all" | "none" }) }; +} +export function emptyRouteIntentState(): RouteIntentState { + return { version: 1, defaults: {}, devices: {} }; +} + +export interface HomeRouteProjectionOptions { + /** + * The native client a Home write is deciding right now. The decision is applied through the + * Home's native switch; every other native client on the Home reads that switch as it is. + */ + decidedClient?: RouteClientId; + /** The decided Home value. Absent, the decided client takes the default Children inherit. */ + decidedIntent?: RouteClientIntent; +} +export const HOME_NATIVE_ROUTE_CLIENTS = ["codex", "claude", "grok", "claude-desktop"] as const; +export type HomeNativeRouteClientId = (typeof HOME_NATIVE_ROUTE_CLIENTS)[number]; +export function isHomeNativeRouteClient(client: RouteClientId): client is HomeNativeRouteClientId { + return (HOME_NATIVE_ROUTE_CLIENTS as readonly string[]).includes(client); +} + +/** + * The defaults Children inherit for the native clients, captured once from the Home's durable + * switches. Codex and Claude Code keep their switch meaning (only an explicit `false` is off). + * Grok and Claude Desktop are never captured on: a Home switch enables them on the Home alone. + */ +export function homeNativeRouteDefaults(config: OcxConfig): Record { + return { codex: { enabled: config.clientIntegrations?.codex !== false }, claude: { enabled: config.claudeCode?.enabled !== false }, + grok: { enabled: false }, "claude-desktop": { enabled: false } }; +} + +/** + * Pure projection. Persisted policy is supplied explicitly by the management owner. + * + * On the Home itself (`deviceId === "home"`) the native clients follow the Home's own persisted + * switches with their usual meaning (only an explicit `false` is off), never captured defaults or + * a `devices.home` entry: ocx restore, Messages off and native toggles change those switches + * without recording route intent. Children never read those switches: a device override, then the + * saved default, then the builtin (Codex and Claude Code on, everything else off). + */ +export function projectHomeRouteIntent( + config: OcxConfig, version: string, deviceId?: string, state: RouteIntentState = emptyRouteIntentState(), + options: HomeRouteProjectionOptions = {}, +): HomeRouteIntentDocument { + const clients = {} as HomeRouteIntentDocument["clients"]; + const sources = {} as HomeRouteIntentDocument["sources"]; + for (const client of ROUTE_CLIENT_IDS) { + const home = state.defaults[client]; + if (deviceId === "home" && isHomeNativeRouteClient(client)) { + const live = client === "claude" ? config.claudeCode?.enabled : config.clientIntegrations?.[client]; + const decided = options.decidedClient === client; + const enabled = decided ? options.decidedIntent?.enabled ?? home?.enabled ?? (client === "codex" || client === "claude") + : live !== false; + clients[client] = { enabled }; + sources[client] = "native"; + continue; + } + const device = deviceId === undefined ? undefined : state.devices[deviceId]?.[client]; + const chosen = { enabled: client === "codex" || client === "claude", ...home, ...device }; + clients[client] = { ...chosen, ...(Array.isArray(chosen.accounts) ? { accounts: [...chosen.accounts].sort() } : {}) }; + sources[client] = device ? "device" : home ? "default" : "builtin"; + } + const name = hostname(); + const revisions = deviceId === undefined ? undefined : state.revisions?.[deviceId]; + return { version: 1, home: { name: label(name, 255) ? name : "Home", version }, clients, sources, + ...(revisions && Object.keys(revisions).length ? { clientRevisions: { ...revisions } } : {}) }; +} +/** `allowNative` is for the Home's own local receipt; a document from a Home never carries it. */ +export function parseHomeRouteIntentDocument(value: unknown, options: { allowNative?: boolean } = {}): HomeRouteIntentDocument | null { + if (!keys(value, ["version", "home", "clients", "sources"], ["clientRevisions"]) || value.version !== 1 + || !keys(value.home, ["name", "version"]) || !label(value.home.name, 255) || !label(value.home.version, 64) + || !keys(value.clients, ROUTE_CLIENT_IDS) || !keys(value.sources, ROUTE_CLIENT_IDS)) return null; + const clients = {} as HomeRouteIntentDocument["clients"]; + const sources = {} as HomeRouteIntentDocument["sources"]; + for (const client of ROUTE_CLIENT_IDS) { + const intent = parseRouteClientIntent(value.clients[client], client); + const source = value.sources[client]; + if (!intent || (source !== "builtin" && source !== "default" && source !== "device" + && !(source === "native" && options.allowNative === true && isHomeNativeRouteClient(client)))) return null; + clients[client] = intent; + sources[client] = source; + } + const revisions: Partial> = {}; + if (value.clientRevisions !== undefined) { + if (!record(value.clientRevisions)) return null; + for (const [client, revision] of Object.entries(value.clientRevisions)) { + if (!isRouteClientId(client) || typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) return null; + revisions[client] = revision; + } + } + return { version: 1, home: { name: value.home.name, version: value.home.version }, clients, sources, + ...(value.clientRevisions === undefined ? {} : { clientRevisions: revisions }) }; +} +/** Compact JSON: version, ordered clients (enabled/modelNames/accounts), ordered sources. */ +export function routeIntentDigestPayload(document: HomeRouteIntentDocument): string { + const clients = Object.fromEntries(ROUTE_CLIENT_IDS.map(client => { + const intent = document.clients[client]; + return [client, { enabled: intent.enabled, ...(intent.modelNames === undefined ? {} : { modelNames: intent.modelNames }), + ...(intent.accounts === undefined ? {} : { accounts: Array.isArray(intent.accounts) ? [...intent.accounts].sort() : intent.accounts }) }]; + })); + return JSON.stringify({ version: 1, clients, sources: Object.fromEntries(ROUTE_CLIENT_IDS.map(client => [client, document.sources[client]])) }); +} +export function routeIntentDigest(document: HomeRouteIntentDocument): string { + return createHash("sha256").update(routeIntentDigestPayload(document), "utf8").digest("hex"); +} + +export function routeClientIntentDigest(document: HomeRouteIntentDocument, client: RouteClientId): string { + const intent = parseRouteClientIntent(document.clients[client], client)!; + return createHash("sha256").update(JSON.stringify({ version: 1, client, intent, source: document.sources[client] }), "utf8").digest("hex"); +} + +/** Peer outcomes carry closed fields; local diagnostics are never copied onto the wire. */ +export function parseRouteClientOutcomes(value: unknown): RouteClientOutcome[] | null { + if (!Array.isArray(value) || value.length > ROUTE_CLIENT_IDS.length) return null; + const seen = new Set(); + const out: RouteClientOutcome[] = []; + for (const row of value) { + if (!keys(row, ["client", "outcome"], ["source", "target", "message", "intentDigest", "initialIntent", "initialRevision"]) || !isRouteClientId(row.client) + || seen.has(row.client) || !["current", "stale", "not_installed", "conflict", "failed"].includes(row.outcome as string) + || (row.source !== undefined && row.source !== "local") + || (row.target !== undefined && !label(row.target, 256)) || (row.message !== undefined && !label(row.message, 256))) return null; + if (row.intentDigest !== undefined && (typeof row.intentDigest !== "string" || !ROUTE_INTENT_DIGEST_PATTERN.test(row.intentDigest))) return null; + const initialIntent = row.initialIntent === undefined ? undefined : parseRouteClientIntent(row.initialIntent, row.client); + if (row.initialIntent !== undefined && (!initialIntent || Object.keys(initialIntent).some(key => key !== "enabled") || typeof row.initialRevision !== "number" || !Number.isSafeInteger(row.initialRevision) || row.initialRevision < 0)) return null; + if (row.initialIntent === undefined && row.initialRevision !== undefined) return null; + seen.add(row.client); + out.push({ client: row.client, outcome: row.outcome as RouteClientOutcome["outcome"], ...(row.source === "local" ? { source: "local" as const } : {}), + ...(row.target === undefined ? {} : { target: row.target as string }), ...(row.message === undefined ? {} : { message: row.message as string }), + ...(row.intentDigest === undefined ? {} : { intentDigest: row.intentDigest }), + ...(initialIntent ? { initialIntent, initialRevision: row.initialRevision as number } : {}) }); + } + return out; +} +/** Publish only client identity, disposition and opt-out; target/message can expose local files or secrets. */ +export function routeClientOutcomesForPeer(outcomes: readonly RouteClientOutcome[]): RouteClientOutcome[] { + return outcomes.map(({ client, outcome, source, target, intentDigest, initialIntent, initialRevision }) => ({ client, outcome, ...(source === "local" ? { source } : {}), + ...(intentDigest ? { intentDigest } : {}), ...(initialIntent ? { initialIntent: { enabled: initialIntent.enabled }, initialRevision } : {}), + ...(client === "aside" && typeof target === "string" && /^u[0-9]+(?:,u[0-9]+)*$/.test(target) && target.length <= 256 ? { target } : {}) })); +} diff --git a/src/link/ssh-argv.ts b/src/link/ssh-argv.ts index 5647d6bb168..c471c495e8c 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -1,3 +1,4 @@ +import { isRouteClientId, type RouteClientId } from "./route-intent"; /** * Argument vectors for the system OpenSSH client used by machine links. * @@ -19,7 +20,7 @@ */ import { isAbsolute } from "node:path"; -import { POWERSHELL_ENCODED_MAX, windowsOcxArgv, windowsPinnedSyncArgv } from "./remote-windows"; +import { POWERSHELL_ENCODED_MAX, ROUTE_OVERWRITE_MIN_CHILD_VERSION, windowsOcxArgv, windowsPinnedSyncArgv } from "./remote-windows"; export type TunnelDirection = "R" | "L"; @@ -303,6 +304,26 @@ try_ocx() { ${REMOTE_OCX_CANDIDATES} exit ${REMOTE_COMMAND_NOT_FOUND}`; -export function remotePinnedSyncArgv(linkId: string, platform: "posix" | "windows" = "posix"): string[] { - return platform === "windows" ? windowsPinnedSyncArgv(linkId) : ["sh", "-c", REMOTE_PINNED_SYNC_SCRIPT, "ocx", linkId]; +export { ROUTE_OVERWRITE_MIN_CHILD_VERSION }; + +function replaceOnce(script: string, from: string, to: string): string { + if (!script.includes(from)) throw new Error(`pinned sync script lost its anchor: ${from}`); + return script.replace(from, () => to); +} + +/** Overwrite needs a Child that honours it: refuse ocx-child and older ocx before syncing. */ +function overwritePinnedSyncScript(overwriteClient?: RouteClientId): string { + const [major, minor, patch] = ROUTE_OVERWRITE_MIN_CHILD_VERSION.split("."); + let script = replaceOnce(REMOTE_PINNED_SYNC_SCRIPT, 'c=cmp(a[1],"2"); if(!c)c=cmp(a[2],"93"); if(!c)c=cmp(a[3],"0");', + `c=cmp(a[1],"${major}"); if(!c)c=cmp(a[2],"${minor}"); if(!c)c=cmp(a[3],"${patch}");`); + script = replaceOnce(script, "'ocx-fanout:child-ocx-lacks-expect-link'", "'ocx-fanout:child-ocx-lacks-overwrite'"); + script = replaceOnce(script, ' [ -x "$c" ] || return\n', + ' [ -x "$c" ] || return\n case "$c" in */ocx-child) printf \'%s\\n\' \'ocx-fanout:child-ephemeral-no-overwrite\' >&2; exit 121;; esac\n'); + return replaceOnce(script, 'sync --expect-link "$1" --json', 'sync --expect-link "$1" --json --overwrite-conflict' + (overwriteClient ? ` --overwrite-client ${overwriteClient}` : '')); +} + +export function remotePinnedSyncArgv(linkId: string, platform: "posix" | "windows" = "posix", overwriteConflict = false, overwriteClient?: RouteClientId): string[] { + if (overwriteClient !== undefined && (!overwriteConflict || !isRouteClientId(overwriteClient))) throw new LinkSshArgumentError("invalid overwrite client"); + const script = overwriteConflict ? overwritePinnedSyncScript(overwriteClient) : REMOTE_PINNED_SYNC_SCRIPT; + return platform === "windows" ? windowsPinnedSyncArgv(linkId, overwriteConflict, overwriteClient) : ["sh", "-c", script, "ocx", linkId]; } diff --git a/src/routing/home-route-capture.ts b/src/routing/home-route-capture.ts new file mode 100644 index 00000000000..55ef3ad0684 --- /dev/null +++ b/src/routing/home-route-capture.ts @@ -0,0 +1,123 @@ +/** + * Children inherit the Home's native switches as they stood when route intent began, never as they + * are now: ocx restore, Messages off, Claude Desktop apply and the Claude toggle are Home-only. + * + * One idempotent capture freezes the four native defaults into route-intent.json. It runs before + * a Home default write and at Child enrollment, and, only once the Home has a link, when its link + * listener binds (a failure there is retried, see link-listener.ts) and before any Home-only + * decision changes a switch. On a Home with no link Home-only decisions leave route-intent.json + * alone, so a Child enrolled later inherits the switches as they stand at enrollment unless a Home + * default write already saved them. Reads never capture. Once a default is saved it is never + * captured again; only a Home default write changes it. + */ +import { readConfigDiagnostics } from "../config"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { setIntegrationEnabled, type CodexDesiredStateResult, type DurableIntentClientId } from "../codex/desired-state"; +import { linkStorePath } from "../link/paths"; +import { readLinkStore } from "../link/store"; +import { HOME_NATIVE_ROUTE_CLIENTS, homeNativeRouteDefaults, type HomeNativeRouteClientId, type RouteClientIntent } from "../link/route-intent"; +import type { OcxConfig } from "../types"; +import { readRouteIntentState, routeIntentStatePath, updateRouteIntentState, type RouteIntentState } from "./route-intent-state"; + +function captured(state: RouteIntentState): boolean { + return HOME_NATIVE_ROUTE_CLIENTS.every(client => state.defaults[client] !== undefined); +} + +/** + * Whether a linked Child inherits these defaults. An ephemeral `ocx-child` never reads route intent, + * so a Home whose only links are ephemeral has nothing to protect. Unreadable trust metadata counts + * as linked: a Child may still depend on these defaults. + */ +export function homeHasLinks(): boolean { + try { return readLinkStore(linkStorePath()).links.some(link => link.childKind !== "ephemeral"); } catch { return true; } +} + +export interface CaptureHomeRouteDefaultsOptions { + /** + * Every Home-only switch writer passes this: a Home without any link has no Child to protect + * yet, so route-intent.json is neither read nor written for it. + */ + whenLinked?: boolean; +} + +/** The native defaults the Home's durable switches give (`fallback` only when the file is unreadable); null on a Child. */ +export function durableHomeNativeRouteDefaults(fallback?: OcxConfig): Record | null { + const latest = readConfigDiagnostics(); + const durable = latest.source === "file" && !latest.error ? latest.config : fallback; + return !durable || durable.runtimeRole === "client" ? null : homeNativeRouteDefaults(durable); +} + +/** Fills only the missing native defaults; a saved one is never captured again. */ +export function fillHomeNativeRouteDefaults(state: RouteIntentState, defaults: Record): boolean { + let filled = false; + for (const client of HOME_NATIVE_ROUTE_CLIENTS) { + if (state.defaults[client] !== undefined) continue; + state.defaults[client] = defaults[client]; + filled = true; + } + return filled; +} + +/** + * Saves the missing native defaults from the durable switches (`fallback` only when the config + * file is unreadable). A Child never captures. Returns whether anything was saved. + */ +export function captureHomeRouteDefaults(fallback?: OcxConfig, options: CaptureHomeRouteDefaultsOptions = {}): boolean { + if (fallback?.runtimeRole === "client") return false; + if (options.whenLinked && !homeHasLinks()) return false; + if (captured(readRouteIntentState())) return false; + return withConfigMutationLockSync(() => { + const defaults = durableHomeNativeRouteDefaults(fallback); + if (!defaults) return false; + let saved = false; + updateRouteIntentState(state => { saved = fillHomeNativeRouteDefaults(state, defaults); return state; }); + return saved; + }); +} + +/** + * A Home-only switch write refused because the Children's defaults could not be saved first. + * Writing the switch anyway would change what Children inherit once the file is repaired. + */ +export interface HomeRouteCaptureRefusal { + readonly ok: false; + readonly reason: "unavailable"; + readonly retryable: boolean; + readonly message: string; +} + +export type HomeIntegrationResult = CodexDesiredStateResult | HomeRouteCaptureRefusal; + +/** Only SQLITE_BUSY is contention worth retrying; any other lock failure repeats forever. */ +function lockContention(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + return (error as { code?: unknown }).code === "CONFIG_MUTATION_LOCK_UNAVAILABLE" + && (error as { cause?: { code?: unknown } }).cause?.code === "SQLITE_BUSY"; +} + +export function captureRefusal(error: unknown): HomeRouteCaptureRefusal { + const path = routeIntentStatePath(); + if (lockContention(error)) return { ok: false, reason: "unavailable", retryable: true, + message: `Another process is saving the configuration, so the Children's route defaults in ${path} could not be saved first; nothing was changed. Retry in a moment.` }; + return { ok: false, reason: "unavailable", retryable: false, + message: `The Children's route defaults in ${path} could not be read or saved; nothing was changed. Inspect it, fix or remove it, then retry.` }; +} + +/** `captureHomeRouteDefaults` for the Home-only switch writers: a failure is a typed refusal, never a throw. */ +export function captureHomeRouteDefaultsBeforeHomeSwitch(): HomeRouteCaptureRefusal | null { + try { + captureHomeRouteDefaults(undefined, { whenLinked: true }); + return null; + } catch (error) { + return captureRefusal(error); + } +} + +/** + * The Home-only switch writers (ocx restore, ocx restore back, Claude Desktop apply from the CLI + * and the dashboard) go through here. desired-state.ts itself stays free of link state: it is on + * the always-on request path (tests/lab/core-link-boundary.test.ts). + */ +export function setHomeIntegrationEnabled(client: DurableIntentClientId, enabled: boolean): HomeIntegrationResult { + return captureHomeRouteDefaultsBeforeHomeSwitch() ?? setIntegrationEnabled(client, enabled); +} diff --git a/src/routing/home-route-intent.ts b/src/routing/home-route-intent.ts new file mode 100644 index 00000000000..32ae8226567 --- /dev/null +++ b/src/routing/home-route-intent.ts @@ -0,0 +1,178 @@ +/** Upgrade bridge: existing journaled integrations are Home intent, never unowned file guesses. */ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { readBoundedRegularFileSync } from "../lib/bounded-file-read"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../integrations/store"; +import { listAsideProfileStores } from "../integrations/aside-profile-context"; +import { asideHomeDir } from "../clients/config-export"; +import { parseRouteClientIntent, projectHomeRouteIntent, ROUTE_CLIENT_IDS, type HomeRouteIntentDocument, type RouteClientId, type RouteClientIntent, type RouteIntentState } from "../link/route-intent"; +import { dropHomeNativeOverrides, homeRouteIntentInUse, readRouteIntentState, setRouteClientIntent, updateRouteIntentState } from "./route-intent-state"; +import { captureHomeRouteDefaults, durableHomeNativeRouteDefaults, fillHomeNativeRouteDefaults, homeHasLinks, type CaptureHomeRouteDefaultsOptions } from "./home-route-capture"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { readConfigDiagnostics } from "../config"; +import { packageVersion } from "../lib/package-version"; +import type { OcxConfig } from "../types"; + +/** Existing Aside ownership must be resolved explicitly, never projected as off. */ +export class HomeAsideIntentDerivationError extends Error { + constructor(cause: unknown) { + super("Existing Aside ownership needs an explicit Home route intent", { cause }); + this.name = "HomeAsideIntentDerivationError"; + } +} + +export function readHomeRouteIntentState(config: OcxConfig, options: { store?: IntegrationStateStore; home?: string; state?: RouteIntentState } = {}): RouteIntentState { + const state = structuredClone(options.state ?? readRouteIntentState()); + const store = options.store ?? createIntegrationStateStore(); + const records = store.readRecordsStrict(); + for (const [client, owner] of Object.entries(records)) { + if (state.defaults[client as keyof typeof state.defaults] !== undefined || !owner || client === "aside") continue; + const intent = parseRouteClientIntent({ enabled: true, ...(owner.modelNames ? { modelNames: owner.modelNames } : {}) }); + if (intent) state.defaults[client as keyof typeof state.defaults] = intent; + } + if (state.defaults.aside !== undefined) return state; + const aside = deriveHomeAsideIntent(config, { store, home: options.home }); + if (aside) state.defaults.aside = aside; + return state; +} + +/** + * The Aside intent the Home's current Aside ownership amounts to: the stable userIds of every owned + * profile, all profiles under the all-profiles policy, or undefined when nothing is owned. + * `extraProfileIds` adds profiles about to be enabled, so a caller can refuse before writing + * anything when one of them has no stable userId. Throws HomeAsideIntentDerivationError. + */ +export function deriveHomeAsideIntent(config: OcxConfig, options: { store?: IntegrationStateStore; home?: string; extraProfileIds?: readonly number[] | "all" } = {}): RouteClientIntent | undefined { + const store = options.store ?? createIntegrationStateStore(); + const records = store.readRecordsStrict(); + const scopes = listAsideProfileStores(store).filter(scope => scope.store.readRecordsStrict().aside); + const extra = options.extraProfileIds ?? []; + if (!records.aside && scopes.length === 0 && extra.length === 0 && config.asideProfileSync?.allProfiles !== true) return undefined; + if (config.asideProfileSync?.allProfiles === true) return { enabled: true, accounts: "all" }; + try { + const raw: unknown = JSON.parse(readBoundedRegularFileSync(join(asideHomeDir(process.env, options.home ?? homedir()), "accounts.json"), 4 * 1024 * 1024).toString("utf8")); + if (!raw || typeof raw !== "object" || !("accounts" in raw) || !Array.isArray(raw.accounts)) throw new Error("existing Aside selection needs explicit route intent"); + const every = extra === "all" ? raw.accounts.flatMap((row: unknown) => row && typeof row === "object" && "id" in row && typeof row.id === "number" ? [row.id] : []) : extra; + const slots = new Set([...scopes.map(scope => scope.profileId), ...every]); + if (records.aside) { + const root = asideHomeDir(process.env, options.home ?? homedir()); + const owned = resolve(records.aside.configPath); + const matches = raw.accounts.filter((row: unknown) => row && typeof row === "object" && "id" in row + && typeof row.id === "number" && Number.isSafeInteger(row.id) && row.id >= 0 + && resolve(join(root, "u", String(row.id), "models.json")) === owned); + if (matches.length !== 1) throw new Error("existing Aside owner needs explicit route intent"); + slots.add(matches[0].id); + } + const userIds: string[] = []; + for (const slot of slots) { + const row = raw.accounts.find((value: unknown) => value && typeof value === "object" && "id" in value && value.id === slot); + if (!row || typeof row !== "object" || !("userId" in row) || typeof row.userId !== "string") throw new Error("existing Aside selection needs explicit stable userIds"); + userIds.push(row.userId); + } + const intent = parseRouteClientIntent({ enabled: true, accounts: [...new Set(userIds)] }, "aside"); + if (!intent) throw new Error("existing Aside selection needs explicit stable userIds"); + return intent; + } catch (cause) { + throw new HomeAsideIntentDerivationError(cause); + } +} + +/** + * Records a Home Aside profile change (the dashboard, `ocx integration client enable --client aside + * --profile N`, an Aside restore) as the Home's `devices.home.aside` decision, naming the stable + * userIds of every profile it owns afterwards, so ocx sync keeps it. Only once the Home uses route + * intent; returns whether a decision was recorded. Throws HomeAsideIntentDerivationError. + */ +export function recordHomeAsideDecision(config: OcxConfig): boolean { + return withConfigMutationLockSync(() => { + if (!homeRouteIntentInUse(readRouteIntentState())) return false; + setRouteClientIntent("aside", deriveHomeAsideIntent(config) ?? { enabled: false }, "home"); + return true; + }); +} + +/** + * Capture defaults before a local materializer changes native switches or owners. The native + * defaults come from the shared one-time capture; every other client from ownership or builtin. + * A Home-only decision passes `whenLinked`, the rule every Home-only switch writer follows: a Home + * without links has no Child to protect, so nothing reads or writes route-intent.json for it. Saving + * ownership defaults there would pin clients the dashboard later turns on (ocx sync applies saved + * defaults), and a damaged file would block a toggle that never needed it. + */ +export function initializeHomeRouteIntentDefaults(config: OcxConfig, version: string, decidingDefault?: RouteClientId, + options: CaptureHomeRouteDefaultsOptions = {}): void { + if (options.whenLinked && !homeHasLinks()) return; + withConfigMutationLockSync(() => { + // Every default is derived before anything is written: an Aside refusal leaves the file as it was. + const native = durableHomeNativeRouteDefaults(config); + const prior = readRouteIntentState(); + if (native) fillHomeNativeRouteDefaults(prior, native); + if (decidingDefault) prior.defaults[decidingDefault] = { enabled: false }; + const inherited = projectHomeRouteIntent(config, version, undefined, readHomeRouteIntentState(config, { state: prior })); + updateRouteIntentState(state => { + if (native) fillHomeNativeRouteDefaults(state, native); + for (const [client, intent] of Object.entries(inherited.clients)) state.defaults[client as keyof typeof state.defaults] ??= intent; + // A `devices.home` native entry from an earlier version no longer decides anything. + dropHomeNativeOverrides(state); + return state; + }); + }); +} + +/** + * The link listener's capture of every default a Child inherits, from the Home's durable config: + * the native switches and the file clients' ownership, frozen in one write. An upgraded Home that + * saved only the native defaults gets the rest here, so a later dashboard toggle cannot move a + * Child's default. When existing Aside ownership cannot be resolved nothing else is saved; the + * native defaults are then captured on their own and the error is raised so the listener retries. + */ +export function captureChildRouteDefaults(): void { + const latest = readConfigDiagnostics(); + if (latest.error) throw new Error("the configuration could not be read"); + if (latest.config.runtimeRole === "client") return; + try { + initializeHomeRouteIntentDefaults(latest.config, packageVersion("0.0.0"), undefined, { whenLinked: true }); + } catch (error) { + if (error instanceof HomeAsideIntentDerivationError) captureHomeRouteDefaults(undefined, { whenLinked: true }); + throw error; + } +} + +/** + * What a Child inherits. Never reads the Home's live native switches; see home-route-capture. + * A pure read: enrollment and the link listener's start have already captured the native defaults. + */ +export function projectChildRouteIntent(config: OcxConfig, version: string, deviceId: string): HomeRouteIntentDocument { + return projectHomeRouteIntent(config, version, deviceId, readHomeRouteIntentState(config)); +} + +/** Provisioning runs under Home authority, before the read-only Child starts. */ +export function seedRouteDeviceClients(config: OcxConfig, version: string, deviceId: string, selected?: readonly RouteClientId[], activeDeviceIds?: readonly string[]): void { + withConfigMutationLockSync(() => { + if (activeDeviceIds) reconcileHomeRouteDevices(activeDeviceIds); + initializeHomeRouteIntentDefaults(config, version); + // Absence means inherit, not an implicit device selection that masks Home changes. + if (selected === undefined) return; + updateRouteIntentState(state => { + const overrides = (state.devices[deviceId] ??= {}); + const revisions = ((state.revisions ??= {})[deviceId] ??= {}); + for (const client of ROUTE_CLIENT_IDS) { + if (client !== "codex" && client !== "claude" && !selected.includes(client)) continue; + overrides[client] = { ...(overrides[client] ?? {}), enabled: selected.includes(client) }; + revisions[client] = (revisions[client] ?? 0) + 1; + } + return state; + }); + }); +} + +/** Trust retirement also retires policy/revisions; Home and live peers retain their state. */ +export function reconcileHomeRouteDevices(activeDeviceIds: readonly string[]): void { + const live = new Set(["home", ...activeDeviceIds]); + updateRouteIntentState(state => { + for (const map of [state.devices, state.revisions]) { + for (const id of Object.keys(map ?? {})) if (!live.has(id)) delete map![id]; + } + return state; + }); +} diff --git a/src/routing/route-intent-state.ts b/src/routing/route-intent-state.ts new file mode 100644 index 00000000000..cf209864fec --- /dev/null +++ b/src/routing/route-intent-state.ts @@ -0,0 +1,178 @@ +/** Home-owned desired policy. Reads never create or repair a file. */ +import { chmodSync, lstatSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { getConfigDir } from "../config/paths"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { emptyRouteIntentState, HOME_NATIVE_ROUTE_CLIENTS, isHomeNativeRouteClient, isRouteClientId, parseRouteClientIntent, + type HomeNativeRouteClientId, type RouteClientId, type RouteClientIntent, type RouteIntentState } from "../link/route-intent"; +export type { RouteIntentState } from "../link/route-intent"; + +const MAX_STATE_BYTES = 1024 * 1024; +export function routeIntentStatePath(): string { return join(getConfigDir(), "route-intent.json"); } + +/** + * `route-intent.json` exists but cannot be read or parsed. Writers answer it with a typed refusal + * that names the file, because only the operator can fix or remove it. + */ +export class RouteIntentStateUnreadableError extends Error { + readonly path: string; + constructor(path: string, cause: unknown) { + super(`Route intent in ${path} could not be read; nothing was changed. Inspect it, fix or remove it, then retry.`, { cause }); + this.name = "RouteIntentStateUnreadableError"; + this.path = path; + } +} +function deviceIdAllowed(value: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/.test(value) && !["__proto__", "constructor", "prototype"].includes(value); +} +function parseIntentMap(value: unknown): Partial> { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid route intent map"); + const result: Partial> = {}; + for (const [client, raw] of Object.entries(value)) { + if (!isRouteClientId(client)) throw new Error("unknown route client"); + const intent = parseRouteClientIntent(raw, client); + if (!intent) throw new Error("invalid route client intent"); + result[client] = intent; + } + return result; +} +export function parseRouteIntentState(value: unknown): RouteIntentState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid route intent state"); + const raw = value as Record; + if (raw.version !== 1 || Object.keys(raw).some(key => !["version", "defaults", "devices", "revisions"].includes(key)) || !Object.hasOwn(raw, "defaults") || !Object.hasOwn(raw, "devices") + || !raw.devices || typeof raw.devices !== "object" || Array.isArray(raw.devices)) throw new Error("invalid route intent state"); + const state = emptyRouteIntentState(); + state.defaults = parseIntentMap(raw.defaults); + for (const [id, map] of Object.entries(raw.devices)) { + if (!deviceIdAllowed(id)) throw new Error("invalid route device id"); + state.devices[id] = parseIntentMap(map); + } + if (raw.revisions !== undefined) { + if (!raw.revisions || typeof raw.revisions !== "object" || Array.isArray(raw.revisions)) throw new Error("invalid route device revisions"); + state.revisions = {}; + for (const [id, map] of Object.entries(raw.revisions)) { + if (!deviceIdAllowed(id) || !map || typeof map !== "object" || Array.isArray(map)) throw new Error("invalid route device revisions"); + const revisions: Partial> = {}; + for (const [client, revision] of Object.entries(map)) { + if (!isRouteClientId(client) || typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) throw new Error("invalid route client revision"); + revisions[client] = revision; + } + state.revisions[id] = revisions; + } + } + return state; +} +export function readRouteIntentState(path = routeIntentStatePath()): RouteIntentState { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size > MAX_STATE_BYTES) throw new Error("invalid route intent state file"); + return parseRouteIntentState(JSON.parse(readFileSync(path, "utf8"))); + } catch (error) { + if (isMissingPathError(error)) return emptyRouteIntentState(); + throw new RouteIntentStateUnreadableError(path, error); + } +} +function writeState(state: RouteIntentState, path: string): void { + const dir = dirname(path); + assertNotRealHomeUnderTest(dir); + const body = `${JSON.stringify(state, null, 2)}\n`; + if (Buffer.byteLength(body) > MAX_STATE_BYTES) throw new Error("route intent state too large"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform === "win32") hardenSecretDir(dir, { required: true }); + else chmodSync(dir, 0o700); + atomicWriteFile(path, body); +} +export function writeRouteIntentState(state: RouteIntentState, path = routeIntentStatePath()): void { + const parsed = parseRouteIntentState(state); + withConfigMutationLockSync(() => writeState(parsed, path), dirname(path)); +} +export function updateRouteIntentState(update: (state: RouteIntentState) => RouteIntentState, path = routeIntentStatePath()): RouteIntentState { + return withConfigMutationLockSync(() => { + const next = parseRouteIntentState(update(readRouteIntentState(path))); + writeState(next, path); + return next; + }, dirname(path)); +} +export function setRouteClientIntent(client: RouteClientId, intent: RouteClientIntent, deviceId?: string): RouteIntentState { + const parsed = parseRouteClientIntent(intent, client); + if (!isRouteClientId(client) || !parsed || (deviceId !== undefined && !deviceIdAllowed(deviceId))) throw new Error("invalid route intent selection"); + if (deviceId === "home" && isHomeNativeRouteClient(client)) throw new Error("A Home native decision is its native switch; use recordHomeNativeDecision"); + return updateRouteIntentState(state => { + const map = deviceId === undefined ? state.defaults : (state.devices[deviceId] ??= {}); + map[client] = { ...map[client], ...parsed }; + if (deviceId !== undefined) advanceRevision(state, deviceId, client); + return state; + }); +} +export function clearRouteClientIntent(client: RouteClientId, deviceId: string): RouteIntentState { + if (!isRouteClientId(client) || !deviceIdAllowed(deviceId)) throw new Error("invalid route intent selection"); + if (deviceId === "home" && isHomeNativeRouteClient(client)) throw new Error("A Home native decision is its native switch; use recordHomeNativeDecision"); + return updateRouteIntentState(state => { + const map = state.devices[deviceId]; + if (map) { delete map[client]; if (Object.keys(map).length === 0) delete state.devices[deviceId]; } + advanceRevision(state, deviceId, client); + return state; + }); +} + +/** + * Earlier versions recorded Home native decisions as `devices.home` entries. The Home's native + * switches govern those clients now, so such an entry is dropped instead of pinning anything. + */ +export function dropHomeNativeOverrides(state: RouteIntentState, clients: readonly HomeNativeRouteClientId[] = HOME_NATIVE_ROUTE_CLIENTS): void { + const map = state.devices.home; + if (!map) return; + for (const client of clients) delete map[client]; + if (Object.keys(map).length === 0) delete state.devices.home; +} + +/** Drops those legacy entries from the file, writing only when one is there. */ +export function dropLegacyHomeNativeOverrides(): void { + withConfigMutationLockSync(() => { + const map = readRouteIntentState().devices.home; + if (!map || !HOME_NATIVE_ROUTE_CLIENTS.some(client => map[client] !== undefined)) return; + updateRouteIntentState(state => { dropHomeNativeOverrides(state); return state; }); + }); +} + +/** + * Bookkeeping for a Home decision about a native client, which itself is written to the Home's + * native switch. No `devices.home` entry is kept. The caller has already captured the Children's + * native defaults (src/routing/home-route-capture.ts), so the switch never becomes one. + */ +export function recordHomeNativeDecision(client: HomeNativeRouteClientId): RouteIntentState { + return updateRouteIntentState(state => { + dropHomeNativeOverrides(state, [client]); + return state; + }); +} + +/** ocx sync applies route intent to every Home client once the file holds a default or a Home entry. */ +export function homeRouteIntentInUse(state: RouteIntentState): boolean { + return Object.keys(state.defaults).length > 0 || Object.keys(state.devices.home ?? {}).length > 0; +} + +/** + * A file integration toggle on the Home (the dashboard, `PUT /api/client-integrations/`) is the + * Home's own decision for that client. Once the Home uses route intent, ocx sync applies it to every + * client, so the toggle is recorded as a `devices.home` entry and Children keep their defaults. + * Without route intent nothing is written. Returns whether a decision was recorded. + */ +export function recordHomeFileIntegrationDecision(client: RouteClientId, intent: RouteClientIntent): boolean { + if (isHomeNativeRouteClient(client) || client === "aside") return false; + return withConfigMutationLockSync(() => { + if (!homeRouteIntentInUse(readRouteIntentState())) return false; + setRouteClientIntent(client, intent, "home"); + return true; + }); +} + +function advanceRevision(state: RouteIntentState, deviceId: string, client: RouteClientId): void { + const revisions = ((state.revisions ??= {})[deviceId] ??= {}); + const next = (revisions[client] ?? 0) + 1; + if (!Number.isSafeInteger(next)) throw new Error("route device revision exhausted"); + revisions[client] = next; +} diff --git a/src/routing/route-to-live-settings.ts b/src/routing/route-to-live-settings.ts new file mode 100644 index 00000000000..a67095346d3 --- /dev/null +++ b/src/routing/route-to-live-settings.ts @@ -0,0 +1,35 @@ +import { readConfigDiagnostics, withConfigMutationLockSync } from "../config"; +import type { OcxConfig } from "../types"; + +/** Reflect controller-owned fields from current durable state, preserving concurrent settings. */ +export function mirrorRouteNativeSettings(live: OcxConfig, current: OcxConfig): void { + for (const client of ["codex", "grok", "claude-desktop"] as const) { + const value = current.clientIntegrations?.[client]; + if (value !== undefined) (live.clientIntegrations ??= {})[client] = value; + else if (live.clientIntegrations) delete live.clientIntegrations[client]; + } + for (const key of ["enabled", "authModeMigratedAt"] as const) { + const value = current.claudeCode?.[key]; + if (value !== undefined) live.claudeCode = { ...live.claudeCode, [key]: value }; + else if (live.claudeCode) delete live.claudeCode[key]; + } + const messages = current.apiSurfaces?.messages?.enabled; + if (messages !== undefined) live.apiSurfaces = { ...live.apiSurfaces, messages: { ...live.apiSurfaces?.messages, enabled: messages } }; + else if (live.apiSurfaces?.messages) delete live.apiSurfaces.messages.enabled; +} + +export function refreshRouteNativeSettings(live: OcxConfig): void { + withConfigMutationLockSync(() => { + const latest = readConfigDiagnostics(); + if (latest.source !== "file" || latest.error) throw new Error("config_unavailable"); + mirrorRouteNativeSettings(live, latest.config); + }); +} + +/** A copy carrying the durable native switches; another process may have changed them. */ +export function withDurableRouteNativeSettings(config: OcxConfig): OcxConfig { + const copy = structuredClone(config); + const latest = readConfigDiagnostics(); + if (latest.source === "file" && !latest.error) mirrorRouteNativeSettings(copy, latest.config); + return copy; +} diff --git a/src/routing/route-to-local.ts b/src/routing/route-to-local.ts new file mode 100644 index 00000000000..e7db194af7e --- /dev/null +++ b/src/routing/route-to-local.ts @@ -0,0 +1,118 @@ +/** Local CLI reads/applies never relay management writes from a Child to its Home. */ +import { readClientConnectionState } from "../client/state"; +import { withLinkApply } from "../client/link-home"; +import { projectHomeRouteIntent, ROUTE_CLIENT_IDS, type RouteClientId, type RouteClientIntent, type RouteClientOutcome } from "../link/route-intent"; +import { HomeAsideIntentDerivationError, initializeHomeRouteIntentDefaults, readHomeRouteIntentState } from "./home-route-intent"; +import { captureHomeRouteDefaults } from "./home-route-capture"; +import { routeToDeviceStatus, type RouteToDeviceStatus, type RouteToStatus } from "./route-to-status"; +import { withDurableRouteNativeSettings } from "./route-to-live-settings"; +import { dropLegacyHomeNativeOverrides, readRouteIntentState } from "./route-intent-state"; +import { readLocalRouteIntentOff, readRouteIntentReceipt, setLocalRouteIntentOff } from "../integrations/route-intent"; +import type { LinkChildContext } from "../cli/link-child"; +import type { OcxConfig } from "../types"; + +export function readHomeRouteToStatus(config: OcxConfig, version: string): RouteToDeviceStatus { + const local = withDurableRouteNativeSettings(config); + const document = projectHomeRouteIntent(local, version, "home", readHomeRouteIntentState(local)); + const receipt = readRouteIntentReceipt(); + return routeToDeviceStatus("home", "home", document, receipt?.outcomes ?? [], receipt?.appliedRouteIntentDigest ?? null); +} + +/** What a full Home sync tells the person when it leaves Aside alone. */ +export const HOME_ASIDE_UNRESOLVED_MESSAGE = "Aside was left unchanged on this Home: its existing Aside profiles have no stable userIds in Aside's accounts.json, so route intent cannot name them. Repair the manifest, or decide Aside for the Home with ocx route to aside on|off --device home."; + +/** + * `onlyClient` is the client a single Home write decides: only that client is materialized on the + * Home. For a native client the decision (`onlyIntent`, else the default Children inherit) is + * written through the Home's native switch. A sync passes none and never writes a Home native + * switch: each native client is refreshed to match the switch as it is. + * + * A full sync on a Home whose existing Aside ownership cannot be named by stable userIds, and that + * has no saved Aside decision, leaves Aside exactly as it is and applies every other client, calling + * `onAsideUnresolved`. A Home upgraded with such profiles therefore keeps a working `ocx sync`; + * only a write that decides Aside still refuses until the person resolves it. + */ +export async function syncHomeRouteTo(config: OcxConfig, version: string, overwriteConflict = false, overwriteClient?: RouteClientId, commit?: () => void, + deps: { loadModels?: typeof import("../server/management/model-rows").loadExportModels; materialize?: typeof import("../client/route-intent-apply").materializeLocalRouteIntent; + onlyClient?: RouteClientId; onlyIntent?: () => RouteClientIntent | undefined; onAsideUnresolved?: (message: string) => void } = {}): Promise { + return withLinkApply(async () => { + commit?.(); + const local = withDurableRouteNativeSettings(config); + const leaveAside = deps.onlyClient === undefined || deps.onlyClient !== "aside"; + let asideUnresolved = false; + // The Home's own apply is Home-only: it captures the Children's native defaults only once the + // Home has a link. A default write's commit above has already captured them. Without a link it + // saves no defaults, and only drops a legacy devices.home native entry if one is there. + try { initializeHomeRouteIntentDefaults(local, version, undefined, { whenLinked: true }); } + catch (error) { + if (!(error instanceof HomeAsideIntentDerivationError) || !leaveAside) throw error; + // Save what can be saved: the native defaults, never a guessed Aside default. + captureHomeRouteDefaults(undefined, { whenLinked: true }); + asideUnresolved = true; + } + dropLegacyHomeNativeOverrides(); + const decidedIntent = deps.onlyIntent?.(); + let state; + try { state = readHomeRouteIntentState(local); } + catch (error) { + if (!(error instanceof HomeAsideIntentDerivationError) || !leaveAside) throw error; + asideUnresolved = true; + // A placeholder so the document is complete; Aside is excluded from materialization below. + const pinned = readRouteIntentState(); + pinned.defaults.aside = { enabled: false }; + state = readHomeRouteIntentState(local, { state: pinned }); + } + if (asideUnresolved && (state.devices.home?.aside !== undefined || readRouteIntentState().defaults.aside !== undefined)) asideUnresolved = false; + const document = projectHomeRouteIntent(local, version, "home", state, + { decidedClient: deps.onlyClient, ...(decidedIntent ? { decidedIntent } : {}) }); + const { loadExportModels } = await import("../server/management/model-rows"); + const { materializeLocalRouteIntent } = await import("../client/route-intent-apply"); + const materialize = deps.materialize ?? materializeLocalRouteIntent; + const only = deps.onlyClient !== undefined ? [deps.onlyClient] + : asideUnresolved ? ROUTE_CLIENT_IDS.filter(client => client !== "aside") : undefined; + // A single-client write keeps the other clients' last proven outcomes in the Home receipt. + const scope = only ? { retainSkippedOutcomes: true, ...(deps.onlyClient ? { decidedClient: deps.onlyClient } : {}) } : {}; + const off = ROUTE_CLIENT_IDS.filter(client => !document.clients[client].enabled && (!only || only.includes(client))); + await materialize(local, document, [], { overwriteConflict, overwriteClient, onlyClients: off, ...scope }); + const receipt = await materialize(local, document, await (deps.loadModels ?? loadExportModels)(local), { overwriteConflict, overwriteClient, + ...(only ? { onlyClients: only } : {}), ...scope }); + if (asideUnresolved) deps.onAsideUnresolved?.(HOME_ASIDE_UNRESOLVED_MESSAGE); + return asideUnresolved ? receipt.outcomes.filter(row => row.client !== "aside") : receipt.outcomes; + }); +} + +function assertChild(context: LinkChildContext): void { + const state = readClientConnectionState(); + if (state.kind !== "connected" || state.value.transport !== "link" || state.value.link?.linkId !== context.linkId) { + throw new Error("The Remote Link connection changed; retry against the current device"); + } +} + +export function readLocalRouteToStatus(context: LinkChildContext): RouteToStatus { + assertChild(context); + const receipt = readRouteIntentReceipt(); + if (!receipt) throw new Error("This Child has no recorded route intent. Run ocx route to sync to read and apply its Home intent"); + const stale = receipt.outcomes.filter(row => row.outcome === "stale").map(row => row.client); + return { schema: "ocx.route-to/1", devices: [routeToDeviceStatus(context.linkId, "this", receipt.document, + receipt.outcomes, receipt.appliedRouteIntentDigest, readLocalRouteIntentOff())], + ...(receipt.catalogPending || stale.length ? { deferred: { reason: "intent_or_catalog_pending" as const, clients: stale } } : {}) }; +} + +export async function syncLocalRouteTo(context: LinkChildContext, overwriteConflict = false, + deps: { sync?: typeof import("../client/connect").syncConnectedClient } = {}): Promise { + assertChild(context); + const { syncConnectedClient } = await import("../client/connect"); + const result = await (deps.sync ?? syncConnectedClient)({ expect: { kind: "link", linkId: context.linkId }, overwriteConflict }); + const status = readLocalRouteToStatus(context); + if (result.stale) status.deferred ??= { reason: "intent_or_catalog_pending", clients: status.devices[0]!.clients.filter(row => row.applied === "stale").map(row => row.client) }; + return status; +} + +export async function setLocalRouteToOff(context: LinkChildContext, client: RouteClientId, off: boolean): Promise { + await withLinkApply(async () => { + assertChild(context); + setLocalRouteIntentOff(client, off); + }); + // The existing sync takes the lifecycle/config locks and checks the pinned connection again. + return syncLocalRouteTo(context); +} diff --git a/src/routing/route-to-status.ts b/src/routing/route-to-status.ts new file mode 100644 index 00000000000..c944491ca62 --- /dev/null +++ b/src/routing/route-to-status.ts @@ -0,0 +1,81 @@ +import { ROUTE_CLIENT_IDS, routeClientIntentDigest, routeIntentDigest, type HomeRouteIntentDocument, type RouteClientId, type RouteClientOutcome } from "../link/route-intent"; + +export interface RouteToClientStatus { + client: RouteClientId; + intent: "on" | "off"; + /** `native`: a Home row governed by the Home's own persisted native switch. */ + source: "builtin" | "default" | "device" | "native" | "local"; + applied: RouteClientOutcome["outcome"] | null; + target?: string; +} + +export interface RouteToDeviceStatus { + id: string; + alias: string; + desiredDigest: string; + appliedRouteIntentDigest: string | null; + clients: RouteToClientStatus[]; + /** + * Present only on a device that never reads route intent: an ephemeral `ocx-child` refreshes the + * Home catalog through ocx sync and nothing else, so it has no client rows. + */ + routeIntent?: "unsupported"; + reason?: typeof EPHEMERAL_NO_ROUTE_INTENT; +} + +/** Why an ephemeral `ocx-child` link has no route intent. */ +export const EPHEMERAL_NO_ROUTE_INTENT = "ephemeral-child-no-route-intent" as const; + +export type RouteToSyncRow = { id: string; state: "synced" | "failed" | "pending" | "unsupported"; reason?: string }; + +export interface RouteToStatus { + schema: "ocx.route-to/1"; + devices: RouteToDeviceStatus[]; + syncs?: RouteToSyncRow[]; + deferred?: { reason: "intent_or_catalog_pending"; clients: RouteClientId[] }; +} + +/** The status row of an ephemeral Child: route intent is unsupported, never stale or pending. */ +export function unsupportedRouteToDevice(id: string, alias: string): RouteToDeviceStatus { + return { id, alias, desiredDigest: "", appliedRouteIntentDigest: null, clients: [], routeIntent: "unsupported", reason: EPHEMERAL_NO_ROUTE_INTENT }; +} + +/** + * Compare receipts with today's intent: an outcome recorded for an earlier intent is stale, never + * current. That includes an earlier conflict or failure, which says nothing about the new intent. + */ +export function routeToDeviceStatus( + id: string, + alias: string, + document: HomeRouteIntentDocument, + outcomes: readonly RouteClientOutcome[], + appliedDigest: string | null, + localOff: readonly RouteClientId[] = [], +): RouteToDeviceStatus { + const effective = structuredClone(document); + for (const row of outcomes) { + if (row.initialIntent && row.initialRevision === (document.clientRevisions?.[row.client] ?? 0)) { + effective.clients[row.client] = { ...effective.clients[row.client], ...row.initialIntent }; + effective.sources[row.client] = "device"; + } + } + const desiredDigest = routeIntentDigest(effective); + return { + id, alias, desiredDigest, appliedRouteIntentDigest: appliedDigest, + clients: ROUTE_CLIENT_IDS.map(client => { + const outcome = outcomes.find(row => row.client === client); + const disabledLocally = localOff.includes(client) || outcome?.source === "local"; + // A receipt without per-client digests only proves its whole-document digest for current rows. + const outdated = outcome !== undefined && !disabledLocally && outcome.outcome !== "stale" + && (outcome.intentDigest ? outcome.intentDigest !== routeClientIntentDigest(effective, client) + : outcome.outcome === "current" && desiredDigest !== appliedDigest); + return { + client, + intent: disabledLocally ? "off" : effective.clients[client].enabled ? "on" : "off", + source: disabledLocally ? "local" : effective.sources[client], + applied: outdated ? "stale" : outcome?.outcome ?? null, + ...(outcome?.target ? { target: outcome.target } : {}), + }; + }), + }; +} diff --git a/src/routing/route-to-sync-child.ts b/src/routing/route-to-sync-child.ts new file mode 100644 index 00000000000..e7c5e2d863a --- /dev/null +++ b/src/routing/route-to-sync-child.ts @@ -0,0 +1,32 @@ +import { buildExecArgv, remotePinnedSyncArgv } from "../link/ssh-argv"; +import { classifySyncResult } from "../cli/sync-fanout"; +import { requireRemotePlatformFramed } from "../link/remote-platform"; +import { knownHostsFile, runnerFor } from "../server/management/link-route-state"; +import type { ManagementContext } from "../server/management/context"; +import type { RouteClientId } from "../link/route-intent"; +import type { LinkRecord } from "../link/store"; +import { homeRouteIntentWatch } from "../link/route-intent-watch"; +import { EPHEMERAL_NO_ROUTE_INTENT, type RouteToSyncRow } from "./route-to-status"; + +/** Reuse pinned sync: never run on a machine that has disconnected or joined another Home. */ +export async function syncRouteToChild(link: LinkRecord, ctx: ManagementContext, overwriteConflict = false, overwriteClient?: RouteClientId): Promise> { + // An ephemeral ocx-child never reads route intent; its catalog sync would apply none of it. + if (link.childKind === "ephemeral") return { state: "unsupported", reason: EPHEMERAL_NO_ROUTE_INTENT }; + if (link.direction === "client-initiated") { + const notified = homeRouteIntentWatch.notify(link.id); + return { state: "pending", reason: overwriteConflict + ? "Reconciliation requested without conflict overwrite; run ocx route to sync --overwrite-conflict on the Child to authorize local overwrites" + : notified ? "Immediate reconciliation requested; awaiting the Child apply report" + : "No active Child watcher; pending next sync tick. Run ocx route to sync on the Child for immediate apply" }; + } + try { + const runner = runnerFor(ctx); + const knownHosts = knownHostsFile(ctx); + const platform = link.remotePlatform ?? await requireRemotePlatformFramed(runner, link.alias, knownHosts); + const result = await runner.run(buildExecArgv({ alias: link.alias, knownHostsFile: knownHosts, argv: remotePinnedSyncArgv(link.id, platform, overwriteConflict, overwriteClient) }), { timeoutMs: 60_000, maxOutputBytes: 64 * 1024 }); + const verdict = classifySyncResult(result); + return verdict.state === "synced" ? { state: "synced" } : { state: "failed", reason: verdict.reason ?? "child-sync-failed" }; + } catch { + return { state: "failed", reason: "child-sync-unavailable" }; + } +} diff --git a/src/server/index/link-listener.ts b/src/server/index/link-listener.ts index 4f020381c9c..bc1e088314c 100644 --- a/src/server/index/link-listener.ts +++ b/src/server/index/link-listener.ts @@ -11,6 +11,7 @@ import { linkStorePath } from "../../link/paths"; import { linkRouteAllowed } from "../../link/routes"; import { withConfigMutationLockSync } from "../../config/mutation-lock"; import { registerFallbackLinkResolver } from "../../routing/fallback-policy"; +import { captureChildRouteDefaults } from "../../routing/home-route-intent"; export const LINK_INGRESS_HOSTNAME = "opencodex-link.invalid"; @@ -36,6 +37,10 @@ export interface LinkListenerDeps { writeStore?: (path: string, store: LinkStore) => void; serve?: (options: Parameters[0]) => Server; warn?: (message: string) => void; + /** Freezes every default Children inherit (native switches and file ownership) before this listener can serve route intent. */ + captureRouteDefaults?: () => void; + /** Delay before retry `attempt` (0-based) of a failed capture; tests shorten it. */ + captureRetryDelayMs?: (attempt: number) => number; } export type LinkListenerStatus = { @@ -100,6 +105,32 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin const releaseFallbackLinkResolver = registerFallbackLinkResolver(keyId => readStoreForAdmission().links.find(link => link.apiKeyId === keyId)?.id); + // Children read route intent through this listener, and that read is pure: it never captures. + // So a failed capture (a busy config lock, a damaged route-intent.json, unresolved Aside + // ownership) is owed and retried with backoff until it succeeds, the listener closes or stops, + // and again on every ensureStarted. Meanwhile every Home-only writer still captures first. + let routeDefaultsOwed = false; + let captureAttempt = 0; + let captureRetry: ReturnType | undefined; + const retryDelay = deps.captureRetryDelayMs ?? (attempt => Math.min(1_000 * 2 ** attempt, 300_000)); + const captureRouteDefaults = (): void => { + if (captureRetry) { clearTimeout(captureRetry); captureRetry = undefined; } + try { + (deps.captureRouteDefaults ?? captureChildRouteDefaults)(); + routeDefaultsOwed = false; + captureAttempt = 0; + } catch (error) { + if (!routeDefaultsOwed) warn(`⚠ Child route defaults were not captured; retrying: ${error instanceof Error ? error.message : String(error)}`); + routeDefaultsOwed = true; + if (stopped) return; + captureRetry = setTimeout(() => { + captureRetry = undefined; + if (!stopped && routeDefaultsOwed) captureRouteDefaults(); + }, retryDelay(captureAttempt++)); + (captureRetry as { unref?: () => void }).unref?.(); + } + }; + const bindIfNeeded = (): void => { if (stopped || listener || !startContext) return; let store: LinkStore; @@ -113,6 +144,10 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin setStatus("off", null, null); return; } + // Capture the Children's defaults from the Home's switches and integrations as they stand at + // startup, before any Home-only command can move one. A failure leaves the listener up and is + // retried (see above). + captureRouteDefaults(); const requestedPort = store.listenerPort ?? 0; const sessions = createLinkRelaySessions({ peers, connectionGeneration: linkId => startContext?.connectionGeneration?.(linkId), fingerprints: (keyId, linkId) => { if (!readStoreForAdmission().links.some(link => link.id === linkId && link.apiKeyId === keyId)) return []; @@ -176,6 +211,8 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin const flight = (async () => { if (closeFlight) await closeFlight; if (stopped) return; + // An unbound listener captures in bindIfNeeded; a bound one retries an owed capture here. + if (routeDefaultsOwed && listener) captureRouteDefaults(); bindIfNeeded(); })(); ensureFlight = flight.finally(() => { @@ -187,6 +224,9 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin const close = (): Promise => { if (closeFlight) return closeFlight; + // No link is left to protect: an owed capture is retried at the next bind, not on a timer. + if (captureRetry) { clearTimeout(captureRetry); captureRetry = undefined; } + captureAttempt = 0; const flight = (async () => { if (ensureFlight) await ensureFlight; const current = listener; @@ -231,6 +271,7 @@ export function createLinkListenerLifecycle(deps: LinkListenerDeps = {}): Lin close, async stop() { stopped = true; + if (captureRetry) { clearTimeout(captureRetry); captureRetry = undefined; } releaseFallbackLinkResolver(); // Process shutdown is deliberately forceful, unlike last-link removal. Every // authenticated socket is closed, and the client transport cannot reconnect it. diff --git a/src/server/index/link-management.ts b/src/server/index/link-management.ts index a6b8e3a9c99..bb264d8c5c1 100644 --- a/src/server/index/link-management.ts +++ b/src/server/index/link-management.ts @@ -12,7 +12,9 @@ import type { OcxConfig } from "../../types"; import { handleManagementAPI, type ManagementApiDeps } from "../management-api"; import type { ManagementSessionControl } from "../management-auth"; import { runPassiveRead } from "../../lib/passive-read"; -import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes"; +import { LINK_CODEX_SETTINGS_PATH, LINK_ROUTE_INTENT_PATH, LINK_ROUTE_CATALOG_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes"; +import { projectRouteCatalogFacts } from "../../integrations/route-intent-catalog"; +import { readLinkRouteIntent } from "./route-intent-watch"; import { projectHomeCodexSettings } from "../../link/codex-settings"; import { homeLinkChildParity, LINK_CHILD_PARITY_MAX_BYTES, LINK_CHILD_PARITY_PATH, parseLinkChildParityReport } from "../../link/child-parity"; import { linkStorePath } from "../../link/paths"; @@ -76,9 +78,21 @@ export async function handleLinkManagementRequest( deps: ManagementApiDeps, sessionControl?: ManagementSessionControl, admittedKeyId?: string, + /** Repeats link-key admission for a request that waited; see readLinkRouteIntent. */ + readmit?: () => string | undefined, ): Promise { if (url.pathname === LINK_CHILD_PARITY_PATH) return handleChildParityReport(req, url, admittedKeyId); if (!linkManagementRouteAllowed(url, req)) return notFound(); + if (url.pathname === LINK_ROUTE_CATALOG_PATH) { + if (!admittedKeyId) return refused(403, "link_key_required"); + try { + if (readLinkStore(linkStorePath()).links.filter(link => link.apiKeyId === admittedKeyId).length !== 1) return refused(403, "link_mismatch"); + return Response.json(projectRouteCatalogFacts(config), { headers: { "cache-control": "no-store" } }); + } catch { return refused(503, "route_catalog_unavailable"); } + } + if (url.pathname === LINK_ROUTE_INTENT_PATH) { + return readLinkRouteIntent(req, config, VERSION, admittedKeyId, readmit); + } if (url.pathname === LINK_CODEX_SETTINGS_PATH) { return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } }); } diff --git a/src/server/index/route-intent-watch.ts b/src/server/index/route-intent-watch.ts new file mode 100644 index 00000000000..f68f9f12fed --- /dev/null +++ b/src/server/index/route-intent-watch.ts @@ -0,0 +1,47 @@ +import type { OcxConfig } from "../../types"; +import { routeIntentDigest, ROUTE_INTENT_DIGEST_PATTERN } from "../../link/route-intent"; +import { homeRouteIntentWatch, ROUTE_INTENT_AFTER_HEADER, ROUTE_INTENT_WAKE_HEADER, ROUTE_INTENT_WATCH_HEADER } from "../../link/route-intent-watch"; +import { projectChildRouteIntent } from "../../routing/home-route-intent"; +import { linkStorePath } from "../../link/paths"; +import { readLinkStore } from "../../link/store"; + +function refused(status: number, error: string): Response { + return Response.json({ error }, { status, headers: { "cache-control": "no-store" } }); +} +/** + * Called only after the shared exact GET/no-query/no-upgrade gate. `readmit` repeats the + * listener's key admission against current policy and returns the key id it admits now. + */ +export async function readLinkRouteIntent(req: Request, config: OcxConfig, version: string, admittedKeyId?: string, + readmit?: () => string | undefined): Promise { + if (!admittedKeyId) return refused(403, "link_key_required"); + const watch = req.headers.get(ROUTE_INTENT_WATCH_HEADER); + const after = req.headers.get(ROUTE_INTENT_AFTER_HEADER); + if ((watch !== null && watch !== "1") || (after !== null && (watch !== "1" || !ROUTE_INTENT_DIGEST_PATTERN.test(after)))) { + return refused(400, "invalid_route_intent_watch"); + } + try { + const ownedLink = () => { + const links = readLinkStore(linkStorePath()).links.filter(link => link.apiKeyId === admittedKeyId); + return links.length === 1 ? links[0]!.id : null; + }; + const linkId = ownedLink(); + if (!linkId) return refused(403, "link_mismatch"); + const project = () => projectChildRouteIntent(config, version, linkId); + let document = project(); + let wakeReason: "changed" | "notified" | "timeout" = "changed"; + if (watch === "1" && after === routeIntentDigest(document)) { + const wake = await homeRouteIntentWatch.wait(linkId, req.signal); + if (wake === "cancelled") return refused(499, "route_intent_watch_cancelled"); + if (wake === "busy") return refused(429, "route_intent_watch_busy"); + wakeReason = wake; + // Trust, the key itself and effective policy can all have changed during the wait. + if (readmit && readmit() !== admittedKeyId) return refused(403, "link_key_required"); + if (ownedLink() !== linkId) return refused(403, "link_mismatch"); + document = project(); + } + if (req.signal.aborted) return refused(499, "route_intent_watch_cancelled"); + return Response.json(document, { headers: { "cache-control": "no-store", [ROUTE_INTENT_WATCH_HEADER]: "1", + ...(watch === "1" ? { [ROUTE_INTENT_WAKE_HEADER]: wakeReason } : {}) } }); + } catch { return refused(503, "route_intent_unavailable"); } +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 867228df12b..6abe81aaaa5 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -398,6 +398,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { return withCors(await handleLinkManagementRequest( req, requestUrl, config, requestManagementApiDeps, managementSessionControl, admission.kind === "configured" ? admission.keyId : undefined, + () => { const current = resolveApiAuth(req, linkPolicy()); return current?.kind === "configured" ? current.keyId : undefined; }, ), req, policy); } } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index dc206ce4e2e..d8f4f7df9ca 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -176,6 +176,12 @@ async function handleFallbackRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/route-to")) return null; + const { handleRouteToRoutes } = await import("./management/route-to-routes"); + return handleRouteToRoutes(ctx); +} + /** * Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is * mounted for every dashboard request, so a static import would put the workflow-budget ledger @@ -370,6 +376,7 @@ export async function handleManagementAPI( ?? (await handleLowQuotaRoutesOnDemand(ctx)) ?? (await handleQuotaPoolRoutesOnDemand(ctx)) ?? (await handleFallbackRoutesOnDemand(ctx)) + ?? (await handleRouteToRoutesOnDemand(ctx)) ?? (await handleWorkflowBudgetRoutesOnDemand(ctx)) ?? (await handleCodexAgentRoleRoutesOnDemand(ctx)) ?? (await handleProtocolRoutesOnDemand(ctx)) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index a3f27971255..0e2dccb6648 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -9,6 +9,7 @@ import { ensureManagementClaudeIntercept, interceptStartRefusal, interceptStatus import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state"; import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../../claude/desktop-applied-marker"; import { isPassiveRead } from "../../lib/passive-read"; +import { captureHomeRouteDefaultsBeforeHomeSwitch } from "../../routing/home-route-capture"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname } from "node:path"; @@ -1121,9 +1122,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise { - const result = setIntegrationEnabled("claude-desktop", true); + const result = setHomeIntegrationEnabled("claude-desktop", true); if (result.ok) mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); return result; }); @@ -1212,9 +1213,10 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise { - const result = setIntegrationEnabled("claude-desktop", true); + const result = setHomeIntegrationEnabled("claude-desktop", true); if (result.ok) mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); return result; }); @@ -1841,6 +1843,10 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise RouteClientIntent): Response { + try { + if (unlinkedUnreadableState()) return success; + if (clientId === "aside") recordHomeAsideDecision(durableConfig(ctx.config)); + else if (isRouteClientId(clientId) && intent) recordHomeFileIntegrationDecision(clientId, intent()); + return success; + } catch (error) { + const reason = error instanceof HomeAsideIntentDerivationError + ? "its Aside profiles could not be named by stable userIds" + : `route intent in ${routeIntentStatePath()} could not record it`; + return jsonResponse({ error: `The ${clientId} integration was changed, but ${reason}, so ocx sync may undo it. Inspect it, fix or remove it, then make the change again.`, + code: "route_intent_unavailable", clientId, integrationChanged: true }, 500, ctx.req, ctx.config); + } +} diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 1d95ac15456..2ca717145b7 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -48,6 +48,8 @@ import { setIntegrationMutationFlightTestHook, } from "../../integrations/mutation-flight"; import { jsonResponse } from "../auth-cors"; +import { isRouteClientId, type RouteClientIntent } from "../../link/route-intent"; +import { prepareHomeIntegrationDecision, recordHomeIntegrationDecision } from "./home-integration-decision"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { managementInferencePort, type ManagementContext } from "./context"; import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot } from "./model-rows"; @@ -281,6 +283,33 @@ function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions { }; } +/** + * Which Aside requests are a Home decision, and the profiles each may enable: a toggle of one + * profile or all of them, or a profile restore. Undefined for reads, previews, journal and sync. + */ +async function asideDecisionScope(ctx: ManagementContext, known: unknown): Promise { + // The paths handleAsideProfileRoutes owns: the whole set, the profile collection, one profile, + // and one profile's restore. + const match = /^\/api\/client-integrations\/aside(?:\/profiles(?:\/(0|[1-9][0-9]{0,8})(\/restore)?)?)?$/.exec(ctx.url.pathname); + if (!match) return undefined; + const restore = match[2] !== undefined && ctx.req.method === "POST"; + const toggle = match[2] === undefined && ctx.req.method === "PUT"; + if (!toggle && !restore) return undefined; + const raw = match[1] ?? ctx.url.searchParams.get("profile"); + const id = raw !== null && /^(0|[1-9][0-9]{0,8})$/.test(raw) ? Number(raw) : undefined; + if (restore) return id === undefined ? [] : [id]; + const body = known !== undefined ? known : await readJsonBody({ ...ctx, req: ctx.req.clone() }); + if (!isPlainRecord(body) || body.enabled !== true) return []; + return id === undefined ? "all" : [id]; +} + +/** A file client's Home decision as its ownership record stands after a restore. */ +function ownedIntent(store: IntegrationStateStore, clientId: IntegrationClientId): RouteClientIntent { + const owner = store.readRecords()[clientId]; + return { enabled: Boolean(owner), + ...(owner?.modelNames && (clientId === "hermes" || clientId === "commandcode") ? { modelNames: owner.modelNames } : {}) }; +} + async function buildIntegrationWriteInput( clientId: IntegrationClientId, ctx: ManagementContext, @@ -686,10 +715,12 @@ async function handleJournalDelete(ctx: ManagementContext): Promise { export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { // Aside delegates before the ordinary client handler. Reject unsupported intent before that // delegation can enumerate profiles, load catalogs, or write a different preference. + let asideBody: unknown; if (/^\/api\/client-integrations\/aside\/profiles(?:\/|$)/.test(ctx.url.pathname) && (ctx.req.method === "PUT" || ctx.req.method === "POST")) { const body = await readJsonBody({ ...ctx, req: ctx.req.clone() }); if (body instanceof Response) return body; + asideBody = body; if (isPlainRecord(body)) { const naming = readModelNames(ctx, body, "aside", "apply"); if (naming instanceof Response) return naming; @@ -708,8 +739,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise= 200 && aside.status < 300 ? recordHomeIntegrationDecision(ctx, "aside", aside) : aside; if (url.searchParams.has("profile") && (url.pathname === INTEGRATION_COLLECTION_PATH || url.pathname.startsWith(INTEGRATION_ROUTE_PREFIX)) && !INTEGRATION_HISTORY_PATHS.includes(url.pathname)) { @@ -966,13 +1003,20 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise= 200 && asideRestore.status < 300 + ? recordHomeIntegrationDecision(ctx, "aside", asideRestore) : asideRestore; let restoreClientId: IntegrationClientId | undefined; try { const store = integrationStore(ctx); @@ -1038,7 +1082,9 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise ownedIntent(store, operation.clientId)) : restored; } catch (error) { if (error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError) { return jsonResponse({ @@ -1131,6 +1177,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise ({ enabled: parsed.enabled === true, + ...(modelNames !== undefined && (requestedClient === "hermes" || requestedClient === "commandcode") ? { modelNames } : {}) })) : toggled; } catch (error) { if (error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError) { return jsonResponse({ diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index c157e964fa9..c833c1a7ffd 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -1,3 +1,4 @@ +import { seedRouteDeviceClients } from "../../routing/home-route-intent"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -311,6 +312,9 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom if (current.links.some(link => link.alias === alias && link.direction === "hub-initiated")) throw new Error("link exists"); writeStoreFor(ctx, { ...current, links: [...current.links, record!] }); }); + // An ephemeral ocx-child never reads route intent, so its enrollment saves none: seeding would + // also start ocx sync applying route intent to every Home client. + if (!ephemeralChild) withConfigMutationLockSync(() => seedRouteDeviceClients(ctx.config, ctx.version, id, undefined, readStoreFor(ctx).links.map(link => link.id))); await state.listener.ensureStarted(); if (state.listener.status().state !== "listening") return await failed(fail("listener_unavailable", "The link listener is unavailable.", 503)); await state.supervisor.ensureStarted(); diff --git a/src/server/management/link-route-state.ts b/src/server/management/link-route-state.ts index d58522417c4..42dc39d8490 100644 --- a/src/server/management/link-route-state.ts +++ b/src/server/management/link-route-state.ts @@ -6,6 +6,7 @@ import { clearCompensationFailed, compensationPath, markCompensationFailed } fro import { readLinkStore, writeLinkStore, type LinkStore } from "../../link/store"; import { boundHint, createSshRunner, type SshRunner } from "../../link/ssh-runner"; import { withConfigMutationLockSync } from "../../config/mutation-lock"; +import { reconcileHomeRouteDevices } from "../../routing/home-route-intent"; import type { ManagementContext } from "./context"; import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes"; @@ -181,6 +182,7 @@ export async function compensateNewLink(ctx: ManagementContext, state: LinkRoute if (filtered.links.length !== current.links.length) { writeStoreFor(ctx, filtered); } + reconcileHomeRouteDevices(filtered.links.map(link => link.id)); return filtered; }); clearCompensationFailed(record.id, compensationPath()); diff --git a/src/server/management/link-routes.ts b/src/server/management/link-routes.ts index 116a498c725..fc90a0961d6 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -1,3 +1,5 @@ +import { isRouteClientId, ROUTE_CLIENT_IDS } from "../../link/route-intent"; +import { seedRouteDeviceClients, reconcileHomeRouteDevices } from "../../routing/home-route-intent"; import { probe, confirmHost, apply } from "./link-enrollment"; import { fail, pairedSession, auth, port, isRecord, exactBody, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, issueKey, revokeKeyIdempotent, compensateNewLink, type LinkRouteState } from "./link-route-state"; export type { LinkRouteState, PendingLinkHost, ConfirmedLinkHost, LinkRouteListener } from "./link-route-state"; @@ -75,9 +77,12 @@ async function candidates(ctx: ManagementContext): Promise { } async function issue(ctx: ManagementContext): Promise { - const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "tunnelPort"]); + const raw = await readManagementJsonBodyOr(ctx.req, null); + const body = exactBody(raw, isRecord(raw) && Object.hasOwn(raw, "selectedClients") ? ["alias", "tunnelPort", "selectedClients"] : ["alias", "tunnelPort"]); if (!body || typeof body.alias !== "string" || !isLinkPort(body.tunnelPort)) return fail("invalid_body", "alias and tunnelPort are required.", 400); try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); } + const selection = body.selectedClients; + if (selection !== undefined && (!Array.isArray(selection) || selection.length === 0 || selection.length > ROUTE_CLIENT_IDS.length || !selection.every(isRouteClientId) || new Set(selection).size !== selection.length)) return fail("invalid_body", "selectedClients must contain unique supported clients", 400); let issued: IssuedApiKey; try { issued = issueKey(ctx, `link:${body.alias}`); } catch { return fail("key_issue_failed", "The link key could not be issued.", 503); } @@ -105,6 +110,7 @@ async function issue(ctx: ManagementContext): Promise { const compensation = await compensateNewLink(ctx, state, record); return compensation ?? fail("link_exists", "A link for this alias already exists.", 409); } + withConfigMutationLockSync(() => seedRouteDeviceClients(ctx.config, ctx.version, id, selection as import("../../link/route-intent").RouteClientId[] | undefined, readStoreFor(ctx).links.map(link => link.id))); await state.listener.ensureStarted(); if (state.listener.status().state !== "listening") { failureCode = "listener_unavailable"; @@ -255,6 +261,7 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string) const latest = readStoreFor(ctx); const filtered = { ...latest, links: latest.links.filter(link => link.id !== id) }; if (filtered.links.length !== latest.links.length) writeStoreFor(ctx, filtered); + reconcileHomeRouteDevices(filtered.links.map(link => link.id)); return filtered; }); clearCompensationFailed(id, compensationPath()); diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index be01108fbf8..6dd26a34bde 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -6,6 +6,12 @@ import { mirrorDesiredEnabledOntoSnapshot } from "./claude-desktop-config-writer import { ensureManagementClaudeIntercept, interceptStartRefusal } from "./claude-intercept-routes"; import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state"; import { commitClaudeCodeBlock } from "../../claude/claude-code-block"; +import { HomeAsideIntentDerivationError, initializeHomeRouteIntentDefaults } from "../../routing/home-route-intent"; +import { homeHasLinks } from "../../routing/home-route-capture"; +import { recordHomeNativeDecision, RouteIntentStateUnreadableError } from "../../routing/route-intent-state"; +import { withDurableRouteNativeSettings } from "../../routing/route-to-live-settings"; +import { withLinkApply, LinkApplyBusyError } from "../../client/link-home"; +import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config/mutation-lock"; /** * Toggle routes for the integrations that are NOT file-merged clients. * @@ -62,6 +68,8 @@ import { managementInferencePort, type ManagementContext } from "./context"; export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop"; +type NativeDecisionContext = ManagementContext & { decisionProgress?: { desiredEnabled?: boolean } }; + /** Every reason this module can decline, in one place (audit r3 #6). */ export type NativeRefusalReason = | "not_installed" @@ -115,7 +123,12 @@ export interface NativeRefusalEnvelope { /** Present on every post-commit refusal; absent only for pre-commit refusals. */ desiredEnabled?: boolean; residualPaths?: string[]; - diagnostic?: InterceptSecretDiagnostic; + diagnostic?: InterceptSecretDiagnostic | NativeConfigDiagnostic; +} + +interface NativeConfigDiagnostic { + stage: "config"; + cause: "SQLITE_NOTADB" | "SQLITE_CORRUPT" | "SQLITE_CANTOPEN" | "SQLITE_IOERR" | "EACCES" | "EPERM" | "ENOENT" | "EIO" | "unknown"; } export type NativePostCommitRefusalEnvelope = Omit & { @@ -127,7 +140,7 @@ function refusal( clientId: NativeIntegrationClientId, reason: NativeRefusalReason, message: string, - extra: Pick = {}, + extra: Pick = {}, ): Response { return jsonResponse({ error: status >= 500 ? "native integration change failed" : "native integration change refused", @@ -274,12 +287,66 @@ function grokStatus(config: ManagementContext["config"]): NativeStatus { * whole class to a retryable 409 would tell the user to retry a lock file they * cannot open, which fails identically forever (audit r8 #2). */ -function isLockContention(error: unknown): boolean { +function isConfigMutationContention(error: unknown): boolean { if (!error || typeof error !== "object") return false; const cause = (error as { cause?: { code?: unknown } }).cause; return cause?.code === "SQLITE_BUSY"; } +function configFailureDiagnostic(error: unknown): NativeConfigDiagnostic { + const cause = error instanceof ConfigMutationLockError || error instanceof ConfigWritePublishedError ? error.cause : error; + const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined; + const allowed: readonly NativeConfigDiagnostic["cause"][] = ["SQLITE_NOTADB", "SQLITE_CORRUPT", "SQLITE_CANTOPEN", "SQLITE_IOERR", "EACCES", "EPERM", "ENOENT", "EIO"]; + return { stage: "config", cause: allowed.find(value => value === code) ?? "unknown" }; +} + +/** ACL admission and Home apply exclusion precede this synchronous C section. */ +async function commitNativeDecision( + ctx: NativeDecisionContext, clientId: NativeIntegrationClientId, enabled: boolean, + persist: () => boolean | Response, +): Promise { + let published = false; + try { + return await settleConfigRoot(configRoot(), () => withConfigMutationLockSync(() => { + // Capture the Children's defaults from the durable switches before this one changes, under + // the rule every Home-only switch writer follows: only once the Home has a link. Another + // process may have changed the switches since this server loaded its snapshot. A Home with + // no link has no Child to protect, so the toggle changes only the switch. + const linked = homeHasLinks(); + if (linked) initializeHomeRouteIntentDefaults(withDurableRouteNativeSettings(ctx.config), ctx.version); + const result = persist(); + if (result instanceof Response) return result; + published = result; + if (published && ctx.decisionProgress) ctx.decisionProgress.desiredEnabled = enabled; + // The switch just written is the Home's decision; route intent keeps no Home entry for it. + if (linked) recordHomeNativeDecision(clientId); + if (ctx.decisionProgress) ctx.decisionProgress.desiredEnabled = enabled; + return null; + })); + } catch (error) { + if (error instanceof WindowsSecretAclSettleTimeoutError && !published) { + const firstParty = clientId === "claude-desktop" && resolveClaudeDesktopApplyMode(ctx.config, observeClaudeDesktopMode(ctx.config)) === "first-party"; + return refusal(500, clientId, "write_failed", "Config ACL settlement timed out. Nothing was written; retry after it settles.", + firstParty ? { diagnostic: { stage: "ca", cause: "ETIMEDOUT" } } : {}); + } + if (error instanceof HomeAsideIntentDerivationError) return refusal(409, clientId, "metadata_unreadable", + "Existing Aside ownership could not be resolved. Repair the Aside accounts.json manifest and stable userId selection, or choose an explicit Home Aside intent with ocx route to aside on or ocx route to aside off, then retry. Existing policy was preserved."); + if (error instanceof RouteIntentStateUnreadableError && !published) return refusal(500, clientId, "metadata_unreadable", error.message); + if (published || error instanceof ConfigWritePublishedError) { + if (clientId === "claude-desktop" && error instanceof ConfigWritePublishedError) mirrorDesiredEnabledOntoSnapshot(ctx.config, clientId, enabled); + const busy = isConfigLockError(error) && isConfigMutationContention(error); + return postCommitRefusal(busy ? 409 : 500, clientId, busy ? "config_busy" : "write_failed", + "The native switch was saved, but Home route intent or configuration bookkeeping did not complete. Repair configuration storage if needed, then retry the same switch to reconcile Home intent and apply it.", + { desiredEnabled: enabled, diagnostic: configFailureDiagnostic(error) }); + } + const busy = isConfigLockError(error) && isConfigMutationContention(error); + return refusal(busy ? 409 : 500, clientId, busy ? "config_busy" : "write_failed", + busy ? "Another process is saving the configuration right now. Nothing was written; retry in a moment." + : "The native decision could not be saved. Inspect and repair configuration storage, then retry.", + busy ? {} : { diagnostic: configFailureDiagnostic(error) }); + } +} + function isConfigLockError(error: unknown): boolean { return !!error && typeof error === "object" && (error as { code?: unknown }).code === "CONFIG_MUTATION_LOCK_UNAVAILABLE"; @@ -334,7 +401,7 @@ let codexToggleFlight: Promise | null = null; * reads the persisted intent and converges — whereas converging first and dying * would leave artifacts the next start silently undoes. */ -async function handleCodexToggle(ctx: ManagementContext): Promise { +async function handleCodexToggle(ctx: NativeDecisionContext): Promise { const { req } = ctx; if (codexToggleFlight) { return refusal(409, "codex", "config_busy", @@ -354,21 +421,27 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { const enabled = body.enabled; const { setCodexIntegrationEnabled } = await import("../../codex/desired-state"); - const persisted = setCodexIntegrationEnabled(enabled); + let decisionResult!: ReturnType; /* * `missing` does not block the switch — see the Grok route for the reasoning. * A user with no config file yet still gets the artifact change; what they * lose is durability across a restart, and the envelope says so rather than * implying the switch failed. */ - if (!persisted.ok && persisted.reason !== "missing") { - return refusal( - persisted.retryable ? 409 : 500, - "codex", - persisted.retryable ? "config_busy" : "write_failed", - persisted.message, - ); - } + const decisionRefusal = await commitNativeDecision(ctx, "codex", enabled, () => { + decisionResult = setCodexIntegrationEnabled(enabled); + if (!decisionResult.ok && decisionResult.reason !== "missing") { + return refusal( + decisionResult.retryable ? 409 : 500, + "codex", + decisionResult.retryable ? "config_busy" : "write_failed", + decisionResult.message, + ); + } + return decisionResult.ok; + }); + if (decisionRefusal) return decisionRefusal; + const persisted = decisionResult; const durable = persisted.ok; if (enabled) { @@ -435,7 +508,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { } } -async function handleGrokToggle(ctx: ManagementContext): Promise { +async function handleGrokToggle(ctx: NativeDecisionContext): Promise { const { req, config, deps } = ctx; /* * The guard stands BEFORE the first await, or two concurrent PUTs could both @@ -482,7 +555,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { * on, where the other order leaves artifacts the next start undoes. */ const { setGrokIntegrationEnabled } = await import("../../codex/desired-state"); - const persisted = setGrokIntegrationEnabled(enabled); + let decisionResult!: ReturnType; /* * `missing` does NOT block the toggle here. * @@ -494,14 +567,20 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { * on the envelope says. `conflict` and `invalid` are different: another * writer won, or the file is malformed and must not be overwritten. */ - if (!persisted.ok && persisted.reason !== "missing") { - return refusal( - persisted.retryable ? 409 : 500, - "grok", - persisted.retryable ? "config_busy" : "write_failed", - persisted.message, - ); - } + const decisionRefusal = await commitNativeDecision(ctx, "grok", enabled, () => { + decisionResult = setGrokIntegrationEnabled(enabled); + if (!decisionResult.ok && decisionResult.reason !== "missing") { + return refusal( + decisionResult.retryable ? 409 : 500, + "grok", + decisionResult.retryable ? "config_busy" : "write_failed", + decisionResult.message, + ); + } + return decisionResult.ok; + }); + if (decisionRefusal) return decisionRefusal; + const persisted = decisionResult; const durable = persisted.ok; const desiredEnabled = durable ? loadConfig().clientIntegrations?.grok !== false : enabled; @@ -701,7 +780,7 @@ async function persistDesktopModeMarker(config: ManagementContext["config"], des let claudeDesktopToggleFlight: Promise | null = null; -async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { +async function handleClaudeDesktopToggle(ctx: NativeDecisionContext): Promise { if (claudeDesktopToggleFlight) { return refusal(409, "claude-desktop", "config_busy", "Another Claude Desktop change is already in flight. Nothing was written — try again in a moment."); @@ -719,17 +798,15 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { + const decisionRefusal = await commitNativeDecision(ctx, "claude-desktop", enabled, () => { const result = setIntegrationEnabled("claude-desktop", enabled); if (result.ok) { committedDesiredEnabled = enabled; mirrorDesiredEnabledOntoSnapshot(ctx.config, "claude-desktop", enabled); } - return result; + return result.ok ? true : refusal(result.retryable ? 409 : 500, "claude-desktop", result.retryable ? "config_busy" : "write_failed", result.message); }); - if (!persisted.ok) { - return refusal(persisted.retryable ? 409 : 500, "claude-desktop", persisted.retryable ? "config_busy" : "write_failed", persisted.message); - } + if (decisionRefusal) return decisionRefusal; const desiredEnabled = loadConfig().clientIntegrations?.["claude-desktop"] !== false; committedDesiredEnabled = desiredEnabled; const current = loadConfig(); @@ -922,7 +999,7 @@ function persistedIntentConfig(snapshot: ManagementContext["config"]): Managemen } } -export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Promise { +async function handleNativeIntegrationRoutesUnlocked(ctx: NativeDecisionContext): Promise { const { req, url, config, deps } = ctx; if (url.pathname === "/api/native-integrations" && req.method === "GET") { @@ -949,53 +1026,51 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro } const enabled = body.enabled; - if (claudeCodeEnabled(config) === enabled) { - // Re-run the same reconcile on a repeated OFF: definitions left behind by an older - // version that never pruned on disable are removed the next time the user says off. + let changed = false; + const decisionRefusal = await commitNativeDecision(ctx, "claude", enabled, () => { + changed = claudeCodeEnabled(config) !== enabled; + if (!changed) return true; + + /* + * Same block writer as PUT /api/claude-code: it stamps the migration sentinel. + * Toggling Claude ON is one of the two ways a block gets CREATED, so without the + * sentinel the next startServer would silently convert a user's Auto auth mode into + * a sticky manual subscription — a failure that surfaces nowhere near this route. + */ + // Kept so a failed save leaves the shared in-memory config matching disk: otherwise a + // retry on the same object would read the unsaved value, take the repeated-OFF branch + // and prune agents while the persisted integration is still on. + const previousBlock = config.claudeCode === undefined ? undefined : structuredClone(config.claudeCode); + commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled }); + + /* + * `deps.` first: ManagementApiDeps carries this seam so route tests with an + * in-memory fixture cannot overwrite the developer's real OPENCODEX_HOME. + */ + const persist = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + try { + persist(config); + } catch (error) { + // A published write already put the new value on disk; only an unpublished one rolls back. + if (!(error instanceof ConfigWritePublishedError)) { + if (previousBlock === undefined) delete config.claudeCode; + else config.claudeCode = previousBlock; + } + throw error; + } + return true; + }); + if (decisionRefusal) return decisionRefusal; + if (!changed) { + // Repeated OFF also prunes definitions left by older versions. if (!enabled) await ctx.syncClaudeAgentDefsBestEffort?.(); return jsonResponse({ ok: true, clientId: "claude", changed: false, - state: enabled ? "current" : "absent", - desiredEnabled: enabled, + state: enabled ? "current" : "absent", desiredEnabled: enabled, message: enabled ? "Claude inbound is already on" : "Claude inbound is already off", } satisfies NativeToggleEnvelope); } - /* - * Same block writer as PUT /api/claude-code: it stamps the migration sentinel. - * Toggling Claude ON is one of the two ways a block gets CREATED, so without the - * sentinel the next startServer would silently convert a user's Auto auth mode into - * a sticky manual subscription — a failure that surfaces nowhere near this route. - */ - // Kept so a failed save leaves the shared in-memory config matching disk: otherwise a - // retry on the same object would read the unsaved value, take the repeated-OFF branch - // and prune agents while the persisted integration is still on. - const previousBlock = config.claudeCode === undefined ? undefined : structuredClone(config.claudeCode); - commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled }); - - /* - * `deps.` first: ManagementApiDeps carries this seam so route tests with an - * in-memory fixture cannot overwrite the developer's real OPENCODEX_HOME. - */ - const persist = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; - try { - persist(config); - } catch (error) { - // A published write already put the new value on disk; only an unpublished one rolls back. - if (!(error instanceof ConfigWritePublishedError)) { - if (previousBlock === undefined) delete config.claudeCode; - else config.claudeCode = previousBlock; - } - if (isConfigLockError(error)) { - return isLockContention(error) - ? refusal(409, "claude", "config_busy", - "Another process is saving the configuration right now. Try again in a moment.") - : refusal(500, "claude", "write_failed", - `The configuration lock could not be acquired: ${error instanceof Error ? error.message : String(error)}`); - } - throw error; - } - /* * The generated ~/.claude/agents/ocx-*.md definitions route through this proxy; a * native Claude Code session that still loads them after OFF follows their @@ -1034,3 +1109,34 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro return null; } + +export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Promise { + if (ctx.req.method !== "PUT" || !/^\/api\/native-integrations\/(claude|claude-desktop|codex|grok)$/.test(ctx.url.pathname)) { + return handleNativeIntegrationRoutesUnlocked(ctx); + } + if (ctx.config.runtimeRole === "client") return jsonResponse({ error: "Route decisions are owned by the Home", code: "link_child_read_only" }, 403); + const decisionProgress: { desiredEnabled?: boolean } = {}; + try { return await settleConfigRoot(configRoot(), () => withLinkApply(() => handleNativeIntegrationRoutesUnlocked({ ...ctx, decisionProgress }))); } + catch (error) { + const clientId = ctx.url.pathname.split("/").at(-1) as NativeIntegrationClientId; + if (decisionProgress.desiredEnabled !== undefined) { + const busy = error instanceof LinkApplyBusyError || (isConfigLockError(error) && isConfigMutationContention(error)); + const firstPartyTimeout = error instanceof WindowsSecretAclSettleTimeoutError && clientId === "claude-desktop" + && resolveClaudeDesktopApplyMode(ctx.config, observeClaudeDesktopMode(ctx.config)) === "first-party"; + return postCommitRefusal(busy ? 409 : 500, clientId, busy ? "config_busy" : "write_failed", + "The native decision was saved, but applying it did not complete. Repair configuration storage if needed, then retry the same switch to reconcile and apply it.", + { desiredEnabled: decisionProgress.desiredEnabled, + diagnostic: firstPartyTimeout ? { stage: "ca", cause: "ETIMEDOUT" } : configFailureDiagnostic(error) }); + } + if (error instanceof LinkApplyBusyError || (isConfigLockError(error) && isConfigMutationContention(error))) return refusal(409, clientId, "config_busy", "Another Home apply or config mutation is running. Nothing was written; retry in a moment."); + if (isConfigLockError(error)) return refusal(500, clientId, "write_failed", + "Configuration storage is unavailable. Inspect and repair it before retrying.", { diagnostic: configFailureDiagnostic(error) }); + if (error instanceof WindowsSecretAclSettleTimeoutError) { + const clientId = ctx.url.pathname.split("/").at(-1) as NativeIntegrationClientId; + const firstParty = clientId === "claude-desktop" && resolveClaudeDesktopApplyMode(ctx.config, observeClaudeDesktopMode(ctx.config)) === "first-party"; + return refusal(500, clientId, "write_failed", "Config ACL settlement timed out. Nothing was written; retry after it settles.", + firstParty ? { diagnostic: { stage: "ca", cause: "ETIMEDOUT" } } : {}); + } + throw error; + } +} diff --git a/src/server/management/protocol-routes.ts b/src/server/management/protocol-routes.ts index 53e025eed8d..2eca1eea959 100644 --- a/src/server/management/protocol-routes.ts +++ b/src/server/management/protocol-routes.ts @@ -22,6 +22,7 @@ import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSetti import type { OcxConfig } from "../../types"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; +import { captureHomeRouteDefaultsBeforeHomeSwitch } from "../../routing/home-route-capture"; import { applyProtocolSettingsPatch, parseProtocolSettingsPatch, @@ -115,6 +116,12 @@ async function patchProtocolSettings(ctx: ManagementContext): Promise // `deps.` first: route tests with an in-memory fixture must never write the real config. const persist = ctx.deps.saveConfigPreservingClaudeCode ?? (await import("../../config")).saveConfigPreservingClaudeCode; + // Closing Messages turns Claude Code off on this Home alone: freeze what Children inherit first. + const captureRefused = applied.claudeCodeChanged ? captureHomeRouteDefaultsBeforeHomeSwitch() : null; + if (captureRefused) { + restoreProtocolSettings(config, snapshot); + return jsonResponse({ error: { code: "route_intent_unavailable", message: captureRefused.message } }, captureRefused.retryable ? 409 : 500, req, config); + } try { persist(config); } catch (error) { diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 6655a587d94..318b80d6eb5 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -383,6 +383,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/provider-quotas", module: "server/management/provider-routes", mutates: false }, { method: "GET", path: "/api/quota-pool", module: "server/management/quota-pool-routes", mutates: false, exempt: { reason: "deferred-verb", why: "The ocx.quota-pool/1 read is a machine contract GOAT consumes over management and the link relay; the CLI shows the same caches through ocx provider quota and ocx account.", owner: "ocx-1 quota pool", ownerDoc: "structure/quota-pool.md" } }, { method: "GET", path: "/api/fallback", module: "server/management/fallback-routes", mutates: false }, + { method: "GET", path: "/api/route-to", module: "server/management/route-to-routes", mutates: false }, + { method: "PUT", path: "/api/route-to", module: "server/management/route-to-routes", mutates: true }, + { method: "POST", path: "/api/route-to/sync", module: "server/management/route-to-routes", mutates: true }, { method: "PUT", path: "/api/fallback", module: "server/management/fallback-routes", mutates: true }, { method: "GET", path: "/api/provider-request-pacing", module: "server/management/provider-key-pacing-routes", mutates: false }, { method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false }, diff --git a/src/server/management/route-to-routes.ts b/src/server/management/route-to-routes.ts new file mode 100644 index 00000000000..654617b6107 --- /dev/null +++ b/src/server/management/route-to-routes.ts @@ -0,0 +1,235 @@ +import type { OcxConfig } from "../../types"; +/** Home-owned route decisions. This management surface is never added to the link relay. */ +import { jsonResponse } from "../auth-cors"; +import { managementInferencePort, type ManagementContext } from "./context"; +import { readManagementJsonBody, managementBodyTooLargeResponse } from "./body"; +import { readLinkStore, type LinkRecord } from "../../link/store"; +import { linkStorePath } from "../../link/paths"; +import { homeLinkChildParity } from "../../link/child-parity"; +import { homeRouteIntentWatch } from "../../link/route-intent-watch"; +import { ROUTE_CLIENT_IDS, isHomeNativeRouteClient, parseRouteClientIntent, type HomeNativeRouteClientId, type RouteClientId, type RouteClientIntent, type RouteClientOutcome } from "../../link/route-intent"; +import { clearRouteClientIntent, recordHomeNativeDecision, RouteIntentStateUnreadableError, setRouteClientIntent } from "../../routing/route-intent-state"; +import { initializeHomeRouteIntentDefaults, projectChildRouteIntent, reconcileHomeRouteDevices } from "../../routing/home-route-intent"; +import { homeHasLinks } from "../../routing/home-route-capture"; +import { EPHEMERAL_NO_ROUTE_INTENT, routeToDeviceStatus, unsupportedRouteToDevice, type RouteToStatus, type RouteToSyncRow } from "../../routing/route-to-status"; +import { refreshRouteNativeSettings, withDurableRouteNativeSettings } from "../../routing/route-to-live-settings"; +import { withLinkApply, LinkApplyBusyError } from "../../client/link-home"; +import { withConfigMutationLockSync } from "../../config/mutation-lock"; + +export interface RouteToRouteDeps { + applyHome?: (ctx: ManagementContext, overwriteConflict: boolean, overwriteClient?: RouteClientId) => Promise; + /** Isolated transaction test seam; production uses the shared local materializer. */ + materializeHome?: (config: OcxConfig, version: string, overwriteConflict: boolean, overwriteClient?: RouteClientId) => Promise; + syncChild?: (link: LinkRecord, ctx: ManagementContext, overwriteConflict: boolean, overwriteClient?: RouteClientId) => Promise>; +} + +function devices(ctx: ManagementContext): LinkRecord[] { + // Unlike an empty store, unreadable trust metadata must never select another device. + return (ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath())).links; +} + +/** An ephemeral ocx-child never reads route intent: nothing addresses it as a route device. */ +function ephemeral(link: LinkRecord | undefined): boolean { + return link?.childKind === "ephemeral"; +} + +function resolveDevice(value: string | undefined, links: LinkRecord[]): string | undefined { + if (value === undefined) return undefined; + if (value === "home" || value === "this") return "home"; + const exact = links.find(link => link.id === value); + if (exact) return exact.id; + const matches = links.filter(link => link.alias === value); + if (matches.length > 1) throw new Error("ambiguous_device"); + if (!matches.length) throw new Error("unknown_device"); + return matches[0]!.id; +} + +async function view(ctx: ManagementContext, selected?: string): Promise { + const links = devices(ctx); + const selection = selected === "all" ? "all" : resolveDevice(selected ?? "home", links); + const rows: RouteToStatus["devices"] = []; + if (selection === "all" || selection === "home") { + const { readHomeRouteToStatus } = await import("../../routing/route-to-local"); + rows.push(await readHomeRouteToStatus(ctx.config, ctx.version)); + } + for (const link of links) { + if (selection !== "all" && selection !== link.id) continue; + if (ephemeral(link)) { rows.push(unsupportedRouteToDevice(link.id, link.alias)); continue; } + const peer = homeLinkChildParity.get(link.id); + rows.push(routeToDeviceStatus(link.id, link.alias, projectChildRouteIntent(ctx.config, ctx.version, link.id), + peer.routeClientOutcomes ?? [], peer.appliedRouteIntentDigest ?? null)); + } + return { schema: "ocx.route-to/1", devices: rows }; +} + +async function applyHome(ctx: ManagementContext, overwriteConflict: boolean, deps: RouteToRouteDeps, overwriteClient?: RouteClientId, commit?: () => void, + onlyClient?: RouteClientId, onlyIntent?: () => RouteClientIntent | undefined): Promise { + if (deps.applyHome) return withLinkApply(async () => { commit?.(); return deps.applyHome!(ctx, overwriteConflict, overwriteClient); }); + const { syncHomeRouteTo } = await import("../../routing/route-to-local"); + const local = { ...ctx.config, port: managementInferencePort(ctx) }; + const outcomes = deps.materializeHome + ? await withLinkApply(async () => { commit?.(); return deps.materializeHome!(local, ctx.version, overwriteConflict, overwriteClient); }) + : await syncHomeRouteTo(local, ctx.version, overwriteConflict, overwriteClient, commit, { onlyClient, onlyIntent }); + refreshRouteNativeSettings(ctx.config); + return outcomes; +} + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** The Home's durable native switch for a client (absent is on), as another process may have left it. */ +function durableHomeSwitch(config: OcxConfig, client: HomeNativeRouteClientId): boolean { + const durable = withDurableRouteNativeSettings(config); + return client === "claude" ? durable.claudeCode?.enabled !== false : durable.clientIntegrations?.[client] !== false; +} +function parseWrite(value: unknown): { client: RouteClientId; action: "on" | "off" | "inherit"; device?: string; intent: RouteClientIntent; overwriteConflict: boolean } | null { + if (!record(value) || Object.keys(value).some(key => !["client", "action", "device", "accounts", "modelNames", "overwriteConflict"].includes(key))) return null; + if (!(ROUTE_CLIENT_IDS as readonly unknown[]).includes(value.client) || typeof value.action !== "string" || !["on", "off", "inherit"].includes(value.action)) return null; + if (value.device !== undefined && (typeof value.device !== "string" || !value.device.trim())) return null; + if (value.overwriteConflict !== undefined && typeof value.overwriteConflict !== "boolean") return null; + if (value.modelNames !== undefined && value.modelNames !== "alias" && value.modelNames !== "routed") return null; + if (value.modelNames !== undefined && value.client !== "hermes" && value.client !== "commandcode") return null; + if (value.action === "inherit" && (!value.device || value.accounts !== undefined || value.modelNames !== undefined || value.overwriteConflict !== undefined)) return null; + if (value.action === "off" && value.overwriteConflict === true) return null; + const intent = parseRouteClientIntent({ enabled: value.action === "on", ...(value.accounts === undefined ? {} : { accounts: value.accounts }), + ...(value.modelNames === undefined ? {} : { modelNames: value.modelNames }) }, value.client as RouteClientId); + if (!intent) return null; + return { client: value.client as RouteClientId, action: value.action as "on" | "off" | "inherit", + ...(value.device === undefined ? {} : { device: value.device as string }), overwriteConflict: value.overwriteConflict === true, + intent }; +} + +/** Bounded body parsing and one device resolution precede every persisted write. */ +export async function handleRouteToRoutes(ctx: ManagementContext, deps: RouteToRouteDeps = {}): Promise { + const { url, req, config } = ctx; + if (!["/api/route-to", "/api/route-to/sync"].includes(url.pathname)) return null; + const reply = (body: unknown, status = 200): Response => { + const response = jsonResponse(body, status, req, config); + response.headers.set("cache-control", "no-store"); + return response; + }; + const unsupported = (link: LinkRecord): Response => reply({ error: `${link.alias} runs the ephemeral ocx-child, which never reads route intent, so nothing was changed. ocx sync refreshes its Home catalog.`, + code: "route_intent_unsupported", reason: EPHEMERAL_NO_ROUTE_INTENT, device: link.id }, 409); + if (config.runtimeRole === "client") return reply({ error: "Route-to decisions are owned by the Home", code: "link_child_read_only" }, 403); + let desiredCommitted = false; + // A Home native decision is its switch: what the durable switch read before this write. + let nativeBefore: { client: HomeNativeRouteClientId; enabled: boolean } | undefined; + try { + if (url.pathname === "/api/route-to" && req.method === "GET") { + if ([...url.searchParams.keys()].some(key => key !== "device") || url.searchParams.getAll("device").length > 1) return reply({ error: "invalid_request" }, 400); + return reply(await view(ctx, url.searchParams.get("device") ?? undefined)); + } + if (url.pathname === "/api/route-to") { + if (req.method !== "PUT") return reply({ error: "method_not_allowed" }, 405); + } + if (url.pathname === "/api/route-to/sync") { + if (req.method !== "POST") return reply({ error: "method_not_allowed" }, 405); + } + const sync = url.pathname.endsWith("/sync"); + let body: unknown; + try { body = await readManagementJsonBody(req); } + catch (error) { return managementBodyTooLargeResponse(error, req, config) ?? reply({ error: "invalid_request" }, 400); } + if (sync) { + if (!record(body) || Object.keys(body).some(key => !["device", "overwriteConflict"].includes(key)) + || (body.device !== undefined && (typeof body.device !== "string" || !body.device.trim())) + || (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean")) return reply({ error: "invalid_request" }, 400); + const links = devices(ctx); + const selected = body.device === "all" ? "all" : resolveDevice(body.device as string | undefined ?? "home", links); + const only = links.find(link => link.id === selected); + if (ephemeral(only)) return unsupported(only!); + if (selected === "all" || selected === "home") await applyHome(ctx, body.overwriteConflict === true, deps); + const syncs: RouteToSyncRow[] = []; + for (const link of links) { + if (selected !== "all" && selected !== link.id) continue; + if (ephemeral(link)) { syncs.push({ id: link.id, state: "unsupported", reason: EPHEMERAL_NO_ROUTE_INTENT }); continue; } + const run = deps.syncChild ?? (await import("../../routing/route-to-sync-child")).syncRouteToChild; + syncs.push({ id: link.id, ...await run(link, ctx, body.overwriteConflict === true) }); + } + return reply({ ...await view(ctx, body.device as string | undefined), syncs }, syncs.some(row => row.state === "failed") ? 502 : 200); + } + const write = parseWrite(body); + if (!write) return reply({ error: "invalid_request" }, 400); + const selected = resolveDevice(write.device, devices(ctx)); + const target = devices(ctx).find(link => link.id === selected); + if (ephemeral(target)) return unsupported(target!); + // On the Home itself a native client's decision is its native switch, written by the apply below. + const homeNative = selected === "home" && isHomeNativeRouteClient(write.client) ? write.client : undefined; + const switchBefore = homeNative ? durableHomeSwitch(ctx.config, homeNative) : undefined; + if (homeNative) nativeBefore = { client: homeNative, enabled: switchBefore! }; + const commit = () => withConfigMutationLockSync(() => { + // A Home native decision on a Home with no link is only its switch: like every Home-only + // switch writer there, it leaves route-intent.json alone. Until defaults are saved a Home + // inherit takes the builtin. + const routeIntentUntouched = homeNative !== undefined && !homeHasLinks(); + if (!routeIntentUntouched) { + reconcileHomeRouteDevices(devices(ctx).map(link => link.id)); + // A Home-only decision captures the Children's native defaults only once the Home has a link. + initializeHomeRouteIntentDefaults(withDurableRouteNativeSettings(ctx.config), ctx.version, selected === undefined ? write.client : undefined, + { whenLinked: selected === "home" }); + } + if (homeNative) { if (!routeIntentUntouched) recordHomeNativeDecision(homeNative); } + else if (write.action === "inherit") clearRouteClientIntent(write.client, selected!); + else setRouteClientIntent(write.client, write.intent, selected); + // A Home native decision is committed only once the apply writes its switch (below). + if (!homeNative) desiredCommitted = true; + // Desired state remains committed if subsequent reconciliation fails. + if (selected === undefined) for (const link of devices(ctx)) { if (!ephemeral(link)) homeRouteIntentWatch.notify(link.id); } + else if (selected !== "home") homeRouteIntentWatch.notify(selected); + }); + // A default is also the Home's desired state. A device write waits for that Child's tick. + // A Home inherit for a native client sets its switch to the default Children inherit. + let decided: RouteClientOutcome | undefined; + if (selected === undefined || selected === "home") { + decided = (await applyHome(ctx, write.overwriteConflict, deps, write.client, commit, write.client, + homeNative && write.action !== "inherit" ? () => write.intent : undefined)).find(row => row.client === write.client); + } else commit(); + if (selected && selected !== "home" && write.overwriteConflict) { + const link = devices(ctx).find(row => row.id === selected)!; + const run = deps.syncChild ?? (await import("../../routing/route-to-sync-child")).syncRouteToChild; + const result = await run(link, ctx, true, write.client); + return reply({ ...await view(ctx, write.device), syncs: [{ id: selected, ...result }] }, result.state === "failed" ? 502 : 200); + } + const status = await view(ctx, write.device); + if (decided?.outcome === "conflict" || decided?.outcome === "failed") { + // A refused or failed native apply leaves the Home switch, and so the rebuilt row, as it was. + // Report this apply's own outcome rather than the rebuilt row's stale reading of it. + const row = status.devices.find(device => device.id === "home")?.clients.find(entry => entry.client === write.client); + if (row) row.applied = decided.outcome; + const detail = decided.message ? `: ${decided.message}` : ""; + // A Home native decision is its switch. Most refusals come after the apply saved it (an + // external Codex provider, an incomplete Grok block, a changed Desktop profile), so the durable + // switch, not the outcome, says whether anything was saved. First-party Desktop refuses first. + if (homeNative) { + const saved = durableHomeSwitch(ctx.config, homeNative) !== switchBefore; + return reply({ ...status, error: saved + ? `The Home's ${write.client} switch was saved, but applying it ended in ${decided.outcome}${detail}` + : `The Home did not apply ${write.client} (${decided.outcome})${detail}`, + code: "route_apply_unresolved", desiredCommitted: saved }, 409); + } + // A default or a non-native devices.home override is route policy saved before the apply. + return reply({ ...status, error: `${desiredCommitted ? "Route policy was saved, but the" : "The"} Home did not apply ${write.client} (${decided.outcome})${detail}`, + code: "route_apply_unresolved", desiredCommitted }, 409); + } + return reply(status); + } catch (error) { + if (error instanceof LinkApplyBusyError) return reply({ error: desiredCommitted + ? "Route policy was committed, but local apply is busy; retry sync" + : "Another Home apply is running; no route policy was written", code: "route_apply_busy", desiredCommitted }, 409); + // Names the file the operator has to fix or remove; nothing else from the local exception. + if (error instanceof RouteIntentStateUnreadableError) return reply({ error: desiredCommitted + ? `Route policy was saved, but route intent in ${error.path} could not be read afterwards. Inspect it, fix or remove it, then retry sync.` + : error.message, code: "route_intent_unavailable", desiredCommitted }, 500); + if (error instanceof Error && error.message === "ambiguous_device") return reply({ error: "ambiguous device alias: use an exact link id", code: "ambiguous_device" }, 409); + if (error instanceof Error && error.message === "unknown_device") return reply({ error: "unknown device: use home, this, or a link id or alias from ocx link status", code: "unknown_device" }, 404); + // Do not project paths, config bytes, or transport credentials from a local exception. Say + // what was saved before the failure: route policy, or the Home's native switch. + let switchSaved = false; + try { switchSaved = nativeBefore !== undefined && durableHomeSwitch(ctx.config, nativeBefore.client) !== nativeBefore.enabled; } + catch { /* An unreadable switch proves nothing was saved. */ } + const saved = desiredCommitted || switchSaved; + return reply({ error: switchSaved ? `The Home's ${nativeBefore!.client} switch was saved, but applying it failed; inspect the local integration status and retry sync` + : desiredCommitted ? "Route policy was saved, but applying it failed; inspect the local integration status and retry sync" + : "Route-to state could not be read or applied; inspect the local integration status and retry", code: "route_to_failed", desiredCommitted: saved }, 500); + } +} diff --git a/src/service/disconnected-client-restart.ts b/src/service/disconnected-client-restart.ts new file mode 100644 index 00000000000..9bec6f50ce8 --- /dev/null +++ b/src/service/disconnected-client-restart.ts @@ -0,0 +1,55 @@ +import { assertNoClientDisconnectPending, pendingClientConnectMayOwnToken, readClientConnectionState } from "../client/state"; +import { readRuntimePort, verifyPidIdentity } from "../config/process-state"; +import { isProcessAlive, stopProxy } from "../lib/process-control"; +import { findLiveProxy, SERVICE_STOP_LIVENESS } from "../server/proxy-liveness"; + +export interface DisconnectedClientRestartDeps { + disconnected?: () => boolean; + findLive?: () => Promise<{ pid?: number; role?: string } | null>; + stopManager: () => void | Promise; + beforeStop?: () => void; + alive?: (pid: number) => boolean; + verify?: (pid: number) => number | null; + stop?: (pid: number) => Promise; +} + +/** A service stop can leave a detached Child alive (notably Task Scheduler /end). + * Settle that exact, home-verified process before the manager starts standalone. + * Connected Children, foreign/unproven processes and an interrupted disconnect never enter this + * cleanup path: the restart then proceeds exactly as it did before this cleanup existed, and an + * interrupted disconnect is finished by running `ocx disconnect` again. + */ +export async function prepareDisconnectedClientServiceRestart(deps: DisconnectedClientRestartDeps): Promise { + const disconnected = deps.disconnected ?? (() => { + assertNoClientDisconnectPending(); + return readClientConnectionState().kind === "disconnected" && !pendingClientConnectMayOwnToken(); + }); + try { if (!disconnected()) return; } + catch { return; } + const live = await (deps.findLive ?? (() => findLiveProxy({ ...SERVICE_STOP_LIVENESS, acceptPackageTreeFenced: true })))(); + if (live?.role !== "client") return; + const pid = live.pid; + if (!pid || !Number.isSafeInteger(pid) || pid <= 0) throw new Error("Disconnected client PID is unverified; rerun 'ocx service restart'."); + const alive = deps.alive ?? isProcessAlive; + let homeVerified = false; + const verify = deps.verify ?? (candidate => { + const runtime = readRuntimePort(); + if (runtime && runtime.pid !== candidate) return null; + if (runtime?.pid === candidate) homeVerified = true; + // Shutdown removes its record before it necessarily exits. The pre-stop + // home binding still applies, but process identity must be proved again. + return homeVerified ? verifyPidIdentity(candidate) : null; + }); + // A client proxy this home cannot prove is its own is not ours to stop. + if (verify(pid) !== pid) return; + if (!disconnected()) throw new Error("Disconnected client ownership changed; rerun 'ocx disconnect', then 'ocx service restart'."); + deps.beforeStop?.(); + await deps.stopManager(); + deps.beforeStop?.(); + if (!disconnected()) throw new Error("Client connection changed during service restart; no replacement was started."); + if (alive(pid)) { + if (verify(pid) !== pid) throw new Error("Surviving client PID ownership is unverified; service restart stopped without killing it."); + await (deps.stop ?? stopProxy)(pid); + } + if (alive(pid)) throw new Error("Disconnected client did not exit; rerun 'ocx service restart'."); +} diff --git a/src/service/orchestration.ts b/src/service/orchestration.ts index 6a10b2a8325..29cb0da2f6f 100644 --- a/src/service/orchestration.ts +++ b/src/service/orchestration.ts @@ -191,7 +191,7 @@ export async function proxyStillLiveAfterStop(deps: { } } -async function stopTrackedProxyIfRunning(): Promise { +export async function stopTrackedProxyIfRunning(): Promise { let stopped = false; const pid = readPid(); const trackedKillPid = verifiedKillTarget(pid); diff --git a/src/service/repair.ts b/src/service/repair.ts index 615d1bfc890..9fa22dd2ae8 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -13,13 +13,14 @@ import type { LaunchdInstallOutcome } from "./launchd"; import { TASK, writeServiceInstallState, serviceSourceDir, resolveServiceOwnership, serviceLauncherPathDiagnostic } from "./state"; import type { ServiceOwnership, ServiceOwnershipResolution } from "./state"; import { installSystemd } from "./systemd"; -import { writeWindowsSchedulerAssets, reregisterWindowsSchedulerTask, windowsSchedulerRegistrationMatchesSnapshot, restoreWindowsSchedulerTaskIfAbsent, startWindows, stopWindows, statusWindowsXml } from "./windows-ops"; +import { writeWindowsSchedulerAssets, reregisterWindowsSchedulerTask, windowsSchedulerRegistrationMatchesSnapshot, restoreWindowsSchedulerTaskIfAbsent, startWindows, stopWindows, statusWindowsXml, killWindowsServiceWrapperProcesses } from "./windows-ops"; import { probeWindowsSchedulerTask, SCHEDULER_SETTLE_DELAYS_MS, settleDelay } from "./windows-scheduler"; import type { WindowsSchedulerTaskProbe } from "./windows-scheduler"; import { taskXmlSection, taskXmlWithoutCommentsAndCdata, taskXmlElementCount, taskXmlOptionalValueEquals, windowsTaskRegistrationOwnedByAttempt, windowsTaskHasSessionRecoveryTriggers, windowsTaskRegistrationHealthy, windowsTaskRegistrationRefreshableLegacy } from "./windows-taskxml"; import type { ExpectedWindowsTaskUserId } from "./windows-taskxml"; import { win32 } from "node:path"; import { assertNoDesktopSupervision, createSupervisionLatch, type SupervisionInspector, type SupervisionLatch } from "./desktop-command-guard"; +import { prepareDisconnectedClientServiceRestart } from "./disconnected-client-restart"; /** * The two CLI verbs `repairService` serves. They differ on ONE platform and ONE case: a @@ -36,6 +37,12 @@ export interface RepairServiceDeps extends RuntimePreflightDeps { assertAuth?: () => void; writeSchedulerAssets?: (runtime: DurableBunRuntime) => void; stopScheduler?: () => void; + /** Same installation-scoped wrapper cleanup used by service stop/install. */ + killServiceWrappers?: () => void; + /** Bounded post-stop respawn verification; null proves no proxy answered. */ + schedulerProxyStillLive?: () => Promise<{ port: number } | null>; + /** Stops the home-verified tracked proxy, as `ocx service stop` does; failures propagate. */ + stopTrackedProxy?: () => Promise; startScheduler?: () => void; writeSchedulerState?: (runtime: DurableBunRuntime) => void; writeNativeState?: (runtime: DurableBunRuntime) => void; @@ -70,6 +77,7 @@ export interface RepairServiceDeps extends RuntimePreflightDeps { schedulerLauncher?: string; /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */ platform?: NodeJS.Platform; + prepareDisconnectedClientRestart?: () => Promise; /** Test seam for a recorded temporary launcher. */ launcherPathDiagnostic?: () => string | null; } @@ -193,8 +201,46 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise const temporaryLauncher = (deps.launcherPathDiagnostic ?? (() => serviceLauncherPathDiagnostic(undefined, platform, true)))(); if (temporaryLauncher) console.warn(`⚠️ ${temporaryLauncher}`); + const assertRestartOwnership = () => { + assertNoDesktopSupervision(deps.inspectSupervision, latch); + (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)(); + const current = (deps.readOwnership ?? resolveServiceOwnership)(); + if (!Bun.deepEquals(current, ownership, true)) throw new Error("Service ownership changed during restart."); + }; + let schedulerStopped = false; + const stopSchedulerForRestart = (beforeStop: () => void) => { + if (schedulerStopped) return; + beforeStop(); + (deps.stopScheduler ?? stopWindows)(); + beforeStop(); + (deps.killServiceWrappers ?? killWindowsServiceWrapperProcesses)(); + schedulerStopped = true; + }; + // Defer cleanup until the selected backend's registration has been verified. + const prepareDisconnectedRestart = async (verifyRegistration?: () => void) => { + if (deps.verb !== "restart") return; + const beforeStop = () => { assertRestartOwnership(); verifyRegistration?.(); }; + beforeStop(); + await (deps.prepareDisconnectedClientRestart ?? (() => prepareDisconnectedClientServiceRestart({ + beforeStop, + stopManager: async () => { + const { platformOps } = await import("./orchestration"); + const ops = platformOps(diag.backend === "native" ? "native" : "scheduler", { platform }); + if (!ops) throw new Error("Service manager is unavailable; restart stopped."); + // The import yields; repeat ownership and registration proof immediately before stop. + beforeStop(); + if (platform === "win32" && diag.backend !== "native") { + stopSchedulerForRestart(beforeStop); + return; + } + ops.stop(); + }, + })))(); + }; + if (platform === "win32") { if (diag.backend === "native") { + if (deps.verb === "restart") await prepareDisconnectedRestart(); await (deps.repairNative ?? ((selected: DurableBunRuntime) => installWinswService(defaultWinswEntry(serviceSourceDir, selected))))(runtime); assertNoDesktopSupervision(deps.inspectSupervision, latch); (deps.writeNativeState ?? ((selected: DurableBunRuntime) => writeServiceInstallState("native", undefined, {}, selected)))(runtime); @@ -256,7 +302,38 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise : "Task Scheduler registration is not a recognized legacy OpenCodex definition; it was preserved for manual review.", ); } - try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ } + const verifyRegistrationBeforeStop = () => { + let currentXml = ""; + try { currentXml = readSchedulerXml(); } catch { /* Refuse unreadable registration below. */ } + if (!currentXml.trim()) throw new Error("Task Scheduler registration became unreadable before stop; it was preserved and not stopped."); + if (!windowsSchedulerRegistrationMatchesSnapshot(currentXml, registeredXml)) { + throw new Error("Task Scheduler registration changed before stop; it was preserved and not stopped."); + } + }; + if (deps.verb === "restart") { + await prepareDisconnectedRestart(verifyRegistrationBeforeStop); + assertRestartOwnership(); + verifyRegistrationBeforeStop(); + stopSchedulerForRestart(() => { assertRestartOwnership(); verifyRegistrationBeforeStop(); }); + // `schtasks /end` and the wrapper cleanup do not reach the wrapper's child, so a Home or a + // connected Child proxy survives them. Stop that home-verified tracked proxy the way + // `ocx service stop` does before proving nothing answers on the port. + assertRestartOwnership(); + verifyRegistrationBeforeStop(); + await (deps.stopTrackedProxy ?? (async () => { + const { stopTrackedProxyIfRunning } = await import("./orchestration"); + return stopTrackedProxyIfRunning(); + }))(); + const live = await (deps.schedulerProxyStillLive ?? (async () => { + const { proxyStillLiveAfterStop } = await import("./orchestration"); + return proxyStillLiveAfterStop({ canRespawn: true }); + }))(); + if (live) throw new Error("The scheduler proxy is still running after wrapper cleanup; restart stopped."); + assertRestartOwnership(); + verifyRegistrationBeforeStop(); + } else { + try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ } + } (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)(runtime); // Rewriting the on-disk assets does not touch the definition Task Scheduler holds, so a // task registered by an older version keeps its old triggers forever: status reports it @@ -379,6 +456,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise (deps.writeSchedulerState ?? ((selected: DurableBunRuntime) => writeServiceInstallState("scheduler", undefined, {}, selected)))(runtime); return; } + if (deps.verb === "restart") await prepareDisconnectedRestart(); if (platform === "darwin") { const outcome = (deps.repairLaunchd ?? installLaunchd)(); // `installLaunchd` is the only function that knows whether it published anything, and its diff --git a/src/types/config.ts b/src/types/config.ts index 1bc480ef5c4..d9cb3a88516 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -490,7 +490,7 @@ export interface OcxRemoteGuiConfig { allowInsecureHttp?: boolean; } -export type OcxConnectedClientId = "codex" | "claude"; +export type OcxConnectedClientId = "codex" | "claude" | "claude-desktop" | "grok" | import("../clients/client-ids").ExportClientId; /** * Redaction policy for management and CLI projections (#3859). diff --git a/structure/cli-management.md b/structure/cli-management.md index 44e9455289a..decf2222511 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -6,7 +6,11 @@ ## CLI readiness diagnostics -`src/cli/sync-command.ts` runs the Home sync before calling the fan-out primitives in `src/cli/sync-fanout.ts`, `src/cli/sync-fanout-process.ts` and `src/cli/sync-link-status.ts`. Fan-out requires a committed converged catalog, limits concurrency to three, and reports partial failure as exit 6, including when a connected Child's sync is skipped. Such Child rows add an actionable `hint` to the existing JSON schema and human output; intentional skips retain their exit behavior. An unreadable link store produces a fixed path-free stderr message in human mode. A thrown Home error in JSON mode produces a sanitized stderr diagnostic. The subprocess helper bounds output, treats grace-cancelled pipes as incomplete, and escalates termination; the status helper shares one deadline across discovery and response body. Pinned and connected syncs do not fan out. +`src/cli/route-to.ts` adds `ocx route to status|sync| on|off|inherit` without changing combo/policy routing. Home commands drive `/api/route-to`; Home policy commits share apply admission so a busy applier cannot accept an unreported new write, and off clients reconcile before model discovery; the versioned `ocx.route-to/1` matrix separates effective intent, source and applied outcome. Exact device IDs take precedence; an alias must identify exactly one link. Mutation responses must contain the requested device and client before the CLI acknowledges them. Failed sync responses print their versioned outcome matrix and exit nonzero. An omitted device changes Home defaults; `home`/`this` selects the Home override, except that Codex, Claude, Grok and Claude Desktop on the Home are decided by writing the Home's native switch (inherit sets it to the current default, the builtin until a default is saved) and report source `native`; inherit removes only that device override. The shared status projector in `src/routing/route-to-status.ts` marks any prior outcome (current, conflict, failed or not_installed) stale when its per-client intent digest differs from desired intent. + +Child status, sync and off-only opt-outs use `src/routing/route-to-local.ts` directly, with the existing apply lock and a pinned connection check. Home-owned writes print the exact Home command containing the Child link ID. Local on is refused. Plain client enable/disable commands alias Home default intent; `ocx integrations native on|off` calls the native PUT routes, which write only the Home's native switch and run the client's own transition, and on a Child refuses with the Home device command; legacy dashboard native PUTs wait for config-root ACL admission before creating the apply lock, then recheck ACL settlement after body/discovery awaits immediately before committing defaults, the legacy flag and Home native bookkeeping within one synchronous config decision; invalid Aside ownership yields actionable refusal, genuine config contention is retryable, and storage or post-publication failures retain sanitized diagnostics and committed desired state; the Codex/Claude/Claude Desktop/Grok native PUT APIs remain compatibility routes for dashboard clients, with `/api/route-to` as the current CLI replacement; profile-addressed, fingerprint-bound and client-specific policy operations retain their existing local transaction contracts. `src/routing/route-to-sync-child.ts` reuses identity-pinned SSH sync for Home-initiated links; Child-initiated links report pending with local-sync guidance. Explicit conflict permission is passed only to that operation, never saved as policy. Explicit local sync preserves a stale Home result as deferred even when cached all-off client receipts are current; it exits nonzero. Home native settings mirror only a fresh durable config under the mutation lock; missing or unreadable config preserves the live settings and refuses the operation. + +`src/cli/sync-command.ts` runs the Home sync before calling the fan-out primitives in `src/cli/sync-fanout.ts`, `src/cli/sync-fanout-process.ts` and `src/cli/sync-link-status.ts`. Fan-out requires a committed converged catalog, limits concurrency to three, and reports partial failure as exit 6, including when a connected Child's sync is skipped. Such Child rows add an actionable `hint` to the existing JSON schema and human output; intentional skips retain their exit behavior. An unreadable link store produces a fixed path-free stderr message in human mode. A thrown Home error in JSON mode produces a sanitized stderr diagnostic. The subprocess helper bounds output, treats grace-cancelled pipes as incomplete, and escalates termination; the status helper shares one deadline across discovery and response body. Pinned and connected syncs do not fan out. Omitted conflict flags stay absent in their connected-sync options; explicit takeover carries only the requested client when one is supplied. `src/cli/status.ts` retains connection transport in the status JSON. Link clients identify Remote Link Home in the banner and connection line. They read supported Hub state through the identity-checked local Child without a CLI credential, retain owner-bound cache fallback on transport errors, and report standalone Home role absence without obsolete Hub cache. Hub clients retain their existing remote provider and login report. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 0d78675636a..fe992ce4a3d 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -7,8 +7,7 @@ The client-integration subsystem writes one generated OpenCodex provider contrib > Decision record: [ADR-0107](../decisions/ADR-0107-uninstall-integration-recovery.md) Shared response support has a separate [bounded ingestion contract](../transports/inventory.md#bounded-response-ingestion-and-orcarouter-login): -raw-byte callers own their byte and deadline budgets and inherit best-effort cancellation. -The OrcaRouter login ceiling applies to its key exchange; client configuration files retain the +raw-byte callers own their byte and deadline budgets and inherit best-effort cancellation. The OrcaRouter login ceiling applies to its key exchange; client configuration files retain the parsing and ownership rules below. ## Module Responsibilities @@ -21,17 +20,18 @@ parsing and ownership rules below. | `src/integrations/target.ts` | Which file one operation reads, writes and records, and whether a write there reaches the client. | | `src/integrations/config-io.ts` | Bounded regular-file loading and parsing; production reads reject symlinks and stop after 4 MiB, and writes past the same bound are refused so a mutation cannot strand a file it cannot read back. `src/lib/bounded-file-read.ts` starts its buffer from the opened size and grows only as needed, retaining the limit-plus-one overflow probe when a file grows concurrently and returning only bytes actually read. Values that cannot round-trip through the target serializer are rejected before mutation. | | `src/integrations/kilo-candidates.ts` | Inspects all Kilo global config candidates for unsafe files and a competing `provider.opencodex` block before status or any operation that adds or replaces a block. | -| `src/integrations/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. | +| `src/integrations/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. `src/integrations/route-intent.ts` uses the coordinated writer for each local Home/Child client, preserving ownership and per-client failure outcomes. `src/client/route-intent-apply.ts` binds native adapters and lifecycle/runtime fences; connected sync reads the exact served model IDs, preserves native IDs, and requires the published port to agree. Aside selects userIds from a manifest snapshot verified against the enumerated profile roster and rechecked before commit. Initial provisioning choices persist locally per link until the corresponding Home client revision changes; local opt-outs remain off-only. Receipts withdraw old success before mutation and attach each attempted intent digest. Off intents reconcile before model discovery; an unavailable served roster leaves enabled clients stale. Combo identity comes from the authenticated exact-GET route-catalog projection of public selectors and canonical keys, separate from intent; discovery-bearing management reads remain denied. Disconnect drains Link-owned file routes before credential removal, refuses malformed or missing ownership evidence, and retains pending evidence on conflict. Explicit recovery snapshots an owned conflict through the existing private overwrite journal before disabling its keys; unowned blocks and unsafe paths are never adopted. | | `src/integrations/ownership.ts` | Durable ownership records: file, contribution fingerprints, exact paths, operation identity and [export naming policy](export-model-names.md). | | `src/integrations/ownership-policy.ts` | Client-scoped declarations for fields a client is documented to derive after apply. It must never contain a broad format-wide exemption. | | `src/integrations/writer.ts` | Apply, refresh, disable, and restore transactions, including snapshot-first ordering, compare-before-commit, and compensation. Freezing an input copies the proxy configuration and the model roster as plain data before the first await, so the plan a revalidation approves and the document that follows it read one input; an input that cannot be copied is refused rather than read twice. Aside captures the same configuration copy when its context is created, because its preference write edits the live configuration between the check and the profile writes. | | `src/integrations/mutation-plan.ts` | The shared observation both a preview and a mutation read, and the value-free plan an operator confirms. It owns no IO of its own, takes no lock, and must never import `writer.ts`. | | `src/integrations/store.ts` / `journal.ts` | One-root persistence for ownership records, operation history, snapshots, and retention maintenance. | +Native route materialization resolves Codex and Desktop paths from the device process environment. Under an effective first-party mode, Desktop enabled intent is current without a write and disabled intent is a conflict naming the mode switch; Home gateway file and marker writes share picker/lifecycle/config exclusion with mode switches. Offline first-party CLI apply retains one cross-process lifecycle lease through settings, gateway removal, mode save and rollback; nested writers validate and reuse it. Connected off uses remote-store ownership. Explicit takeover of an edited, selected, settled remote-owned projection snapshots its current JSON privately before the ordinary pending transaction, retaining owner, token, path and selection checks. Approved external Codex providers retain the existing injector's URL routing contract. Aside off with no strict ownership record requires no account manifest. Owned legacy/profile records must still match a registered profile; missing registration or unreadable ownership remains an incomplete apply. Missing common receipts allow Link disconnect only when ownership and selected file-config absence prove no materialization occurred; Droid rows are inspected independently of the current model roster, refusing OpenCodex labels and local-listener endpoints. Local opt-outs validate and invalidate the common receipt before publication. + Factory Droid's explicit integration writes only documented `customModels` rows in `~/.factory/settings.json` (`%USERPROFILE%\\.factory\\settings.json` on Windows). Each -row is addressed by its `model` and `OpenCodex:` prefixed `displayName`; `baseUrl` -remains in the protected row value. Duplicate matches refuse. Rows whose model ID +row is addressed by its `model` and `OpenCodex:` prefixed `displayName`; `baseUrl` remains in the protected row value. Duplicate matches refuse. Rows whose model ID or display name cannot be represented by that selector are omitted from both the export document and managed fragments. A nonempty catalog that yields no rows refuses. Direct and management exports use the live listener policy and refuse when Droid diff --git a/structure/codex-home.md b/structure/codex-home.md index a9ac4855fbd..e7d2e319957 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -72,7 +72,7 @@ Restore filters retired bare and trusted account-qualified native rows from its without a backup. The original backup, historical user-selected settings and session records remain intact. Journal restoration compares config and profile independently against their saved originals and -recorded injected hashes. If either changed artifact lacks its injected hash, the config/profile +recorded injected hashes. Disconnect additionally restores matching owned fields through `src/codex/journal-config-restore.ts`, preserving later model, hooks, trusted-project and Computer Use path edits. New journals capture owned-field spans; older hash-backed journals use their recorded URLs/catalog/default values and refuse ambiguous blocks or changed routing values. Owned provider/profile paths reserialized as inline or dotted TOML are detected and refused when field-level removal cannot be proved, preserving both config and recovery journal. Retry an interrupted `rotation: recovery-required` disconnect with `ocx disconnect` (retain `--keep-catalog` if initially used); credential or ownership conflicts remain refusals. If either changed artifact lacks its injected hash, the config/profile pair and journal remain untouched and the result is explicitly unverified; callers must not convert that refusal into successful fallback cleanup. Already-original bytes need no rewrite, and absence is distinct from an empty file. The injector checks a retained hashless journal against diff --git a/structure/config.md b/structure/config.md index 93bd6bb8962..9b2f36e22e4 100644 --- a/structure/config.md +++ b/structure/config.md @@ -15,7 +15,7 @@ Explicit Codex CLI installation observation reads only supplied installation pat The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). -Connected-client catalog diagnostics use the [terminal rendering contract](runtime.md#cli-readiness-diagnostics) on the first connection and on every `ocx sync` refresh; stored catalog values are unchanged. +Connected-client catalog diagnostics use the [terminal rendering contract](runtime.md#cli-readiness-diagnostics) on the first connection and on every `ocx sync` refresh; stored catalog values are unchanged. The `selectedClients` compatibility field accepts all native and exported route client IDs, including an empty effective selection, while rejecting duplicates. Linked connect and sync derive it from effective route intent and local off-only opt-outs. An explicit provisioning selection persists locally per link with baseline Home client revisions; a Home device write or inherit advances that client revision and clears its initial choice. Home defaults retain their policy knobs. A Home without route intent cannot re-enable a locally opted-out client. Local intent receipts and initial selections contain no credentials or copied Home client configurations. Receipt publication and reads share a 16 MiB bound for the local served roster; local-off records retain a separate 64 KiB bound. A malformed cleanup receipt refuses disconnect, and successful Link cleanup removes common and Home apply receipts before its credential is removed. Hub management ingress also selects the [local dashboard address](runtime.md#hub-management-dashboard-address) using its configured port. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index d4d32e281d4..b10b1e4cd28 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,5 @@ # GUI And Management API -The routing profiles dashboard shows its empty state only after a valid empty response; reloads hide it while pending, malformed envelopes and lists with no valid DTOs show a load error, and valid entries in mixed lists remain available. +`src/server/management/route-to-routes.ts` owns authenticated Home route-intent status, writes and explicit device sync at `/api/route-to` and `/api/route-to/sync`. Reads do not materialize clients. Mutations validate closed fields, supported client IDs, Aside userId selections and existing device aliases before updating the locked intent store. Home applies through the same local controller as Children. No route in this management namespace is admitted by the Remote Link read allowlist; the separate authenticated route-intent document returns only the admitting Child's effective policy. The CLI owns its device matrix; the GUI device selector remains a follow-up. The routing profiles dashboard shows its empty state only after a valid empty response; reloads hide it while pending, malformed envelopes and lists with no valid DTOs show a load error, and valid entries in mixed lists remain available. Provider PUT, POST and DELETE in `src/server/management/provider-routes.ts` run [GCP credential cleanup](providers/google.md#vertex-credential-markers) only after saving. `src/server/management/provider-gcp-credential-routes.ts` owns credential-input rejection responses and the locked deletion callback, which snapshots live and valid durable markers under the persistence lock. Rejected mutations and failed saves preserve secrets; other persisted references retain them. Vertex settings PATCH project and location explicitly, and the safe config DTO classifies credential kind without exposing its value; the GUI separates unverified host credentials from missing keys. See [LM Studio Chat instruction editor policy](providers/lm-studio-chat.md). Anthropic subscription labels and masked account display follow [subscription labels](providers/anthropic-account-pool.md#subscription-labels). `GET/PUT /api/codex-auth/preserve-signed-in` and CLI `account preserve-signed-in` expose the [signed-in quota policy](providers/openai-signed-in-preservation.md); strict boolean writes can explicitly release affected bindings/pins for the next request after persistence. The GUI retains its existing account controls. `src/server/management/combo-routes.ts` retains omitted `metered: true` metadata by trimmed provider/model identity during dashboard-shaped saves. Explicit false clears the flag; false defaults are omitted from persisted targets. Omitted combo `allowMeteredTargets` survives saves and renames; explicit false clears it and stays sparse. The CLI exposes `--allow-metered-targets on|off`, and receipts preserve the boolean. Key and loopback operator consent follow the [runtime contract](runtime.md#request-local-target-compatibility). Model-price APIs and receipt validation follow the [pricing contract](pricing.md). Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), `autoSwitchThreshold` (pool default) and `effectiveAutoSwitchThreshold`. The dedicated `PUT /api/oauth/accounts/auto-switch` accepts `{ provider: "anthropic", accountId, threshold }`; @@ -263,7 +263,7 @@ state: `/api/sync`, `/api/client-integrations/*`, `/api/native-integrations/*`, is gated in `syncEnabledClientIntegrations` instead, and the storage policy's startup and scheduled runs stand down in `maybeRequestStorageCleanupPolicyRun` (`src/storage/policy-job.ts`). `PUT /api/subagent-models` can still save the sibling home's roster, but its `src/server/management/agent-settings-routes.ts` Desktop auto-apply refuses before model discovery and rechecks after awaits so it never rewrites the owner's profile. -`GET /api/native-integrations` reads the Codex, Grok and Claude Desktop desired switch states from persisted configuration because those toggles write intent independently of the server's startup config snapshot; every other field still comes from that snapshot, and without a config file the snapshot's own intent stands. The dashboard can therefore refresh a switch immediately after a successful toggle while its routing badge remains based on observed routing. The Codex row reports the state its latest toggle in this process reported while the persisted intent still matches it, so a skipped or failed enable stays `absent` and an incomplete restore stays `unsafe` instead of being re-derived from intent alone. +The Codex/Claude/Claude Desktop/Grok native PUT routes are the native toggles both the dashboard and `ocx integrations native` call: once the Home has a link they capture Child defaults from the durable switches first, then write the Home's native switch and record no Home route intent (`recordHomeNativeDecision`), preserving inherited Child defaults; plain client toggles use `/api/route-to` and its durable per-device intent. The route registry holds them to the CLI coverage check like any other mutating route. `GET /api/native-integrations` reads the Codex, Grok and Claude Desktop desired switch states from persisted configuration because those toggles write intent independently of the server's startup config snapshot; every other field still comes from that snapshot, and without a config file the snapshot's own intent stands. The dashboard can therefore refresh a switch immediately after a successful toggle while its routing badge remains based on observed routing. The Codex row reports the state its latest toggle in this process reported while the persisted intent still matches it, so a skipped or failed enable stays `absent` and an incomplete restore stays `unsafe` instead of being re-derived from intent alone. Management setting writers admit live edits, saves and publication in one synchronous callback. Partial patches merge current state there, including normalization, provenance and before/after values for apply decisions. Async sidecar discovery is evidence-bound: changed validation state returns `409 config_changed` before publication. `src/server/management/claude-code-settings-patch.ts` prepares Claude candidates without mutation; the route rebuilds them within admission. `tests/claude-integration/claude-config-root-stale-updates.test.ts` covers retained-flight partial updates and stale-evidence refusal. An ACL admission timeout returns a sanitized `write_failed` response without publishing rejected live edits; ordered Desktop apply failures report already committed intent/profile or applied settings/gateway state through their existing partial-result contracts. Multi-agent transitions and their config/scalar writes share one admission before any write. The regression coverage is `tests/claude-integration/claude-config-root-late-admission.test.ts`. After `PUT /api/native-integrations/claude-desktop` persists its intent, and whenever the Desktop mode marker is persisted, the route adopts the committed state into the running server config, because the Claude intercept's diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 2bb82bb72e1..d5fc7fd261a 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -17,7 +17,7 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun ## Background service command selection -A bare `ocx service` is an idempotent install-or-repair command. Managed runtime logging follows the [service log contract](service-logging.md). Argument validation happens before +A bare `ocx service` is an idempotent install-or-repair command. Explicit `ocx service restart` uses `src/service/disconnected-client-restart.ts` after ownership/runtime preflight: when disk state is disconnected but this home's runtime record still names a live client, it stops the manager, verifies and stops that surviving PID, then lets the existing backend start standalone. A runtime record removed during shutdown retains its already-proven home binding while the PID identity is independently rechecked. Scheduler restart performs installation-scoped wrapper cleanup after registration/ownership checks even without a disconnected client, then stops the home-verified tracked proxy the way `ocx service stop` does (`stopTrackedProxyIfRunning`), because `schtasks /end` does not reach the wrapper's child and a Home or connected Child proxy would otherwise survive; it then verifies bounded proxy silence and rechecks authority before replacement (`tests/service/service-scheduler-restart-cleanup.test.ts`). A pending disconnect, and a live client proxy this home cannot prove is its own, are not the cleanup's case: it touches nothing and the restart proceeds as it did before the cleanup existed; an interrupted disconnect is finished by running `ocx disconnect` again. Once the cleanup has started, changed ownership and a surviving unverified PID refuse; Desktop ownership keeps its veto. After recovery, use `ocx disconnect` then `ocx service restart` before relinking. Managed runtime logging follows the [service log contract](service-logging.md). Argument validation happens before any platform status probe. macOS and Linux choose from the registration file's proven presence; Windows combines the Task Scheduler and WinSW probes into `installed`, `absent`, or `unknown`. Only proven absence enters registration. A query failure refuses the bare command with status diff --git a/structure/remote-link.md b/structure/remote-link.md index 9d74e557593..f1eee1be23d 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -6,7 +6,7 @@ Signal fencing uses the configuration mutation lock (`C`), yielding 50 ms betwee Orderly signals in an unsupervised Link Child call `src/client/stop-routing.ts` before releasing the listener. It reuses native Codex config/catalog/history restoration and managed Grok removal; the saved connection and credentials remain. `src/client/restore-ownership.ts` optionally binds each write under C to the immutable served connection, including selection and rotation state. Only `catalogFingerprint` and `catalogSyncedAt`, updated by normal catalog sync, are excluded from this restore-specific comparison; `priorCatalog` remains guarded and the shared full-snapshot CAS for asynchronous connection work is unchanged. A changed owner, pending transition or unverified journal refuses remaining writes. Service/desktop supervision, sibling ownership, accepted restart/recycle handoff and outstanding parent teardown receipts preserve integrations for their existing owners. Signal shutdown waits for an accepted restart to hand off or finish rolling back; a pending admission owns no routing. It refuses new or pending restart admission with `409 runtime_stopping`; normal bound restart remains unchanged. After an ordinary unsupervised stop, start alone does not reapply routing; the operator steps live in the public CLI lifecycle reference. Coverage: `tests/clients/client-link-stop.test.ts`, `tests/clients/client-runtime-restart.test.ts`. -`src/link/ssh-argv.ts` and `src/link/remote-windows.ts` build pinned Child sync commands that select and version-check the same executable before executing `sync --expect-link` without a restart flag. Fan-out platform discovery in `src/link/remote-platform.ts` accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's `src/cli/sync-command.ts` fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. +`src/link/ssh-argv.ts` and `src/link/remote-windows.ts` build pinned Child sync commands that select and version-check the same executable before executing `sync --expect-link` without a restart flag. With `--overwrite-conflict`/`--overwrite-client` the gate is `ROUTE_OVERWRITE_MIN_CHILD_VERSION` (`2.100.0`) instead of 2.93.0, because an older `ocx sync` ignores unknown flags and reports a plain sync as success; an older Child ends with `ocx-fanout:child-ocx-lacks-overwrite` and a selected `ocx-child` with `ocx-fanout:child-ephemeral-no-overwrite`, both exit 121 before anything runs and both classify as skipped (`src/cli/sync-fanout.ts`). `ocx sync` never forwards those flags to a link whose `childKind` is `ephemeral`. Coverage: `tests/clients/link-pinned-sync-overwrite.test.ts`, `tests/cli/cli-sync-fanout-ephemeral.test.ts`. Fan-out platform discovery in `src/link/remote-platform.ts` accepts a Windows marker on any stdout line or one framed uname line, so login-shell output cannot decide the platform. The Home requires one valid selection marker on stderr and one stdout delimiter, and classifies only the text after that delimiter; login-shell output before them is ignored. Oversized version probes are skipped. POSIX version capture and watchdog readiness use a private temporary directory, removed before execution or on refusal. The Home's `src/cli/sync-command.ts` fans out only after its catalog is current; the Child may still restart its own OpenCodex client runtime after applying changed Home settings. `src/link/` owns the building blocks for linking OpenCodex machines over SSH. The pure modules start no process, open no socket and schedule no timer on import. @@ -184,7 +184,38 @@ Coverage: `tests/server/link-management-relay.test.ts` (the real hub-link listen The client listener serves that bound restart (`src/client/machine-listener.ts`, `src/client/runtime-restart.ts`). `/healthz` answers the attestation challenge with the secret the runtime publishes in `runtime-port.json` and advertises `restartCapability: "v1"`; `POST /api/system/restart` admits only the HMAC capability bound to this pid and port, never a dashboard session, admin token or link key. If the relaunch command cannot be identified, `src/client/runtime.ts` omits it from the runtime record; update admission treats the identity as unproven, while client startup and shutdown continue. Under the desktop app or a viable service it answers `409 restart_supervised` naming the supervisor and keeps serving, because handing off to a supervisor means exiting before the replacement is proven; the CLI reports "not performed, runtime kept". Otherwise one budget (`CLIENT_RESTART_BUDGET_MS`, 90 s from acceptance) covers a 20 s listener drain, a replacement `ocx start` that must answer by 70 s (`spawnReplacementStart` with `notReady: "terminate"`, which stops an unready replacement and returns only after its exit is observed), and a rollback that binds the port again with a 10 s wait, builds a fresh tunnel supervisor (a stopped one cannot restart) and republishes the same attestation secret and start time. Before binding, and again under the client lifecycle lock right before it publishes, it checks that the durable connection is still the one it served; a disconnect, a new connection or a held lock closes the new listener unpublished and hands off to the standalone recycle, which starts what the current state calls for. `requestBoundSystemRestart` sends the request to a client target only while `CLIENT_RESTART_REQUEST_MIN_REMAINING_MS` remains, so a rollback always ends before the CLI's replacement observation and its recovery start. Coverage: `tests/clients/client-runtime-restart.test.ts`, `tests/server/restart-replacement-not-ready.test.ts`, `tests/cli/link-apply-restart.test.ts`. -The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. +The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 8 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. + + +## Home route intent and Child receipts + +`src/link/route-intent.ts` owns the version-1 Home route intent document, the dependency-free routable client roster and the strict parser. The document contains only client switches, model-name policy, stable Aside user-id selection and each client's source (`builtin`, `default`, `device`, and `native` only in the Home's own document, which the wire parser refuses and the local receipt parser accepts through `allowNative`); it carries no local paths, ports, profile slots or credentials. Child builtins enable Codex and Claude only. On the Home (`deviceId === "home"`), the native clients in `HOME_NATIVE_ROUTE_CLIENTS` (Codex, Claude, Grok, Claude Desktop) project from the Home's persisted switch with source `native` (only an explicit `false` is off), ignoring defaults and `devices.home`; only `decidedClient` takes `decidedIntent` or, absent that, the default Children inherit (the builtin until a default is saved). This does not opt Child devices in. The native adapter (`src/client/route-intent-apply.ts`) treats a `native` row it is not deciding (`LocalRouteIntentOptions.decidedClient`) on a machine that is not a connected Child as the live switch: it never calls `persistNativeRouteIntent` for it, and an enabled Grok or Desktop row gets startup scope only: Grok returns `current` without writing when `localClientSyncAllowed` refuses (a gated hub or a sibling), and Claude Desktop refreshes through the writer's refresh-only path when the library is `gateway_ours`, updating only the applied marker, and otherwise returns `current` without creating a profile or changing `desktopMode`. A refresh-only Desktop profile that inspects as `gateway_drifted`, `unsafe` or `broken` (against the durable applied marker, not the caller's snapshot) is a `conflict`, with or without overwrite, only when OpenCodex owned it: the selected entry is an OpenCodex entry, or ownership is unreadable and a committed applied fingerprint says OpenCodex wrote the profile. A refresh never takes one over. A library OpenCodex never owned (unreadable `_meta.json`, an applied id outside the safe pattern, another app's missing or unparseable profile) is `current` with no write, so a full Home sync never fails on it or suggests an overwrite. Only the decided client gets fresh writes. Regression: `tests/clients/route-intent-home-consent.test.ts`. Children never read the Home's native switches: a device override, then the saved default (`state.defaults`), then the builtin, with unspecified device policy fields inherited from the Home. `homeNativeRouteDefaults` gives the values the Home's switches are captured as: Codex and Claude from the switch (only an explicit `false` is off), Grok and Claude Desktop always off. A Home decision for a native client is its switch: `recordHomeNativeDecision` (`src/routing/route-intent-state.ts`) keeps no `devices.home` entry, `setRouteClientIntent`/`clearRouteClientIntent` refuse one, and `initializeHomeRouteIntentDefaults` drops any an earlier version saved. `ocx integrations native on|off` (`src/cli/inspect.ts`) calls the native route, which captures defaults from the durable switches, writes the switch and runs the client's own transition, including Desktop first-party; on a Child it refuses through the route-to refusal with the Home device command. No-op native enables preserve absent persisted flags and their in-memory projection. Projection is pure: its caller supplies the state as the fourth argument. File integration ownership migration belongs to the Home management owner, not this projection. Regression: `tests/clients/home-route-intent-upgrade.test.ts`, `tests/routing/route-to-home-off-review.test.ts`. + +A full Home sync (`syncHomeRouteTo` with no decided client, from `ocx sync` or `ocx route to sync`) on a Home whose existing Aside ownership cannot be named by stable userIds and that has no saved Aside decision leaves Aside exactly as it is, saves only the native defaults, applies every other client and calls `onAsideUnresolved` (`ocx sync` prints `HOME_ASIDE_UNRESOLVED_MESSAGE`), so a Home upgraded with such profiles keeps a working sync; a write that decides Aside still refuses. Regression: `tests/routing/route-to-home-decisions.test.ts`. + +`src/routing/route-intent-state.ts` stores version-1 defaults and device maps in `/route-intent.json`. Missing state reads as empty; malformed, oversized or non-regular state refuses rather than silently enabling builtin routing. Updates hold the config mutation lock across read, validation and atomic write, preserve disjoint client/device entries and remove only the named device override on inherit. Toggle updates preserve omitted client policy knobs. Retirement removes both device policy and revision entries; writes and enrollment prune entries for no-longer-live peers while retaining defaults and Home state. State is never read or written on module import. + +`src/routing/home-route-intent.ts` bridges existing ownership records into Home defaults without adopting unowned blocks. The native defaults Children inherit are captured once by `captureHomeRouteDefaults` (`src/routing/home-route-capture.ts`), which fills only missing native defaults from the durable switches and never on a Child: `initializeHomeRouteIntentDefaults` derives them in the same single write as the ownership defaults (an Aside refusal writes nothing). Capture happens at these points only: a Home default write (`ocx route to on|off` with no device) and Child enrollment (`seedRouteDeviceClients`, from both link enrollment routes) capture always; the hub-link listener (`src/server/index/link-listener.ts`) captures before it binds when the Home has a link, and a failed capture (a busy lock, a damaged file, unresolved Aside ownership) is owed and retried with backoff until it succeeds or the listener closes or stops, and on each `ensureStarted` (`tests/server/link-listener-lifecycle.test.ts`), because the Child's read never captures. The listener's capture is `captureChildRouteDefaults` (`src/routing/home-route-intent.ts`): it runs the full `initializeHomeRouteIntentDefaults`, so the file clients' ownership defaults are frozen with the native ones and a Home upgraded from a version that saved only the four native defaults cannot have a later Home-only toggle move a Child's default; when existing Aside ownership cannot be resolved it saves only the native defaults and stays owed; and every Home-only decision captures first only when the Home has a link (unreadable trust metadata counts as linked; a link whose `childKind` is `ephemeral` does not count, because `ocx-child` never reads route intent): `setHomeIntegrationEnabled` (`ocx restore`, `ocx restore back`, CLI and dashboard Desktop apply), Messages off (`src/server/management/protocol-routes.ts`), the Claude toggle (`PUT /api/claude-code`), the native route (`ocx integrations native`, `PUT /api/native-integrations/`), a `--device home` route write and the Home's own apply (`syncHomeRouteTo`), each through the `whenLinked` option. On a Home with no link nothing reads or writes `route-intent.json` for these decisions: `initializeHomeRouteIntentDefaults` returns at once under `whenLinked`, the native toggle skips it and `recordHomeNativeDecision`, and a `--device home` native route write skips reconciliation, initialization and that bookkeeping, so each only changes the switch; the Home's own apply (`syncHomeRouteTo`) saves no defaults there and only drops a legacy `devices.home` native entry when one is present (`dropLegacyHomeNativeOverrides`). Saving ownership defaults there would pin clients the dashboard later enables, because `ocx sync` applies saved route intent to every Home client. So a Child enrolled later inherits the switches as they stand at its enrollment, whichever writer moved them, unless a Home default write already saved the defaults; until a default is saved, a Home inherit for a native client takes the builtin. Reads never capture: `projectChildRouteIntent`, the link route-intent read and the `GET /api/route-to` status rows are pure. When `route-intent.json` cannot be read or the config lock is contended, a Home-only switch writer refuses before the switch moves with `{ ok: false, reason: "unavailable", retryable, message }` naming the file to fix or remove (`route_intent_unavailable` from Messages off and the Claude toggle; one complete `ocx restore --json` envelope), because writing the switch first would change what Children inherit once the file is repaired. `readRouteIntentState` raises `RouteIntentStateUnreadableError`, whose message names the file and says to fix or remove it; on a linked Home the native toggle answers it as `metadata_unreadable` (500), and every route-to write, including a `--device home` write on a Home with no link (its apply projects from the file), as `route_intent_unavailable` (500, `desiredCommitted`). Regression: `tests/routing/route-to-home-native-truth.test.ts`. `src/codex/desired-state.ts` itself stays free of link state because it is on the always-on path (`tests/lab/core-link-boundary.test.ts`). Afterwards only a Home default write changes them. Regression: `tests/routing/route-to-child-defaults.test.ts`. On the Home, the persisted native switch (`clientIntegrations.codex`, `.grok`, `.claude-desktop`, `claudeCode.enabled`; absent is on) governs, because `ocx restore`, `ocx restore back`, Messages off, `ocx claude desktop apply` and agent settings change it without recording route intent; only the client a Home write decides (`decidedClient`) is written through its switch, with the default for a default write and the explicit value (`onlyIntent`) for `--device home`. A full Home sync (`ocx sync`, `POST /api/route-to/sync`) decides no client and never writes a Home native switch. `src/routing/route-to-local.ts` projects from the durable switches, not the server's in-memory snapshot. A single-client Home write materializes only that client (`onlyClients`) and keeps the other clients' previous receipt outcomes whose client intent digest is unchanged (`retainSkippedOutcomes`); sync materializes every client. Regression: `tests/routing/route-to-home-live-flags.test.ts`. Provisioning seeds device overrides only for an explicit client selection; absence inherits current and later Home defaults. Retirement, default capture and explicit selection updates share one reentrant config-lock section. Each device-client on/off/inherit increments a sparse revision exposed as optional `clientRevisions`; defaults do not. A direct Child's explicit initial selection remains local and is reported only for display. A matching revision preserves it across sync; a new Home decision supersedes it, including inherit. The initial local selection is recorded before the connection commit, with compensation if commit fails. Older Home issue commands are retried without the selection argument only after a known empty-output usage refusal. Reports never mutate Home policy. First-party Claude Desktop already routes through OpenCodex: an enabled route intent is `current` with no native or file write, and a disabled one is a conflict, before native intent changes, whose message names the existing mode switch (`ocx integrations native claude-desktop off` on the Home, `ocx claude desktop apply --gateway` on a connected Child). A gateway write whose post-write recheck finds first-party still reports the conflict; a client-specific CLI mutation warns about unrelated conflicts while preserving their reported status, and sync remains strict across all clients. A Home write (default or `--device home`) answers 409 `route_apply_unresolved` when `applyHome`'s own outcome for the decided client is `conflict` or `failed`, with that outcome in the Home row instead of the rebuilt row's `stale`, so the CLI exits nonzero and prints the outcome message (the mode switch, for first-party Desktop). Any other failure after a write answers 500 `route_to_failed` whose `desiredCommitted` is true, with a message saying so, when the write had already saved route policy or moved the Home's native switch (the durable switch is compared with its value before the write); otherwise it is false and nothing was saved (`tests/server/route-to-routes.test.ts`). Its `desiredCommitted` is true, and the message starts "Route policy was saved", when the write saved route policy before the apply (a default, or a `devices.home` override for a client that is not native). A Home native decision is its switch, and most refusals come after the apply saved it (an external Codex provider, a failed injection, an incomplete Grok block, a changed Desktop profile), so the route compares the durable switch before and after the apply: when it moved, `desiredCommitted` is true and the message starts "The Home's switch was saved, but applying it ended in ", the same truth `PUT /api/native-integrations` reports; when it did not (first-party Desktop refuses before the write, or the switch write itself failed), `desiredCommitted` is false and the message starts "The Home did not apply". A Home-only file integration write is the Home's decision for that client (`src/server/management/home-integration-decision.ts`): the dashboard toggle and `ocx integration client enable|disable` (`PUT /api/client-integrations/`), an undo (`POST /api/client-integrations/restore`), and every Aside profile toggle or restore (`/api/client-integrations/aside[/profiles[/[/restore]]]`). Before the write, on a linked Home an unreadable `route-intent.json` refuses by name (`route_intent_unavailable`), while on a Home with no link the write proceeds and records nothing, because `ocx sync` cannot read that file either and so cannot undo it (`tests/routing/route-to-home-decisions.test.ts`); and on a linked Home `initializeHomeRouteIntentDefaults` saves every default Children inherit, so the write cannot move a Child's default. After it succeeds, once `homeRouteIntentInUse` (a saved default or a `devices.home` entry, the condition under which `ocx sync` applies route intent) holds, it is recorded as a `devices.home` entry, so `ocx sync` keeps it and Children keep their defaults; without route intent nothing is written. A file client's entry is the toggled value, or after a restore the ownership it left; Aside's (`recordHomeAsideDecision`) names the stable userIds of every profile the Home owns afterwards (`deriveHomeAsideIntent`, the derivation the upgrade bridge uses). An Aside change that may enable a profile without a stable userId is refused before anything changes (409 `route_intent_aside_unresolved`); a record that fails after the write answers 500 with `integrationChanged: true`. Native clients and a selected Hermes profile are not recorded. The plain CLI toggle is the same Home-only decision; `ocx route to on|off` writes the Children's default, and on a Child the plain toggle is refused with that Home command. Regression: `tests/routing/route-to-home-decisions.test.ts`. + +The authenticated `GET /v1/link-management/route-intent` is separate from the management relay and the Child data-plane route list. `src/server/index/link-management.ts` resolves exactly one current link from the admitted key id and projects that link id's effective policy. Every query, including a device selector or refresh request, every other method and every upgrade is refused. The read emits `Cache-Control: no-store` and does not mutate state. Missing or ambiguous link identity refuses; unreadable state returns a fixed unavailable response. + +`src/server/index/route-intent-watch.ts` adds optional closed headers on that same GET: `x-ocx-route-intent-watch: 1` and an optional `x-ocx-route-intent-after` lowercase SHA-256 digest. A differing or absent digest returns immediately. A matching digest waits for a Home notification or at most 30 seconds, then repeats the listener's link-key admission against current policy (`serve-options.ts` passes a `readmit` callback; a key revoked during the wait is refused with 403 `link_key_required`), rechecks current link ownership and projects fresh policy. Successful reads advertise watch support in the same watch header; watch responses identify `changed`, `notified` or `timeout` in `x-ocx-route-intent-wake`. An unchanged timeout needs no apply, but an explicit notification and a changed digest still request sync. A peer without the new response header retains the previous conservative behavior. `src/link/route-intent-watch.ts` holds at most one waiter per link and 256 total in process memory; duplicate/capacity admission refuses, cancellation removes the waiter, and notification neither persists state nor acknowledges an apply. Home device mutations notify that link, default mutations notify every linked device, and Child-initiated explicit sync notifies an active waiter while returning `pending`; without a watcher it directs the caller to the Child or the next periodic tick. Conflict overwrite still requires explicit local Child authority. + +`src/client/route-intent-watch.ts` exposes `startRouteIntentWatch(connection, key, { requestSync })` and `stop()`. Each GET uses the existing connection-bound relay, resolves a key getter afresh for rotation, pins the starting connection identity, refuses redirects and consumes at most 64 KiB with an 8 KiB response-header bound and a 40-second total read budget. A Home without watch support leaves reconciliation to the existing periodic tick. Failed reads retry after five seconds; immediately returning supported peers cannot create a tight loop. Watch notifications retain one pending apply while `requestSync` is busy and retry rejected applies. The runtime owner supplies the same serialized scheduler used by periodic sync, reports successful applies through the existing parity reporter, and stops the watcher with the runtime. Standalone recycle also stops the reporter, watch and tick before listener shutdown or replacement handoff; watch cancellation releases the outstanding Home read. The watch itself writes no client files or receipts and adds no Child-to-Home mutation endpoint. The periodic tick (`src/integrations/route-intent-tick.ts`) runs a full `syncConnectedClient` every 60 seconds even when nothing changed. Each apply first clears the Home proof record and writes a withdrawn receipt, because a failed or interrupted apply must not leave an earlier proof current, then rewrites both; unchanged writers make no client-file change, but the receipt and record are rewritten, and a parity report sent during that window carries the withdrawal until the next report. Avoiding the rewrite would mean knowing an apply changes nothing before running it, which the withdraw-first rule forbids. Native switches on a Child follow the Home: a local `ocx restore` is reverted by the next tick unless the person records `ocx route to codex off --local`. + +`routeIntentDigestPayload` serializes compact JSON in the order `version`, `clients`, `sources`; each map follows `ROUTE_CLIENT_IDS`, each policy uses `enabled`, optional `modelNames`, optional `accounts`, and account lists sort lexically. `routeIntentDigest` is lowercase SHA-256 of those UTF-8 bytes. Home name/version labels are excluded; policy sources are included. The builtin golden digest is pinned independently in `tests/clients/route-intent.test.ts`. + +`src/client/link-home.ts` fetches this document through the existing connection-bound relay with manual redirects, the existing read deadline and a 64 KiB body bound. Only 404 means unsupported; malformed documents and other failures remain explicit failures. The local `/link/home-route-intent.json` receipt is link-bound, bounded and private. It stores the document, fetched/apply timestamps, `appliedRouteIntentDigest` and `routeClientOutcomes`. Only a complete roster of `current` dispositions with no effective local opt-out of a Home-enabled client claims the digest; incomplete, unavailable, failed/stale/conflicted or locally diverged applies record a null digest. Reapplying the same completed digest keeps the original apply timestamp. The materializer owns receipt creation after local work and removal on disconnect. Effective Codex enable is committed under the existing connection/runtime/local-opt-out fences before injection reads its durable gate, including fresh Link enrollment over a standalone disabled flag. Compensation is armed before enable publication; failed enrollment, including a post-publication bookkeeping failure, restores the recorded standalone value (below) under the connecting-owner guard while retaining disjoint config changes; no-sync enrollment leaves the flag and native files alone. Derived `selectedClients` records desired selection, not completed restoration: disconnect still drains a journal owned by the connected client after a failed off apply and refuses another key's journal, malformed or invalid ownership, and injected routing without a recovery journal. Connection transport and receipt helpers import only intent data at load time; device writers load on demand during apply or cleanup, keeping local provider dispatch outside the Child startup graph. + +`src/client/route-intent-native-originals.ts` owns the standalone native originals record, `/link/native-intent-originals.json` (0600, no secrets). A Child's link write through `persistNativeRouteIntent` (the materializer's native adapter while connected, the fresh-enrollment Codex pre-commit and the connected sync's Codex enable) passes `recordStandalone`; Home writes do not. Per field (`clientIntegrations.codex`, `.grok`, `.claude-desktop`, `claudeCode.enabled`, `claudeCode.authModeMigratedAt`, `apiSurfaces.messages`) it keeps the value before route intent first changed it, with absence as `null`, plus the last value this writer published and any pending value. The original is captured once and never overwritten, and the record is not bound to a link, so a crashed enrollment followed by a different link still restores the first standalone value. Under L then C, the original and pending value are written before the atomic config write and marked published after it; a no-op toggle records nothing. Disconnect (in its final locked block, before `removing_token`, for any transport) and failed enrollment restore a field only when its current value is the writer's published or pending value, deleting the key when the original was absent and pruning an emptied parent; any other value is the person's own edit and is kept. The record is removed only after the restored config is read back and verified, so an interrupted restore is retried by the next disconnect. A malformed or unsafe record refuses both capture and restore. ABA limit: a person who sets a field to exactly the published value cannot be distinguished from the writer, and that field is restored. The Child's local opt-out list (`src/routing/route-to-local.ts`, `local-off.json`) is the person's own choice and survives disconnect. Desktop first-party mode records nothing here: enable is already `current` and disable is a conflict before any native write. A failed enrollment whose restore throws keeps its service token and pending-connect marker, exactly as disconnect keeps the token until the restore succeeds, and names the record. On a disconnected machine with no unfinished disconnect receipt (absent or `complete`), plain `ocx disconnect` drains a lone record (`drainLoneNativeIntentOriginals` in `src/client/connect.ts`): it restores and removes the record under L then C, then removes the token only while the pending-connect marker proves it is the enrollment's own, and on failure refuses with the record path and the recovery step. A record left by a crashed enrollment is therefore restored by the next failed enrollment, disconnect, or plain disconnect on the disconnected machine; `ocx disconnect --orphaned-token` still does not touch it. Coverage: `tests/clients/route-intent-native-originals.test.ts`, `tests/clients/route-intent-codex-transitions.test.ts`, `tests/clients/route-intent-connected-review.test.ts`, `tests/clients/route-intent-codex-connect-disabled.test.ts`. + +`src/client/route-intent-file-originals.ts` owns `/link/file-intent-originals.json` (0600, no paths or secrets): the file clients, excluding Aside and the native clients, that had an ownership record at a link's first apply, with their `modelNames`, bound to that link id. `applyConnectedRouteIntent` captures it under L before any materialization and only while no link-bound route receipt exists, so a client a link created is never recorded; a record naming another link is stale and is replaced. Disconnect's route cleanup (`src/integrations/route-intent-disconnect.ts`, the standalone set of `cleanupRouteIntent`) leaves recorded clients out of the off pass: one still owned, or classified other than absent (a block the person wrote), is kept; an absent one is applied again with the receipt's last served roster, which the next standalone start refreshes from the local catalog through `refreshOwnedCatalogIntegrations`; an uninstalled one is a no-op. A local opt-out takes the client out of the restore set. The final locked block removes the record beside the route receipt, and phase gating never reruns the cleanup. A malformed record refuses disconnect. Aside profiles keep the ordinary cleanup. Known limit: a Child whose link-bound receipt predates this record never captures one, so its disconnect removes link-owned file integrations without restoring the ones it had standalone; the user guide tells the operator to set those up again. Coverage: `tests/clients/route-intent-file-originals.test.ts`. + +The Child parity report adds the optional paired fields `appliedRouteIntentDigest` and `routeClientOutcomes`; legacy reports remain accepted. Outcomes use the closed client/disposition schema and optional `source: local` for a Child opt-out. Only Aside slot summaries such as `u0,u1` survive as wire targets; arbitrary paths and diagnostic messages remain local. Sender, receiver, in-memory store and CLI all apply that projection. `src/client/link-parity-reporter.ts` validates the current link's Home proof against its strict common receipt and current local opt-outs on each report tick. Missing or mismatched proof sends explicit null/empty withdrawal fields. HTTP 400/413/422 schema rejection retries the base report once, while later ticks keep probing extended support; authentication and transport failures do not trigger that fallback. `ocx link status --json` preserves these fields while accepting older runtimes that omit them. + +Each outcome may carry `intentDigest`, SHA-256 of its canonical client intent and source, so one unavailable client does not make another successful client stale. Optional `initialIntent` carries only an enrollment boolean paired with `initialRevision`; Home status applies that display overlay only while the revision matches. Revision metadata invalidates enrollment choices but is excluded from the document digest, whose payload remains policy plus sources. + +Coverage: `tests/clients/route-intent.test.ts`, `tests/clients/route-intent-state.test.ts`, `tests/clients/route-intent-parity.test.ts`, `tests/clients/link-home-route-intent.test.ts`, `tests/clients/route-intent-reporter.test.ts`, `tests/server/link-route-intent.test.ts`, `tests/cli/cli-link-route-intent.test.ts`, `tests/clients/route-intent-watch-client.test.ts`, `tests/server/route-intent-watch-boundary.test.ts` and `tests/server/route-intent-watch-waiters.test.ts`. ## Ephemeral Rust Child (`ocx-child`) @@ -202,6 +233,6 @@ Restoration (immediate attach rollback, crash recovery of an incomplete attach d Daemon start commit (`native/ocx-child/src/daemon.rs`, `relay.rs`): `attach`, `restart`, `sync` rollover, and rollback's restart of the prior relay all start `serve --foreground --lifecycle-start` through `start_or_rollover_daemon`. It records a fresh random `daemonGeneration` in `state.json` before spawning and passes `ocx-child-start/1 ` on the child's stdin, which stays open. Inside the daemon start gate the child requires `state.daemonGeneration` to equal its generation, and until it reads `commit ` on stdin it answers only `/healthz` (reporting `generationCommitted: false`), returning `503 child_starting` for every other route and posting no parity reports. EOF, a read error, or any other line before the commit makes it exit without serving, so a lifecycle parent that dies after handing over the secret leaves no relay behind and no recovery record is touched. The parent writes the commit line only after the state naming the generation and PID is durable and, for `attach`, after the ownership phase is `attached` and the rollback snapshot is removed, and for rollback's restart of the prior relay, after the rollback snapshot is removed (a removal that fails leaves that relay uncommitted, and it exits); it then waits for `/healthz` to report `generationCommitted: true`. An operator-run `ocx-child serve --foreground` (no `--lifecycle-start`) serves immediately and is refused while an attach rollback is pending. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (parent killed at a barrier before the commit; stale and superseded generations). -Authenticated shutdown is bounded by `stop_owned_daemon`'s 3-second wait. One-shot Home calls carry a whole-request deadline (60 s for lifecycle fetches, 10 s for parity reports) enforced per read on the upstream socket, because per-read timeouts alone never end a trickling response. The relay's parity thread registers a clone of its in-flight socket in a `CancelSlot`; shutdown takes the same mutex, marks the slot cancelled, and shuts that socket down, so the request either sees the cancellation before it registers or is aborted after. A socket can be registered only once connected, so that connect is capped at 1 second. `serve` exits only after the parity thread has ended. When all 32 workers are busy, a new connection gets a short-lived control thread (at most 256) that answers only `/healthz` and the authenticated shutdown, with 1-second read and write timeouts and a 2-second deadline covering the request heads of the whole exchange. A full worker pool therefore cannot keep the shutdown connection from being handled, and a sender that trickles bytes holds a control slot for at most 2 seconds; while 256 control connections are open, a new one is closed unanswered. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (a Home that authenticates and then stalls or trickles the parity response; a relay whose workers are all blocked; control slots held by senders that trickle their request heads). Coverage: `tests/clients/link-ocx-child-conformance.test.ts`, `tests/clients/link-ephemeral-child-discovery.test.ts`, `tests/clients/link-child-plant-foundations.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/link-ocx-child-e2e.test.ts`, `gui/tests/remote-link.test.tsx`. +Authenticated shutdown is bounded by `stop_owned_daemon`'s 3-second wait. One-shot Home calls carry a whole-request deadline (60 s for lifecycle fetches, 10 s for parity reports) enforced per read on the upstream socket, because per-read timeouts alone never end a trickling response. The relay's parity thread registers a clone of its in-flight socket in a `CancelSlot`; shutdown takes the same mutex, marks the slot cancelled, and shuts that socket down, so the request either sees the cancellation before it registers or is aborted after. A socket can be registered only once connected, so that connect is capped at 1 second. `serve` exits only after the parity thread has ended. When all 32 workers are busy, a new connection gets a short-lived control thread (at most 256) that answers only `/healthz` and the authenticated shutdown, with 1-second read and write timeouts and a 2-second deadline covering the request heads of the whole exchange. A full worker pool therefore cannot keep the shutdown connection from being handled, and a sender that trickles bytes holds a control slot for at most 2 seconds; while 256 control connections are open, a new one is closed unanswered. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (a Home that authenticates and then stalls or trickles the parity response; a relay whose workers are all blocked; control slots held by senders that trickle their request heads). An `ocx-child` never reads `/v1/link-management/route-intent` and sends only the base parity report, so route intent does not apply to it: enrollment seeds no route intent for it (`seedRouteDeviceClients` is skipped), `homeHasLinks` does not count it, a route write naming it and a single-device `POST /api/route-to/sync` answer 409 `route_intent_unsupported` (`reason: ephemeral-child-no-route-intent`) before any state change, `--device all` reports its sync row `unsupported` without running anything, and its status row is `unsupportedRouteToDevice` (no client rows, `routeIntent: "unsupported"`) in `src/routing/route-to-status.ts` (`tests/server/route-to-ephemeral.test.ts`). Coverage: `tests/clients/link-ocx-child-conformance.test.ts`, `tests/clients/link-ephemeral-child-discovery.test.ts`, `tests/clients/link-child-plant-foundations.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/link-ocx-child-e2e.test.ts`, `gui/tests/remote-link.test.tsx`. Foreground startup requires an attached state, matching recovery journal, verified link key and matching listener port. Sync, restart and detach refuse a journal bound to different canonical homes; detach checks this before changing state or stopping the relay. Shutdown authenticates both ends on one socket without sending the private secret and never signals a process based only on a reported PID. Attach keeps a private rollback snapshot until success or completed recovery; detach can retry an incomplete rollback. Failed restore or cleanup keeps recovery records for retry. On Windows, temporary replacements are created with a protected owner-only DACL before bytes are written. diff --git a/structure/runtime.md b/structure/runtime.md index 1aa77595d12..a0cab036e61 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -574,7 +574,7 @@ subject and both manager identities; `recordServiceOwner` re-observes and compar the lock, so a mutable shim or downgrade cannot inherit earlier consent. An upgrade is a separate user-authorized action. Declining consent attaches the app as a guest. If stopping or claiming fails, the app does not proceed as the owner; a stopped runtime remains reported as stopped and unclaimed. `ocx service claim` in `src/service/claim.ts` accepts the expected subject and compatibility token and calls `recordServiceOwner` under the ownership mutation lease. `src/service/ownership-mutation-lease.mjs` returns one-shot release handles bound to an acquisition generation. Nested handles share that generation's reference count; repeated or stale handles cannot shorten a successor lease. The CLI rechecks both before writing; a mismatch writes nothing. The desktop owns the consent prompt and treats `approval-changed`, `manager-still-active`, unreadable stop output and stop-child timeout as terminal before its silence wait or claim. Only a parsed `stopped` result or a validated exit-79 `history-incomplete` result reaches that wait. A stopped but unclaimed runtime is reported as such, without a restoration claim. -Activating service verbs retain their foreign/unknown durable-owner refusals. +Activating service verbs retain their foreign/unknown durable-owner refusals. Explicit restart settles a disconnected, home-recorded stale client before starting the backend again through `src/service/disconnected-client-restart.ts`; see the [service restart contract](ops/service-and-sidecars.md#background-service-command-selection). Disconnect's key-level Codex recovery preserves post-injection edits under the [journal ownership contract](codex-home.md#codex-home). `src/service/desktop-command-guard.ts` separately refuses install, repair, start and restart before mutation, including registration staging; stop and uninstall remain available. Each guarded operation uses one `createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index bfa715af2e0..f3dc8578579 100644 --- a/tests/ci-workflows/skill-ocx-generated.test.ts +++ b/tests/ci-workflows/skill-ocx-generated.test.ts @@ -166,7 +166,7 @@ describe("generated operating-skill chapter contract", () => { row.usage = "ocx " + row.command.join(" ") + " --unsafe-fixture"; } `); - expect(result.status).toBe(0); + expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); expect(result.stdout).not.toContain("--unsafe-fixture"); }); }); diff --git a/tests/claude-integration/claude-config-root-writer-barrier.test.ts b/tests/claude-integration/claude-config-root-writer-barrier.test.ts index 89b6ec9e87f..0eff9cda760 100644 --- a/tests/claude-integration/claude-config-root-writer-barrier.test.ts +++ b/tests/claude-integration/claude-config-root-writer-barrier.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join, sep } from "node:path"; import * as management from "../../src/server/management-api"; +import * as managementBody from "../../src/server/management/body"; import * as paths from "../../src/config/paths"; import { applyDesktop, applyProfile } from "../../src/cli/claude-desktop"; import * as firstParty from "../../src/claude/desktop-first-party"; @@ -243,6 +244,7 @@ test.each([["CLI release", false, false], ["CLI timeout", true, false], ["native try { await parent.reached; await turn(); expect(parent.descendants).toEqual([]); + if (native) expect(existsSync(join(root, "link", "apply-lock.sqlite"))).toBe(false); expect(readFileSync(file, "utf8")).toBe(original); expect(settled).toBe(false); if (timeout) { @@ -265,6 +267,36 @@ test.each([["CLI release", false, false], ["CLI timeout", true, false], ["native } }); +test.each([false, true])("native decision rechecks ACL work started during body parsing (timeout=%s)", async timeout => { + const config = fixture(), file = join(root, "config.json"), original = JSON.stringify(config); + writeFileSync(file, original); + const time = clock(), parsed = deferred(); + let parent: ReturnType | undefined; + const read = managementBody.readManagementJsonBody; + const hook = spyOn(managementBody, "readManagementJsonBody").mockImplementation(async req => { + const body = await read(req); + resetHardenedStateForTests(); parent = holdParent(); await parent.entered; + parsed.resolve(); return body; + }); + restores.push(() => hook.mockRestore()); + let settled = false; + const work = putIntent(config, true).then(response => { settled = true; return response; }); + try { + await parsed.promise; await parent!.reached; await turn(); + expect(settled).toBe(false); + expect(parent!.descendants).toEqual([]); + expect(readFileSync(file, "utf8")).toBe(original); + expect(existsSync(join(root, "route-intent.json"))).toBe(false); + if (timeout) { + time.advance(1000); await turn(); + expect((await work)?.status).toBe(500); + expect(readFileSync(file, "utf8")).toBe(original); + expect(existsSync(join(root, "route-intent.json"))).toBe(false); + } + } finally { parent?.release(); await work; } + if (!timeout) expect((await work)?.status).toBe(200); +}); + test.each([["release", false], ["timeout", true]] as const)("offline gateway re-enters after discovery: %s", async (_name, timeout) => { writeFileSync(join(root, "config.json"), JSON.stringify(fixture())); const time = clock(); diff --git a/tests/claude-integration/claude-intercept-acl-barrier.test.ts b/tests/claude-integration/claude-intercept-acl-barrier.test.ts index a54346fb4e9..6b92cf4444f 100644 --- a/tests/claude-integration/claude-intercept-acl-barrier.test.ts +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -333,6 +333,10 @@ test.each(["mutation", "active config flight", "startup", "offline apply", "nati expect(await controller.transition(async () => "lock released")).toBe("lock released"); } else { expect(outcome).toMatchObject({ diagnostic: { stage: "ca", cause: "ETIMEDOUT" } }); + if (caller === "native PUT") { + expect(outcome).toMatchObject({ clientId: "claude-desktop", reason: "write_failed" }); + expect(existsSync(join(root, "link", "apply-lock.sqlite"))).toBe(false); + } if (caller === "offline apply") expect(outcome).toMatchObject({ ok: false, reason: "ca_unavailable" }); } expect(mutated).toBe(false); diff --git a/tests/cli/cli-capabilities.test.ts b/tests/cli/cli-capabilities.test.ts index 38ad1c134ce..0b92f2b4c2c 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -22,6 +22,17 @@ function captureStdout(): { lines: string[]; restore: () => void } { } describe("capability table is a checked data graph", () => { + test("native toggle APIs are the routes ocx integrations native drives, with no exemption", async () => { + const { MANAGEMENT_ROUTES } = await import("../../src/server/management/route-registry"); + for (const client of ["codex", "claude", "claude-desktop", "grok"]) { + const route = MANAGEMENT_ROUTES.find(row => row.method === "PUT" && row.path === `/api/native-integrations/${client}`); + expect(route).toBeDefined(); + expect(route?.exempt).toBeUndefined(); + expect(capabilityRouteKeys().has(`PUT /api/native-integrations/${client}`)).toBe(true); + } + expect(capabilityRouteKeys().has("PUT /api/route-to")).toBe(true); + }); + test("capability discovery has only the allowed transitive data/type edges", () => { expect(capabilityDataBoundary(join(repoRoot, "src/cli"))).toEqual([]); }); diff --git a/tests/cli/cli-capability-data.test.ts b/tests/cli/cli-capability-data.test.ts index 71f1a676f77..ed8050d21a6 100644 --- a/tests/cli/cli-capability-data.test.ts +++ b/tests/cli/cli-capability-data.test.ts @@ -8,6 +8,7 @@ import * as base from "../../src/cli/capabilities-base"; import { PROVIDER_MODEL_CAPABILITIES } from "../../src/cli/capabilities-provider-models"; import { ACCOUNT_CAPABILITIES } from "../../src/cli/capabilities-accounts"; import { AGENT_ROUTING_CAPABILITIES } from "../../src/cli/capabilities-agents-routing"; +import { ROUTE_TO_CAPABILITIES } from "../../src/cli/capabilities-route-to"; import { INTEGRATION_CAPABILITIES } from "../../src/cli/capabilities-integrations"; import { OBSERVE_SYSTEM_CAPABILITIES } from "../../src/cli/capabilities-observe-system"; import { ACCESS_REMOTE_CAPABILITIES } from "../../src/cli/capabilities-access-remote"; @@ -41,6 +42,12 @@ function checkFixture(changes: Record = {}): string[ } describe("checked capability dependency boundary", () => { + test("route-to metadata remains isolated from runtime settings and handlers", () => { + expect(checkFixture({ "capabilities-route-to.ts": `import { loadConfig } from '../config';` }) + .join("\n")).toContain("forbidden edge"); + expect(checkFixture({ "capabilities-route-to.ts": `import { runRouteTo } from './route-to';` }) + .join("\n")).toContain("outside data graph"); + }); test("real graph and static type/value fixtures use the same predicate", () => { expect(capabilityDataBoundary(repoPath("src", "cli"))).toEqual([]); expect(checkFixture()).toEqual([]); @@ -311,6 +318,7 @@ describe("capability facade and optional usage consumers", () => { ...OBSERVE_SYSTEM_CAPABILITIES, ...ACCESS_REMOTE_CAPABILITIES, ...LAB_CAPABILITIES, + ...ROUTE_TO_CAPABILITIES, ]); expect(facade.HEAD_CAPABILITIES).toBe(base.HEAD_CAPABILITIES); const matches = facade.capabilitiesForRoute("/api/providers/{provider}/model-costs"); diff --git a/tests/cli/cli-help-paths.test.ts b/tests/cli/cli-help-paths.test.ts index 84a44c790d6..dc252108403 100644 --- a/tests/cli/cli-help-paths.test.ts +++ b/tests/cli/cli-help-paths.test.ts @@ -120,13 +120,15 @@ describe("explicit CLI help paths", () => { expect(output).not.toContain("Parent help:"); }); - test("route family help names combo and policy and retains policy topic links", () => { + test("route family help names combo, policy and to and retains topic links", () => { const result = help(["route", "--help"]); expect(result.status).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Usage: ocx route "); - expect(result.stdout).toContain("Manage combo routing and routing profiles."); + expect(result.stdout).toContain("Usage: ocx route "); + expect(result.stdout).toContain("Manage combo routing, routing profiles and per-device client intent."); expect(result.stdout).toContain("ocx help route policy list"); + expect(result.stdout).toContain("ocx help route to status"); + expect(result.stdout).toContain("ocx help route to sync"); expect(result.stdout).not.toContain("combo is currently the supported routing resource"); }); diff --git a/tests/cli/cli-link-route-intent.test.ts b/tests/cli/cli-link-route-intent.test.ts new file mode 100644 index 00000000000..50e9c27b9ce --- /dev/null +++ b/tests/cli/cli-link-route-intent.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { runLinkCommand } from "../../src/cli/link"; +const row = { id: "lnk_0123456789abcdef", alias: "child", direction: "client-initiated", state: "connected", since: "2026-11-07T00:00:00.000Z", reason: null, tunnelPort: 19200, + lastApplyAt: null, runtimeStartedAt: 1794000000000, appliedCodexSettingsDigest: null, reportedAt: 1794000001000, + appliedRouteIntentDigest: "a".repeat(64), routeClientOutcomes: [ + { client: "aside", outcome: "current", source: "local", target: "u0,u1", message: "private-fixture-diagnostic" }, + { client: "pi", outcome: "failed", target: "/fixture/private/pi/models.json", message: "private-fixture-failure" }, + ] }; +test("link status JSON carries digest and sanitized per-client outcomes", async () => { + const lines: string[] = []; + const log = console.log; + console.log = (...args: unknown[]) => { lines.push(args.join(" ")); }; + let code: number; + try { + code = await runLinkCommand(["status", "--json"], { baseUrl: "http://127.0.0.1:19101", readAdminToken: () => "test-token", + fetchImpl: (async () => Response.json({ role: "home", listener: { state: "listening", port: 19100 }, links: [row], child: null })) as typeof fetch }); + } finally { console.log = log; } + expect(code!).toBe(0); + expect(JSON.parse(lines[0]!).links[0]).toEqual({ ...row, routeClientOutcomes: [ + { client: "aside", outcome: "current", source: "local", target: "u0,u1" }, { client: "pi", outcome: "failed" }, + ] }); + expect(lines[0]).not.toContain("private-fixture"); + expect(lines[0]).not.toContain("/fixture/private"); +}); diff --git a/tests/cli/cli-registry.test.ts b/tests/cli/cli-registry.test.ts index 5831e812d30..37988957180 100644 --- a/tests/cli/cli-registry.test.ts +++ b/tests/cli/cli-registry.test.ts @@ -121,7 +121,7 @@ describe("CLI command registry parity", () => { expect(connect?.usage).not.toContain("--token <"); expect(connect?.usage).not.toContain("--admin-token <"); expect(connect?.details?.join(" ")).toContain("not supported"); - expect(findCommand("disconnect")?.usage).toBe("ocx disconnect [--keep-catalog] [--json]"); + expect(findCommand("disconnect")?.usage).toBe("ocx disconnect [--keep-catalog] [--recover-config --yes] [--json]"); }); }); diff --git a/tests/cli/cli-route-to-aliases.test.ts b/tests/cli/cli-route-to-aliases.test.ts new file mode 100644 index 00000000000..1bd34c9785f --- /dev/null +++ b/tests/cli/cli-route-to-aliases.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { handleClientIntegrationCommand } from "../../src/cli/integrations"; +import { handleIntegrationCommand } from "../../src/cli/inspect"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +let out: ReturnType, err: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-route-to-alias-"); + out = spyOn(console, "log").mockImplementation(() => {}); + err = spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { out.mockRestore(); err.mockRestore(); home.remove(); }); + +function fixture(response?: unknown) { + const calls: Array<{ path: string; body: unknown }> = []; + return { calls, deps: { + baseUrl: "http://route-to.invalid", + fetchImpl: (async (url, init) => { + calls.push({ path: new URL(String(url)).pathname, body: JSON.parse(String(init?.body)) }); + return Response.json(response ?? { schema: "ocx.route-to/1", devices: [{ id: "home", clients: ["hermes", "codex", "aside"].map(client => ({ client, intent: "on", source: "default", applied: "current" })) }] }); + }) as typeof fetch, + } }; +} + +test.each(["on", "off"])("native %s stays a Home-only decision through the native route", async action => { + const f = fixture({ ok: true, clientId: "claude-desktop", changed: true, state: action === "on" ? "current" : "absent", desiredEnabled: action === "on", message: "ok" }); + expect(await handleIntegrationCommand(["native", "claude-desktop", action, "--json"], f.deps)).toBe(0); + // The native route records a Home override and runs Desktop's own first-party transition. + expect(f.calls).toEqual([{ path: "/api/native-integrations/claude-desktop", body: { enabled: action === "on" } }]); +}); + +test("native on a Child is refused with the Home device command", async () => { + const f = fixture(); + const deps = { ...f.deps, readChildContext: () => ({ connection: {} as never, linkId: "lnk_0123456789abcdef", homePhrase: "the Home" }) }; + expect(await handleIntegrationCommand(["native", "codex", "off"], deps)).toBe(5); + expect(f.calls).toEqual([]); + expect(err.mock.calls.flat().join("\n")).toContain("ocx route to codex off --device lnk_0123456789abcdef"); +}); + +// Like the dashboard toggle, a plain toggle is this Home's own decision; ocx route to changes the Children's default. +test.each(["enable", "disable"])("plain client %s is a Home-only decision", async action => { + const f = fixture({ ok: true, clientId: "pi", state: "current", changed: true }); + expect(await handleClientIntegrationCommand([action, "--client", "pi", "--json"], { ...f.deps, readChildContext: () => null } as never)).toBe(0); + expect(f.calls).toEqual([{ path: "/api/client-integrations/pi", body: { enabled: action === "enable" } }]); + expect(err.mock.calls.flat().join("\n")).toContain("linked Children keep their route defaults (ocx route to on|off changes those)"); +}); + +test("plain client enable on a Child is refused with the Home command", async () => { + const f = fixture(); + const deps = { ...f.deps, readChildContext: () => ({ connection: {} as never, linkId: "lnk_0123456789abcdef", homePhrase: "the Home" }) }; + expect(await handleClientIntegrationCommand(["enable", "--client", "pi"], deps as never)).toBe(5); + expect(f.calls).toEqual([]); + expect(err.mock.calls.flat().join("\n")).toContain("ocx route to pi on --device lnk_0123456789abcdef"); +}); + +test("profile-scoped actions preserve local profile addressing", async () => { + const f = fixture(); + expect(await handleClientIntegrationCommand(["enable", "--client", "aside", "--profile", "2", "--json"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ path: "/api/client-integrations/aside/profiles/2", body: { enabled: true } }]); +}); + +test.each([ + { client: "pi", flags: ["--plan-fingerprint", `p9:${"a".repeat(32)}`], extra: { operation: "apply", planFingerprint: `p9:${"a".repeat(32)}` } }, + { client: "droid", flags: ["--reasoning-default", "provider/model=high"], extra: { droidReasoningDefaults: { "provider/model": "high" } } }, + { client: "droid", flags: ["--clear-reasoning-defaults"], extra: { droidReasoningDefaults: {} } }, +])("policy/bound operations preserve legacy contracts: $client $flags", async ({ client, flags, extra }) => { + const f = fixture({ ok: true, clientId: client, state: "current", changed: true }); + expect(await handleClientIntegrationCommand(["enable", "--client", client, ...flags, "--json"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ path: `/api/client-integrations/${client}`, body: { enabled: true, ...extra } }]); +}); + + +test("explicit profile model naming keeps its legacy scoped path", async () => { + const f = fixture(); + expect(await handleClientIntegrationCommand(["enable", "--client", "aside", "--profile", "2", "--model-names=alias"], f.deps)).toBe(2); + expect(f.calls).toEqual([]); +}); + +test("plain model naming is a Home-only decision too", async () => { + const f = fixture({ ok: true, clientId: "hermes", state: "current", changed: true }); + expect(await handleClientIntegrationCommand(["enable", "--client=hermes", "--model-names=alias", "--overwrite-conflict", "--json"], { ...f.deps, readChildContext: () => null } as never)).toBe(0); + expect(f.calls).toEqual([{ path: "/api/client-integrations/hermes", body: { enabled: true, modelNames: "alias", overwriteConflict: true } }]); +}); diff --git a/tests/cli/cli-route-to-confirmation.test.ts b/tests/cli/cli-route-to-confirmation.test.ts new file mode 100644 index 00000000000..8883bb5d29a --- /dev/null +++ b/tests/cli/cli-route-to-confirmation.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { handleRouteToCommand, type RouteToDeps } from "../../src/cli/route-to"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +let home: TempHome, out: ReturnType, err: ReturnType; +beforeEach(() => { home = createTempHome("route-confirm-"); out = spyOn(console, "log").mockImplementation(() => {}); err = spyOn(console, "error").mockImplementation(() => {}); }); +afterEach(() => { out.mockRestore(); err.mockRestore(); home.remove(); }); +const id = "lnk_0123456789abcdef"; +function status(device = id, client = "aside", applied: string | null = "current", alias = "child") { + return { schema: "ocx.route-to/1", devices: [{ id: device, alias, clients: [{ client, intent: "on", source: "device", applied }] }] }; +} +function fixture(body: unknown, code = 200): RouteToDeps { + return { readChildContext: () => null, baseUrl: "http://127.0.0.1:18274", fetchImpl: (async () => Response.json(body, { status: code })) as typeof fetch }; +} +test.each([ + { body: status("wrong"), device: id }, + { body: status(id, "codex"), device: id }, + { body: status(id, "aside", "current", "wrong-alias"), device: "child" }, + { body: status(id), device: undefined }, +])("mutation rejects missing requested device/client: $device", async ({ body, device }) => { + expect(await handleRouteToCommand(["aside", "on", ...(device ? ["--device", device] : [])], fixture(body))).not.toBe(0); + expect(err.mock.calls.flat().join("\n")).toContain("unverified"); + expect(out.mock.calls).toHaveLength(0); +}); +test.each(["child", id])("matching pending mutation remains valid for %s", async device => { + expect(await handleRouteToCommand(["aside", "on", "--device", device], fixture(status(id, "aside", null)))).toBe(0); +}); +test.each([false, true])("failed sync prints matrix before returning nonzero (json %s)", async json => { + const body = { ...status(), syncs: [{ id, state: "failed", reason: "fixture_failed" }] }; + expect(await handleRouteToCommand(["sync", "--device", id, ...(json ? ["--json"] : [])], fixture(body, 502))).not.toBe(0); + const printed = out.mock.calls.flat().join("\n"); + expect(printed).toContain("fixture_failed"); + if (json) expect(JSON.parse(printed)).toEqual(body); +}); +test("failed sync in a successful HTTP response still exits nonzero", async () => { + expect(await handleRouteToCommand(["sync", "--device", id], fixture({ ...status(), syncs: [{ id, state: "failed" }] }))).not.toBe(0); +}); diff --git a/tests/cli/cli-route-to.test.ts b/tests/cli/cli-route-to.test.ts new file mode 100644 index 00000000000..773c19303e6 --- /dev/null +++ b/tests/cli/cli-route-to.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { handleRouteToCommand as command, type RouteToDeps } from "../../src/cli/route-to"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { LinkChildContext } from "../../src/cli/link-child"; +import { resolveHelpPath } from "../../src/cli/help-catalog"; +import { dispatchCommand, type CliDispatchDeps } from "../../src/cli/dispatch"; +import { parseCliHead } from "../../src/cli/root"; +import { CAPABILITIES } from "../../src/cli/capabilities"; +import { ROUTE_CLIENT_IDS } from "../../src/link/route-intent"; + +let home: TempHome; +let out: ReturnType, err: ReturnType, network: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-route-to-cli-"); + out = spyOn(console, "log").mockImplementation(() => {}); + err = spyOn(console, "error").mockImplementation(() => {}); + network = spyOn(globalThis, "fetch").mockImplementation(async () => { throw new Error("Network denied"); }); +}); +afterEach(() => { network.mockRestore(); out.mockRestore(); err.mockRestore(); home.remove(); }); + +const status = { schema: "ocx.route-to/1", devices: [ + { id: "home", alias: "home", clients: ROUTE_CLIENT_IDS.map(client => ({ client, intent: "on", source: "default", applied: "current", ...(client === "aside" ? { target: "u0,u1" } : {}) })) }, + { id: "lnk_0123456789abcdef", alias: "child-test", clients: ROUTE_CLIENT_IDS.map(client => ({ client, intent: client === "grok" ? "off" : "on", source: "device", applied: client === "grok" ? "not_installed" : "current" })) }, +] }; +const child = { linkId: "lnk_0123456789abcdef", homePhrase: "Home 'home-test'", connection: {} } as LinkChildContext; + +function fixture(response: unknown = status, code = 200) { + const calls: Array<{ path: string; method: string; body: unknown }> = []; + let probes = 0; + const deps: RouteToDeps = { + readChildContext: () => null, + findLiveProxy: async () => { probes++; return { hostname: "127.0.0.1", port: 10100, pid: 1, source: "runtime" }; }, + fetchImpl: (async (input, init) => { + expect(init?.redirect).toBe("error"); + const url = new URL(String(input)); + calls.push({ path: url.pathname + url.search, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : null }); + return Response.json(response, { status: code }); + }) as typeof fetch, + }; + return { deps, calls, probes: () => probes }; +} + +describe("Home route-to wire contract", () => { + test("a client decision reports unrelated conflicts without treating them as its own failure", async () => { + const result = structuredClone(status); + result.devices[0]!.clients.find(row => row.client === "claude-desktop")!.applied = "conflict"; + expect(await command(["opencode", "on"], fixture(result).deps)).toBe(0); + expect(err.mock.calls.flat().join("\n")).toContain("other clients"); + expect(await command(["claude-desktop", "on"], fixture(result).deps)).toBe(5); + expect(await command(["sync"], fixture(result).deps)).toBe(5); + }); + test("an empty device matrix cannot confirm a mutation", async () => { + expect(await command(["aside", "on"], fixture({ schema: "ocx.route-to/1", devices: [] }).deps)).toBe(1); + expect(err.mock.calls.flat().join("\n")).toContain("unverified"); + }); + test("an incomplete local sync exposes deferred state and exits nonzero", async () => { + const result = { ...status, deferred: { reason: "intent_or_catalog_pending", clients: ["aside"] } }; + expect(await command(["sync", "--json"], fixture(result).deps)).toBe(1); + expect(JSON.parse(out.mock.calls[0]![0]).deferred).toEqual(result.deferred); + expect(err.mock.calls.flat().join("\n")).toContain("deferred"); + }); + test("a notified Child request stays pending in human output until an apply report arrives", async () => { + const f = fixture({ ...status, syncs: [{ id: "child-test", state: "pending", reason: "Immediate reconciliation requested; awaiting the Child apply report" }] }); + expect(await command(["sync", "--device", "child-test"], f.deps)).toBe(0); + expect(out.mock.calls.flat().join("\n")).toContain("sync child-test: pending (Immediate reconciliation requested; awaiting the Child apply report)"); + expect(f.calls).toHaveLength(1); + }); + test.each([undefined, "all", "this", "child-test"])("status device %s", async device => { + const f = fixture(); + expect(await command(["status", ...(device ? ["--device", device] : []), "--json"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ path: `/api/route-to${device ? `?device=${device}` : ""}`, method: "GET", body: null }]); + expect(JSON.parse(out.mock.calls[0]![0])).toEqual(status); + }); + test.each([ + { argv: ["aside", "on"], body: { client: "aside", action: "on" } }, + { argv: ["aside", "on", "--accounts", "user-a,user-b", "--device", "child-test"], body: { client: "aside", action: "on", accounts: ["user-a", "user-b"], device: "child-test" } }, + { argv: ["aside", "on", "--accounts=none"], body: { client: "aside", action: "on", accounts: "none" } }, + { argv: ["aside", "on", "--accounts=all"], body: { client: "aside", action: "on", accounts: "all" } }, + { argv: ["hermes", "on", "--model-names=alias", "--overwrite-conflict"], body: { client: "hermes", action: "on", modelNames: "alias", overwriteConflict: true } }, + { argv: ["commandcode", "on", "--model-names=routed"], body: { client: "commandcode", action: "on", modelNames: "routed" } }, + { argv: ["codex", "off", "--device=this"], body: { client: "codex", action: "off", device: "this" } }, + { argv: ["pi", "inherit", "--device=child-test"], body: { client: "pi", action: "inherit", device: "child-test" } }, + ])("PUT $argv", async ({ argv, body }) => { + const f = fixture(); + expect(await command([...argv, "--json"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ path: "/api/route-to", method: "PUT", body }]); + }); + test("sync is a single POST and human status exposes each outcome", async () => { + const f = fixture(); + expect(await command(["sync", "--device", "all", "--overwrite-conflict"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ path: "/api/route-to/sync", method: "POST", body: { device: "all", overwriteConflict: true } }]); + const text = out.mock.calls.map(call => call[0]).join("\n"); + expect(text).toContain("home aside on default current u0,u1"); + expect(text).toContain("child-test grok off device not_installed -"); + }); + test("conflict is not retried or silently overwritten", async () => { + const f = fixture({ error: "conflict" }, 409); + expect(await command(["aside", "on"], f.deps)).toBe(5); + expect(f.calls).toHaveLength(1); + expect(f.calls[0]!.body).toEqual({ client: "aside", action: "on" }); + }); + test("unverified response cannot claim success", async () => { + const f = fixture({ ok: true }); + expect(await command(["aside", "on"], f.deps)).toBe(1); + expect(out.mock.calls).toEqual([]); + }); + test.each([ + { client: "aside", intent: "on", source: "default" }, + { client: "aside", intent: "on", source: "default", applied: "invented" }, + { client: "aside", intent: "on", source: "default", applied: null, target: {} }, + ])("malformed client rows cannot become confirmed status: %j", async row => { + const f = fixture({ schema: "ocx.route-to/1", devices: [{ id: "home", clients: [row] }] }); + expect(await command(["status", "--json"], f.deps)).toBe(1); + expect(out.mock.calls).toEqual([]); + }); + test("per-client failure is printed and returns nonzero", async () => { + const f = fixture({ schema: "ocx.route-to/1", devices: [{ id: "home", clients: [{ client: "pi", intent: "on", source: "default", applied: "failed" }] }] }); + expect(await command(["sync", "--json"], f.deps)).toBe(5); + expect(JSON.parse(out.mock.calls[0]![0]).devices[0].clients[0].applied).toBe("failed"); + }); +}); + +test.each([ + ["bogus", "on"], ["aside", "maybe"], ["aside", "inherit"], ["status", "extra"], + ["aside", "on", "--device=all"], + ["status", "--accounts=all"], ["sync", "--model-names=alias"], ["status", "--overwrite-conflict"], + ["pi", "on", "--accounts=all"], ["aside", "off", "--accounts=none"], + ["aside", "on", "--accounts=u/0"], ["aside", "on", "--accounts=user-a,,user-b"], + ["aside", "on", "--accounts=U0"], ["aside", "on", "--accounts=user/path"], + ["aside", "on", `--accounts=${"a".repeat(129)}`], + ["aside", "on", "--accounts=user-a,user-a"], ["aside", "on", "--accounts=all,user-a"], + ["hermes", "on", "--model-names=invalid"], ["pi", "on", "--model-names=alias"], + ["pi", "off", "--overwrite-conflict"], ["pi", "on", "--local"], ["sync", "--local"], + ["status", "--device="], ["status", "--device", " "], ["status", "--device=a", "--device=b"], + ["status", "--json=true"], ["status", "--json", "--json"], ["status", "--unknown"], +])("invalid input refuses before discovery %j", async (...argv) => { + const f = fixture(); + let contextReads = 0; + f.deps.readChildContext = () => { contextReads++; return child; }; + expect(await command(argv, f.deps)).toBe(2); + expect(f.calls).toEqual([]); expect(f.probes()).toBe(0); expect(contextReads).toBe(0); +}); + +function localFixture() { + const f = fixture(); + const localCalls: unknown[][] = []; + f.deps.readChildContext = () => child; + f.deps.loadLocal = async () => ({ + readLocalRouteToStatus: async (...args: unknown[]) => { localCalls.push(["status", ...args]); return status; }, + syncLocalRouteTo: async (...args: unknown[]) => { localCalls.push(["sync", ...args]); return status; }, + setLocalRouteToOff: async (...args: unknown[]) => { localCalls.push(["off", ...args]); return status; }, + }) as Awaited>>; + return { ...f, localCalls }; +} + +describe("Child route-to stays local", () => { + test.each([undefined, "this", child.linkId])("local status for %s", async device => { + const f = localFixture(); + expect(await command(["status", ...(device ? ["--device", device] : []), "--json"], f.deps)).toBe(0); + expect(f.localCalls).toEqual([["status", child]]); + expect(f.calls).toEqual([]); expect(f.probes()).toBe(0); + }); + test("sync forwards explicit conflict decision only to local materializer", async () => { + const f = localFixture(); + expect(await command(["sync", "--overwrite-conflict"], f.deps)).toBe(0); + expect(f.localCalls).toEqual([["sync", child, true]]); + expect(f.calls).toEqual([]); expect(f.probes()).toBe(0); + }); + test.each(["on", "off", "inherit"])("Home-owned %s refuses with exact child id", async action => { + const f = localFixture(); + expect(await command(["aside", action, "--device", "this"], f.deps)).toBe(5); + expect(err.mock.calls.flat().join("\n")).toContain(`ocx route to aside ${action} --device lnk_0123456789abcdef`); + expect(f.calls).toEqual([]); expect(f.localCalls).toEqual([]); expect(f.probes()).toBe(0); + }); + test.each(["off", "inherit"])("local %s toggles opt-out", async action => { + const f = localFixture(); + expect(await command(["aside", action, "--local"], f.deps)).toBe(0); + expect(f.localCalls).toEqual([["off", child, "aside", action === "off"]]); + expect(f.calls).toEqual([]); expect(f.probes()).toBe(0); + }); + test.each(["all", "home-test", "another-link"])("foreign device %s cannot use local status/sync", async device => { + const f = localFixture(); + for (const action of ["status", "sync"]) expect(await command([action, "--device", device], f.deps)).toBe(2); + expect(f.calls).toEqual([]); expect(f.localCalls).toEqual([]); expect(f.probes()).toBe(0); + }); + test("Home refuses --local without any management request", async () => { + const f = fixture(); + expect(await command(["aside", "off", "--local"], f.deps)).toBe(2); + expect(f.calls).toEqual([]); expect(f.probes()).toBe(0); + }); +}); + +test("route dispatch reaches route-to without a live proxy or local writes", async () => { + const argv = ["route", "to", "status", "--json"]; + const head = parseCliHead(argv); + let probes = 0; + expect(await dispatchCommand(head, { args: argv, findLiveProxy: async () => { probes++; return null; } } as unknown as CliDispatchDeps)).toBe(1); + expect(probes).toBe(1); + expect(err.mock.calls.flat().join("\n")).toContain("Proxy is not running"); +}); + +test.each([ + { path: ["route", "to", "status"], method: "GET", endpoint: "/api/route-to" }, + { path: ["route", "to", "sync"], method: "POST", endpoint: "/api/route-to/sync" }, + { path: ["route", "to", "aside", "on"], method: "PUT", endpoint: "/api/route-to" }, + { path: ["route", "to", "codex", "inherit"], method: "PUT", endpoint: "/api/route-to" }, +])("help resolves executable route-to leaves: $path", ({ path, method, endpoint }) => { + const resolved = resolveHelpPath(path); + expect(resolved.kind).toBe("capability"); + if (resolved.kind !== "capability") throw new Error("Missing capability"); + expect(resolved.capability.routes).toEqual([{ method, path: endpoint }]); +}); + +// A plain toggle is a Home-only decision on the client-integration route, never a route-to default write. +test.each(["enable", "disable"])("client %s discovery names the Home-only toggle route", action => { + const resolved = resolveHelpPath(["integration", "client", action]); + if (resolved.kind !== "capability") throw new Error("Missing alias capability"); + expect(resolved.capability.routes).toContainEqual({ method: "PUT", path: "/api/client-integrations/{clientId}" }); + expect(resolved.capability.routes).not.toContainEqual({ method: "PUT", path: "/api/route-to" }); +}); + +test.each(["restore", "preview", "sync"])("client %s discovery preserves its legacy endpoints", action => { + const resolved = resolveHelpPath(["integration", "client", action]); + if (resolved.kind !== "capability") throw new Error("Missing legacy capability"); + expect(resolved.capability.routes).not.toContainEqual({ method: "PUT", path: "/api/route-to" }); +}); + +test("route-to discovery stays bounded while covering all API operations", () => { + const entries = CAPABILITIES.filter(row => row.command[0] === "route" && row.command[1] === "to"); + expect(entries.map(row => row.command.join(" ")).sort()).toEqual(["route to", "route to status", "route to sync"]); + expect(entries.flatMap(row => row.routes)).toEqual(expect.arrayContaining([ + { method: "GET", path: "/api/route-to" }, + { method: "PUT", path: "/api/route-to" }, + { method: "POST", path: "/api/route-to/sync" }, + ])); +}); diff --git a/tests/cli/cli-storage-inspect.test.ts b/tests/cli/cli-storage-inspect.test.ts index 336cc7fc907..b5c04c025db 100644 --- a/tests/cli/cli-storage-inspect.test.ts +++ b/tests/cli/cli-storage-inspect.test.ts @@ -269,10 +269,12 @@ describe("ocx integration native", () => { expect(out).toContain("disable blocked: in use"); }); - test("a toggle PUTs the per-client route with a boolean", async () => { + test("a toggle PUTs the per-client native route, which records only the Home override", async () => { const { calls, deps } = harness(() => ({ json: { ok: true } })); const cap = capture(); - try { await handleIntegrationCommand(["native", "claude-desktop", "off"], deps); } finally { cap.restore(); } + let code: number; + try { code = await handleIntegrationCommand(["native", "claude-desktop", "off"], deps); } finally { cap.restore(); } + expect(code!).toBe(0); expect(calls[0]).toMatchObject({ method: "PUT", path: "/api/native-integrations/claude-desktop", diff --git a/tests/cli/cli-sync-expect-link.test.ts b/tests/cli/cli-sync-expect-link.test.ts index 26edbc95497..7f1953fd075 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -329,6 +329,14 @@ describe("ocx sync --expect-link / --expect-hub-key", () => { expect(readFileSync(DEFAULT_CATALOG_PATH, "utf8")).toBe(HOME_CATALOG); }); + test("pinned sync forwards conflict permission only for the explicitly named client", async () => { + writeClient({ transport: "link" }); + const run = await runSync(["sync", "--expect-link", LINK_ID, "--overwrite-conflict", "--overwrite-client", "aside"]); + expect(run.code).toBe(0); + expect(run.connectedCalls).toEqual([{ restartCodex: false, expect: { kind: "link", linkId: LINK_ID }, + overwriteConflict: true, overwriteClient: "aside" }]); + }); + test("a real link connect paused at its readiness read makes a pinned sync a pending refusal", async () => { writeStandalone(); const configBefore = readFileSync(join(root, "config.json"), "utf8"); diff --git a/tests/cli/cli-sync-fanout-ephemeral.test.ts b/tests/cli/cli-sync-fanout-ephemeral.test.ts new file mode 100644 index 00000000000..05e21315210 --- /dev/null +++ b/tests/cli/cli-sync-fanout-ephemeral.test.ts @@ -0,0 +1,49 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runSyncCommand } from "../../src/cli/sync-command"; +import type { CliDispatchDeps } from "../../src/cli/dispatch"; +import * as codexSync from "../../src/codex/sync"; +import * as statusApi from "../../src/cli/sync-link-status"; +import * as processApi from "../../src/cli/sync-fanout-process"; + +/** ocx sync --overwrite-conflict fans out the plain catalog sync to an ephemeral ocx-child. */ +test("ocx sync never forwards overwrite flags to an ephemeral ocx-child link", async () => { + const temp = mkdtempSync(join(tmpdir(), "ocx-sync-ephemeral-")); + const previousHome = process.env.OPENCODEX_HOME, previousExit = process.exitCode; + process.env.OPENCODEX_HOME = temp; process.exitCode = 0; + const linkDir = join(temp, "link"); mkdirSync(linkDir, { recursive: true }); + const base = { direction: "hub-initiated" as const, hostKeyFingerprint: "SHA256:AAAAAAAAAAAAAAAA", apiKeyId: "test", createdAt: "2026-01-01T00:00:00Z", remotePlatform: "posix" as const }; + const links = [ + { ...base, id: "lnk_0123456789abcdef", alias: "full", tunnelPort: 27001, childKind: "full" as const }, + { ...base, id: "lnk_fedcba9876543210", alias: "devbox", tunnelPort: 27002, childKind: "ephemeral" as const }, + ]; + writeFileSync(join(temp, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai" })); + writeFileSync(join(linkDir, "links.json"), JSON.stringify({ version: 1, listenerPort: 27000, links })); + const synced = { status: "catalog-only" as const, ok: true, added: 0, catalogPath: "fixture", catalogExists: true, catalogWritten: false, cacheSynced: false, message: "done", refreshOutcome: "committed" as const }; + const sync = spyOn(codexSync, "syncModelsToCodex").mockResolvedValue(synced); + const status = spyOn(statusApi, "readHomeLinkStatus").mockResolvedValue({ role: "home", listener: { state: "listening", port: 27000 }, child: null, + links: links.map(link => ({ ...link, state: "connected", since: link.createdAt, reason: null, lastApplyAt: null, runtimeStartedAt: null, appliedCodexSettingsDigest: null })) }); + const commands = new Map(); + const child = spyOn(processApi, "runFanoutProcess").mockImplementation(async argv => { + const command = argv.at(-1)!; + commands.set(command.includes("lnk_fedcba9876543210") ? "devbox" : "full", command); + return { code: 0, stdout: "ocx-fanout:stdout-begin\nRemote Link Home catalog synchronized.", stderr: "ocx-fanout:selected:2.100.0\n" }; + }); + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const deps = { args: ["sync", "--json", "--overwrite-conflict", "--overwrite-client", "pi"], findLiveProxy: async () => null } as unknown as CliDispatchDeps; + expect(await runSyncCommand(deps)).toBe(0); + expect(commands.get("full")).toContain("--overwrite-conflict --overwrite-client pi"); + expect(commands.get("devbox")).toBeDefined(); + expect(commands.get("devbox")).not.toContain("--overwrite"); + expect(commands.get("devbox")).not.toContain("child-ephemeral-no-overwrite"); + } finally { + sync.mockRestore(); status.mockRestore(); child.mockRestore(); log.mockRestore(); error.mockRestore(); warn.mockRestore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + process.exitCode = previousExit ?? 0; rmSync(temp, { recursive: true, force: true }); + } +}); diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index f3472bc4b71..9e07721c2af 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -517,10 +517,10 @@ function runTransactionScenario( connected = await connectClient({ serverUrl: "https://hub.example.test", credential: { kind: "admin", value: credential }, - selectedClients: ["claude"], + selectedClients: stage === "preflight" ? ["codex"] : ["claude"], managementTransport: "direct", noSync: true, - }, { fetchImpl, now: () => { + }, { fetchImpl, catalogCompatibility: { supportedEfforts: () => new Set() }, now: () => { if (stage === "commit") setPersistedConfigMutationBeforeCommitForTests(() => { commitFaultTriggered = true; throw new Error("fixture_final_client_commit_failed"); @@ -1190,7 +1190,7 @@ function runDesktopLifecycleScenario(mode: string) { await (async () => { let result = null, error = null, second = null, statusInside = null, statusOutside = null, cliRotation = null; const credential = new TextEncoder().encode("ocx_admin_fixture"); - const deps = { fetchImpl, lifecycleLockDeps: lockDeps }; + const deps = { fetchImpl, lifecycleLockDeps: lockDeps, catalogCompatibility: { supportedEfforts: () => new Set() } }; try { if (mode.startsWith("disconnect")) { let journalSpy; diff --git a/tests/clients/client-disconnect-config-recovery.test.ts b/tests/clients/client-disconnect-config-recovery.test.ts new file mode 100644 index 00000000000..aca0115ba9c --- /dev/null +++ b/tests/clients/client-disconnect-config-recovery.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function scenario(mode: string, windows = false) { + const root = mkdtempSync(join(tmpdir(), "ocx-disconnect-recover-")); + const ocx = join(root, "ocx"), codex = join(root, "codex"); + mkdirSync(ocx); mkdirSync(codex); + const eol = windows ? "\r\n" : "\n"; + const edits = ['model = "user/model"', '[hooks]', 'notify = ["user-hook"]', + windows ? "[projects.'C:\\work\\project']" : "[projects.'/work/project']", 'trust_level = "trusted"', + '[mcp_servers.computer_use]', windows ? "command = 'C:\\tools\\computer-use.exe'" : 'command = "/opt/computer-use"'].join(eol) + eol; + const marker = ["legacy-unmarked", "legacy-recorded", "edited-unmarked"].includes(mode) ? "" : "# Auto-injected by opencodex" + eol; + const current = marker + 'openai_base_url = "https://edited.example/v1"' + eol + edits; + writeFileSync(join(codex, "config.toml"), current); + const script = ` + const {readFileSync,writeFileSync,existsSync,readdirSync,statSync} = require("node:fs"); + const {createHash} = require("node:crypto"); + const {handleDisconnectCommand} = require("./src/cli/connect"); + const {readClientConnectionState} = require("./src/client/state"); + const {readDesktopDisconnectReceipt} = require("./src/claude/desktop-remote-store"); + const {tryAcquire,release} = require("./src/codex/prompt-lock"); + const c=process.env.CODEX_HOME,h=process.env.OPENCODEX_HOME; + const mode=${JSON.stringify(mode)}, current=${JSON.stringify(current)}; + const hash=v=>createHash("sha256").update(v).digest("hex"); + const token="fixture-recovery-key",catalog='{"models":[]}'; + const connection={serverUrl:"https://home.example.test",managementUrl:"https://home.example.test",managementTransport:"direct", + selectedClients:["codex"],tokenEnv:"OPENCODEX_API_AUTH_TOKEN",apiKeyId:"client-1",tokenFingerprint:hash(token), + protocolVersion:1,connectedAt:"2026-11-07T00:00:00.000Z",catalogFingerprint:createHash("sha256").update(catalog).digest("base64url"),catalogSyncedAt:"2026-11-07T00:00:00.000Z"}; + writeFileSync(h+"/config.json",JSON.stringify({port:10100,providers:{},defaultProvider:"openai",runtimeRole:"client",client:connection})); + writeFileSync(h+"/service-api-token",token,{mode:0o600}); + writeFileSync(c+"/opencodex-catalog.json",catalog); + const legacy=mode.startsWith("legacy"); + const journal={version:1,originalConfig:Buffer.from('model = "before"\\n').toString("base64"),originalProfile:null, + ...(legacy?{}:{injectedConfigHash:hash('openai_base_url = "http://127.0.0.1:10100/v1"\\nmodel = "before"\\n'), + injectedProfileHash:mode==="profile-known"?hash('model_provider = "opencodex"\\n'):null,injectedOpenaiBaseUrl:"http://127.0.0.1:10100/v1"}), + ...(mode==="legacy-recorded"?{injectedOpenaiBaseUrl:"http://127.0.0.1:10100/v1"}:{}), + owner:mode==="wrong-process"?{kind:"process",pid:process.pid}:{kind:"client",apiKeyId:mode==="wrong-owner"?"other-client":"client-1"},pid:process.pid,timestamp:"2026-11-07T00:00:00.000Z"}; + writeFileSync(c+"/opencodex-journal.json",JSON.stringify(journal)); + if(mode==="profile-unknown") writeFileSync(c+"/opencodex.config.toml",'model = "private-profile-edit"\\n'); + if(mode==="profile-known") writeFileSync(c+"/opencodex.config.toml",'model_provider = "opencodex"\\n'); + const deps={lifecycleLockDeps:{lockPath:h+"/lifecycle.sqlite"},linkTeardownDeps:{reapOrphanTunnel:()=>{throw new Error("teardown ran before confirmation")}}}; + const first=await handleDisconnectCommand(mode==="confirmation"?["--recover-config"]:[],deps); + const receiptAfterFirst=readDesktopDisconnectReceipt().kind; + const beforeRecovery=readFileSync(c+"/config.toml","utf8"); + if(mode==="journal-race" || mode==="connection-race") { + const section=require("./src/codex/inject/config-write-section"); + const begin=section.beginCodexWriteSection; + require("bun:test").spyOn(section,"beginCodexWriteSection").mockImplementation(held=>{ + begin(held); + if(mode==="journal-race") { + const changed=JSON.parse(readFileSync(c+"/opencodex-journal.json","utf8")); + changed.owner={kind:"client",apiKeyId:"changed-under-lock"}; + writeFileSync(c+"/opencodex-journal.json",JSON.stringify(changed)); + } else { + const changed=JSON.parse(readFileSync(h+"/config.json","utf8")); + changed.client.apiKeyId="changed-under-lock"; + writeFileSync(h+"/config.json",JSON.stringify(changed)); + } + }); + } + const recover=()=>handleDisconnectCommand(["--recover-config","--yes","--json"],{lifecycleLockDeps:deps.lifecycleLockDeps}); + let second=null; + if(mode==="locked") { + const held=tryAcquire(c+"/config.toml.ocx-write.lock"); + if(!held.ok) throw new Error("fixture lock failed"); + try {second=await recover();} finally {release(held.handle);} + } else if(mode!=="confirmation") second=await recover(); + const after=readFileSync(c+"/config.toml","utf8"); + const backups=readdirSync(c).filter(n=>n.startsWith(".ocx-disconnect-recovery-")); + const backup=backups.length?JSON.parse(readFileSync(c+"/"+backups[0]+"/snapshot.json","utf8")):null; + const backupModes=backups.length?[statSync(c+"/"+backups[0]).mode&0o777,statSync(c+"/"+backups[0]+"/snapshot.json").mode&0o777]:null; + const repeat=second===0?await handleDisconnectCommand(["--recover-config","--yes","--json"],{lifecycleLockDeps:deps.lifecycleLockDeps}):null; + console.log("RESULT:"+JSON.stringify({first,second,repeat,receiptAfterFirst,beforeRecovery,after,backups,backup,backupModes, + token:existsSync(h+"/service-api-token"),journal:existsSync(c+"/opencodex-journal.json"),state:readClientConnectionState().kind, + profile:existsSync(c+"/opencodex.config.toml")?readFileSync(c+"/opencodex.config.toml","utf8"):null})); + `; + try { + const result = spawnSync(process.execPath, ["--eval", script], {cwd: repoRoot(), encoding: "utf8", timeout: 30_000, + env: {...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: ocx, CODEX_HOME: codex, CODEX_SQLITE_HOME: codex}}); + expect(result.status, result.stderr).toBe(0); + const line = result.stdout.split("\n").find(line => line.startsWith("RESULT:")); + expect(line, result.stdout + result.stderr).toBeDefined(); + return {...JSON.parse(line!.slice(7)), stderr: result.stderr, current, edits}; + } finally { removeTreeWithRetry(root); } +} + +test("recovery requires --yes before any teardown or receipt", () => { + const r = scenario("confirmation"); + expect(r.first).toBe(2); + expect(r.stderr).toContain("--recover-config requires --yes"); + expect(r.stderr).not.toContain("teardown ran"); + expect(r.after).toBe(r.current); + expect(r.backups).toHaveLength(0); + expect(r.token).toBe(true); + expect(r.receiptAfterFirst).toBe("absent"); +}); + +test.each([false, true])("explicit recovery resolves edited owned URL and preserves edits (CRLF: %s)", windows => { + const r = scenario("edited", windows); + expect(r.first).not.toBe(0); + expect(r.beforeRecovery).toBe(r.current); + expect(r.second, r.stderr).toBe(0); + expect(r.repeat, r.stderr).toBe(0); + expect(r.after).toBe(r.edits); + expect(r.backups).toHaveLength(1); + expect(r.backup.config).toBe(r.current); + expect(r.backup.profile).toBeNull(); + expect(r.backup.journal.owner).toEqual({kind: "client", apiKeyId: "client-1"}); + if (process.platform !== "win32") expect(r.backupModes).toEqual([0o700, 0o600]); + expect(r.state).toBe("disconnected"); + expect(r.token).toBe(false); + expect(r.journal).toBe(false); +}); + +test("hashless legacy journal uses exact structural marker plus matching client owner", () => { + const r = scenario("legacy-marked"); + expect(r.first).not.toBe(0); + expect(r.second, r.stderr).toBe(0); + expect(r.after).toBe(r.edits); + expect(r.backup.config).toBe(r.current); + expect(r.backup.journal.injectedConfigHash).toBeUndefined(); +}); + +test.each(["edited-unmarked", "legacy-recorded"])("recorded field evidence survives marker removal: %s", mode => { + const r = scenario(mode); + expect(r.first).not.toBe(0); + expect(r.second, r.stderr).toBe(0); + expect(r.after).toBe(r.edits); + expect(r.backup.config).toBe(r.current); +}); + +test("known unchanged generated profile is backed up before its removal", () => { + const r = scenario("profile-known"); + expect(r.second, r.stderr).toBe(0); + expect(r.backup.profile).toBe('model_provider = "opencodex"\n'); + expect(r.profile).toBeNull(); + expect(r.after).toBe(r.edits); +}); + +test.each(["legacy-unmarked", "wrong-owner", "wrong-process", "profile-unknown", "locked", "journal-race", "connection-race"])("recovery refuses insufficient ownership or unavailable lock: %s", mode => { + const r = scenario(mode); + expect(r.second).not.toBe(0); + expect(r.after).toBe(r.current); + expect(r.state).toBe("connected"); + expect(r.token).toBe(true); + expect(r.journal).toBe(true); + expect(r.backups).toHaveLength(0); + if (mode === "profile-unknown") expect(r.profile).toBe('model = "private-profile-edit"\n'); +}); diff --git a/tests/clients/client-disconnect-edited-codex.test.ts b/tests/clients/client-disconnect-edited-codex.test.ts new file mode 100644 index 00000000000..c45e442829d --- /dev/null +++ b/tests/clients/client-disconnect-edited-codex.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function scenario(windows: boolean, conflict = false, hashless = false) { + const root = mkdtempSync(join(tmpdir(), "ocx-disconnect-edits-")); + const ocx = join(root, "ocx"), codex = join(root, "codex"); + mkdirSync(ocx); mkdirSync(codex); + const eol = windows ? "\r\n" : "\n"; + const edits = [ + 'model = "user-provider/new-model"', + '[hooks]', 'notify = ["user-hook"]', + windows ? '[projects.\'C:\\work\\project\']' : '[projects.\'/work/project\']', + 'trust_level = "trusted"', '[mcp_servers.computer_use]', + windows ? "command = 'C:\\tools\\computer-use.exe'" : 'command = "/opt/tools/computer-use"', + ].join(eol) + eol; + const url = "http://127.0.0.1:10100/v1"; + const injected = `openai_base_url = "${url}"${eol}experimental_realtime_ws_base_url = "${url}"${eol}model_catalog_json = ${JSON.stringify(join(codex, "opencodex-catalog.json"))}${eol}model = "old-model"${eol}`; + writeFileSync(join(codex, "config.toml"), injected.replace('model = "old-model"' + eol, "") .replace(url, conflict ? "https://user.example/v1" : url) + edits); + const script = ` + const { readFileSync, writeFileSync, existsSync } = require("node:fs"); + const { createHash } = require("node:crypto"); + const { disconnectClient } = require("./src/client/connect"); + const { readClientConnectionState } = require("./src/client/state"); + const { writeJournal, markJournalInjectedState } = require("./src/codex/journal"); + const c = process.env.CODEX_HOME, h = process.env.OPENCODEX_HOME; + const hash = value => createHash("sha256").update(value).digest("hex"); + const token = "fixture-disconnect-key", catalog = '{"models":[]}'; + const config = {port:10100,providers:{},defaultProvider:"openai",runtimeRole:"client",client:{ + serverUrl:"https://home.example.test",managementUrl:"https://home.example.test",managementTransport:"direct", + selectedClients:["codex"],tokenEnv:"OPENCODEX_API_AUTH_TOKEN",apiKeyId:"client-1",tokenFingerprint:hash(token), + protocolVersion:1,connectedAt:"2026-11-07T00:00:00.000Z", + catalogFingerprint:createHash("sha256").update(catalog).digest("base64url"),catalogSyncedAt:"2026-11-07T00:00:00.000Z"}}; + writeFileSync(h+"/config.json", JSON.stringify(config)); + writeFileSync(h+"/service-api-token",token,{mode:0o600}); + writeFileSync(c+"/opencodex-catalog.json",catalog); + const journal = {version:1,originalConfig:Buffer.from('model = "old-model"\\n').toString("base64"),originalProfile:null, + ${hashless ? "" : "injectedConfigHash:hash(" + JSON.stringify(injected) + "),injectedProfileHash:null,"} + injectedOpenaiBaseUrl:${JSON.stringify(url)},injectedRealtimeWsBaseUrl:${JSON.stringify(url)}, + injectedCatalogPath:c+"/opencodex-catalog.json",owner:{kind:"client",apiKeyId:"client-1"},pid:999999,timestamp:"2026-11-07T00:00:00.000Z"}; + writeFileSync(c+"/opencodex-journal.json", JSON.stringify(journal)); + let result, error; + try { result = await disconnectClient({}, {lifecycleLockDeps:{lockPath:h+"/lifecycle.sqlite"}}); } + catch(e) { error=e.message; } + const after = readFileSync(c+"/config.toml","utf8"); + const retained = existsSync(c+"/opencodex-journal.json"); + let relinkBaseline; + if (result) { + // The next injection must journal the user's edited standalone file, not the old baseline. + writeJournal({currentStateIsNative:true,owner:{kind:"client",apiKeyId:"client-2"}}); + relinkBaseline = Buffer.from(JSON.parse(readFileSync(c+"/opencodex-journal.json","utf8")).originalConfig,"base64").toString("utf8"); + markJournalInjectedState(after,null,{injectedOpenaiBaseUrl:null,injectedRealtimeWsBaseUrl:null,injectedCatalogPath:null}); + } + console.log(JSON.stringify({result,error,after,retained,relinkBaseline,state:readClientConnectionState().kind,token:existsSync(h+"/service-api-token")})); + `; + try { + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot(), encoding: "utf8", timeout: 30_000, + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: ocx, CODEX_HOME: codex, CODEX_SQLITE_HOME: codex }, + }); + expect(child.status, child.stderr).toBe(0); + return { ...JSON.parse(child.stdout.trim()), edits }; + } finally { removeTreeWithRetry(root); } +} + +test.each([false, true])("disconnect preserves post-injection edits and relink baseline (Windows text: %s)", windows => { + const result = scenario(windows); + expect(result.error).toBeUndefined(); + expect(result.result.restored).toBe(true); + expect(result.after).toBe(result.edits); + expect(result.relinkBaseline).toBe(result.edits); + expect(result.retained).toBe(false); + expect(result.state).toBe("disconnected"); + expect(result.token).toBe(false); +}); + +test("a changed owned URL refuses without clearing the credential or journal", () => { + const result = scenario(false, true); + expect(result.error).toContain("ocx disconnect"); + expect(result.after).toContain('openai_base_url = "https://user.example/v1"'); + expect(result.retained).toBe(true); + expect(result.state).toBe("connected"); + expect(result.token).toBe(true); +}); + +test("a changed hashless journal remains unverified", () => { + const result = scenario(false, false, true); + expect(result.error).toBeDefined(); + expect(result.after).toContain("openai_base_url"); + expect(result.retained).toBe(true); + expect(result.state).toBe("connected"); + expect(result.token).toBe(true); +}); diff --git a/tests/clients/client-disconnect-field-snapshot.test.ts b/tests/clients/client-disconnect-field-snapshot.test.ts new file mode 100644 index 00000000000..1bdf4245844 --- /dev/null +++ b/tests/clients/client-disconnect-field-snapshot.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function scenario(dropSnapshot = false) { + const root = mkdtempSync(join(tmpdir(), "ocx-disconnect-field-snapshot-")); + const ocx = join(root, "ocx"), codex = join(root, "codex"); + mkdirSync(ocx); mkdirSync(codex); + writeFileSync(join(codex, "config.toml"), 'model = "native-model"\n'); + writeFileSync(join(ocx, "config.json"), JSON.stringify({ port: 10100, providers: {}, defaultProvider: "openai" })); + const script = ` + const { readFileSync, writeFileSync, existsSync } = require("node:fs"); + const { createHash } = require("node:crypto"); + const { connectClient, disconnectClient } = require("./src/client/connect"); + const c = process.env.CODEX_HOME, h = process.env.OPENCODEX_HOME; + const catalog = '{"models":[]}'; + const fetchImpl = async (input, init = {}) => { + const url = String(input); + if (url.endsWith("/readyz")) return Response.json({service:"opencodex",version:"0.0.0",uptime:1,pid:1, + port:443,status:"ready",protocol:1,minimumClientProtocol:1,managementUrl:"https://manage.example.test"}); + if (url.endsWith("/api/keys") && init.method === "POST") return Response.json({id:"snapshot-client",name:"fixture", + key:"ocx_data_"+"d".repeat(40),createdAt:"2026-11-07T00:00:00.000Z"},{status:201}); + if (url.endsWith("/v1/catalog")) return new Response(catalog,{headers:{"Content-Type":"application/json", + ETag:'"sha256-'+createHash("sha256").update(catalog).digest("base64url")+'"'}}); + throw new Error("unexpected fixture request "+url); + }; + await connectClient({serverUrl:"https://hub.example.test",managementTransport:"direct",selectedClients:["codex"], + credential:{kind:"admin",value:new TextEncoder().encode("ocx_admin_fixture-authority")}}, + {fetchImpl,catalogCompatibility:{supportedEfforts:()=>new Set()},lifecycleLockDeps:{lockPath:h+"/lifecycle.sqlite"}}); + const journalPath=c+"/opencodex-journal.json"; + const journal=JSON.parse(readFileSync(journalPath,"utf8")); + const fields=journal.injectedConfigFields; + const injected=readFileSync(c+"/config.toml","utf8"); + const user='[hooks]\\nnotify = ["fixture-user-hook"]\\n'; + const edited=injected.replace(/^model\\s*=.*$/m,'model = "user-edited-model"')+user; + writeFileSync(c+"/config.toml",edited); + if (${dropSnapshot}) { delete journal.injectedConfigFields; writeFileSync(journalPath,JSON.stringify(journal)); } + let result,error; + try { result=await disconnectClient({}, {lifecycleLockDeps:{lockPath:h+"/lifecycle.sqlite"}}); } + catch(e) {error=e.message;} + const after=readFileSync(c+"/config.toml","utf8"); + console.log(JSON.stringify({fields,injected,edited,after,parsed:Bun.TOML.parse(after),result,error, + retained:existsSync(journalPath),token:existsSync(h+"/service-api-token")})); + `; + try { + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot(), encoding: "utf8", timeout: 30_000, + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: ocx, CODEX_HOME: codex, CODEX_SQLITE_HOME: codex }, + }); + expect(child.status, child.stderr).toBe(0); + return JSON.parse(child.stdout.trim()); + } finally { removeTreeWithRetry(root); } +} + +test("real Codex connect persists field snapshots used to disconnect after user edits", () => { + const result = scenario(); + expect(result.fields.model_provider).toContain('"opencodex"'); + expect(result.fields["model_providers.opencodex"]).toContain("base_url"); + expect(result.error).toBeUndefined(); + expect(result.result.restored).toBe(true); + expect(result.parsed.model).toBe("user-edited-model"); + expect(result.parsed.hooks.notify).toEqual(["fixture-user-hook"]); + expect(result.parsed.model_provider).toBeUndefined(); + expect(result.parsed.model_providers?.opencodex).toBeUndefined(); + expect(result.retained).toBe(false); + expect(result.token).toBe(false); +}); + +test("the same edited provider-table disconnect requires the persisted field snapshot", () => { + const result = scenario(true); + expect(result.error).toBeDefined(); + expect(result.after).toBe(result.edited); + expect(result.retained).toBe(true); + expect(result.token).toBe(true); +}); diff --git a/tests/clients/client-home-unreachable.test.ts b/tests/clients/client-home-unreachable.test.ts index 9ce57ed293f..97f136b2942 100644 --- a/tests/clients/client-home-unreachable.test.ts +++ b/tests/clients/client-home-unreachable.test.ts @@ -27,6 +27,12 @@ describe("a Child or Hub client cannot serve from local providers", () => { test("control: the standalone server does reach provider dispatch, so the walk is not vacuous", () => { expect(firstLoadTimePathTo("src/server/index.ts", path => LOCAL_SERVING.test(path))).not.toBeNull(); }); + test("route-intent transport and receipt helpers do not eagerly load device writers", () => { + const writers = /\/src\/client\/route-intent-apply\.ts$|\/src\/integrations\/route-intent\.ts$/; + for (const entry of ["src/integrations/route-intent-connected.ts", "src/integrations/route-intent-disconnect.ts"]) { + expect(firstLoadTimePathTo(entry, path => writers.test(path))).toBeNull(); + } + }); }); describe("home_unreachable answers", () => { diff --git a/tests/clients/home-route-intent-upgrade.test.ts b/tests/clients/home-route-intent-upgrade.test.ts new file mode 100644 index 00000000000..d64ca0ddcf6 --- /dev/null +++ b/tests/clients/home-route-intent-upgrade.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { applyIntegration } from "../../src/integrations/writer"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { projectHomeRouteIntent } from "../../src/link/route-intent"; +import { initializeHomeRouteIntentDefaults, readHomeRouteIntentState, seedRouteDeviceClients } from "../../src/routing/home-route-intent"; +import { readRouteIntentState, recordHomeNativeDecision, routeIntentStatePath, setRouteClientIntent, writeRouteIntentState } from "../../src/routing/route-intent-state"; +import { integrationEnabled } from "../../src/codex/desired-state"; +import { getDefaultConfig, loadConfig, saveConfig } from "../../src/config"; +import { persistNativeRouteIntent } from "../../src/client/route-intent-apply"; +import type { OcxConfig } from "../../src/types"; + +let home: TempHome; +beforeEach(() => { home = createTempHome("route-upgrade-"); }); +afterEach(() => home.remove()); + +test.each([undefined, false, true])("unrelated Home changes preserve existing native intent when flags are %s", flag => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {}, + ...(flag === undefined ? {} : { clientIntegrations: { grok: flag, "claude-desktop": flag } }), + } as OcxConfig; + const expected = { grok: integrationEnabled(config, "grok"), desktop: integrationEnabled(config, "claude-desktop") }; + const before = projectHomeRouteIntent(config, "1", "home", readHomeRouteIntentState(config)); + expect(before.clients.grok.enabled).toBe(expected.grok); + expect(before.clients["claude-desktop"].enabled).toBe(expected.desktop); + initializeHomeRouteIntentDefaults(config, "1", "opencode"); + setRouteClientIntent("opencode", { enabled: true }); + const after = projectHomeRouteIntent(config, "1", "home", readHomeRouteIntentState(config)); + expect(after.clients.grok.enabled).toBe(expected.grok); + expect(after.clients["claude-desktop"].enabled).toBe(expected.desktop); + expect(after.clients.opencode.enabled).toBe(true); + if (flag === undefined) { + const child = projectHomeRouteIntent(config, "1", "lnk_0123456789abcdef", readHomeRouteIntentState(config)); + expect(child.clients.grok.enabled).toBe(false); + expect(child.clients["claude-desktop"].enabled).toBe(false); + } +}); + +test("the Home's own Grok switch governs the Home; defaults govern Children and a decided Home write", () => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + initializeHomeRouteIntentDefaults(config, "1", "opencode"); + const homeIntent = (decided?: "grok", decidedIntent?: { enabled: boolean }) => projectHomeRouteIntent(config, "1", "home", readHomeRouteIntentState(config), + { decidedClient: decided, ...(decidedIntent ? { decidedIntent } : {}) }).clients.grok.enabled; + const childIntent = () => projectHomeRouteIntent(config, "1", "lnk_0123456789abcdef", readHomeRouteIntentState(config)).clients.grok.enabled; + expect(homeIntent()).toBe(true); + setRouteClientIntent("grok", { enabled: false }); + // The absent switch is on; only the write deciding Grok applies the default to the Home. + expect(homeIntent()).toBe(true); + expect(homeIntent("grok")).toBe(false); + expect(childIntent()).toBe(false); + config.clientIntegrations = { grok: false }; + expect(homeIntent()).toBe(false); + // A Home-only decision carries its own value, whatever the default. + expect(homeIntent("grok", { enabled: true })).toBe(true); + config.clientIntegrations = { grok: true }; + expect(homeIntent()).toBe(true); + expect(childIntent()).toBe(false); + expect(projectHomeRouteIntent(config, "1", "home", readHomeRouteIntentState(config)).sources.grok).toBe("native"); +}); + +test.each(["grok", "claude-desktop"] as const)("the first capture saves the off Child builtin for %s and a Home decision records no Home entry", client => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + initializeHomeRouteIntentDefaults(config, "1", "opencode"); + // Grok and Desktop are never captured on, whatever the Home's switch says. + expect(readRouteIntentState().defaults[client]).toEqual({ enabled: false }); + recordHomeNativeDecision(client); + // The switch the decision wrote; a later capture must not spread it to Children. + config.clientIntegrations = { [client]: true }; + initializeHomeRouteIntentDefaults(config, "1"); + const state = readHomeRouteIntentState(config); + expect(state.defaults[client]).toEqual({ enabled: false }); + expect(state.devices.home).toBeUndefined(); + expect(projectHomeRouteIntent(config, "1", "home", state).clients[client].enabled).toBe(true); + expect(projectHomeRouteIntent(config, "1", "home", state, { decidedClient: client }).clients[client].enabled).toBe(false); + expect(projectHomeRouteIntent(config, "1", "lnk_0123456789abcdef", state).clients[client].enabled).toBe(false); +}); + +test.each(["codex", "claude", "grok", "claude-desktop"] as const)("route intent refuses a devices.home entry for native %s and drops an old one", client => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + expect(() => setRouteClientIntent(client, { enabled: false }, "home")).toThrow(); + writeRouteIntentState({ version: 1, defaults: {}, devices: { home: { [client]: { enabled: false }, pi: { enabled: true } } } }); + // An old entry never decides the Home: an absent switch is on. + expect(projectHomeRouteIntent(config, "1", "home", readHomeRouteIntentState(config)).clients[client].enabled).toBe(true); + initializeHomeRouteIntentDefaults(config, "1"); + expect(readRouteIntentState().devices.home).toEqual({ pi: { enabled: true } }); +}); + +test.each(["grok", "claude-desktop"] as const)("a no-op enable does not invent a persisted %s flag", client => { + const config = getDefaultConfig(); + delete config.clientIntegrations; + saveConfig(config); + const snapshot = loadConfig(); + expect(snapshot.clientIntegrations?.[client]).toBeUndefined(); + persistNativeRouteIntent(client, true, () => {}, snapshot, false); + expect(snapshot.clientIntegrations?.[client]).toBeUndefined(); + expect(loadConfig().clientIntegrations?.[client]).toBeUndefined(); +}); + +test("an enrollment without a client selection inherits current and later Home defaults", () => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + const id = "lnk_0123456789abcdef"; + setRouteClientIntent("codex", { enabled: false }); + setRouteClientIntent("claude", { enabled: false }); + seedRouteDeviceClients(config, "1", id); + let state = readHomeRouteIntentState(config); + expect(state.devices[id]).toBeUndefined(); + expect(projectHomeRouteIntent(config, "1", id, state).clients.codex.enabled).toBe(false); + expect(projectHomeRouteIntent(config, "1", id, state).clients.claude.enabled).toBe(false); + setRouteClientIntent("codex", { enabled: true }); + state = readHomeRouteIntentState(config); + expect(projectHomeRouteIntent(config, "1", id, state).clients.codex.enabled).toBe(true); +}); + +test("an explicit enrollment selection remains a device override", () => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + const id = "lnk_0123456789abcdef"; + setRouteClientIntent("codex", { enabled: false }); + seedRouteDeviceClients(config, "1", id, ["codex"]); + const state = readHomeRouteIntentState(config); + expect(projectHomeRouteIntent(config, "1", id, state).clients.codex.enabled).toBe(true); + expect(projectHomeRouteIntent(config, "1", id, state).clients.claude.enabled).toBe(false); +}); + +test("a passive Home read preserves old owned intent; local overrides cannot rewrite migrated defaults", () => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + const spec = INTEGRATION_CLIENTS.pi; + const deviceHome = home.path("device"); + const path = spec.configPath({}, deviceHome); + mkdirSync(spec.detectDir({}, deviceHome), { recursive: true }); mkdirSync(dirname(path), { recursive: true }); + const store = createIntegrationStateStore(); + expect(applyIntegration({ clientId: "pi", config, port: config.port, models: [], home: deviceHome, env: {}, store }).ok).toBe(true); + const inherited = readHomeRouteIntentState(config); + expect(projectHomeRouteIntent(config, "1", undefined, inherited).clients.pi.enabled).toBe(true); + expect(existsSync(routeIntentStatePath())).toBe(false); + initializeHomeRouteIntentDefaults(config, "1"); + recordHomeNativeDecision("codex"); + const localMaterializedConfig = { ...config, clientIntegrations: { codex: false } }; + const state = readHomeRouteIntentState(localMaterializedConfig); + expect(projectHomeRouteIntent(localMaterializedConfig, "1", "home", state).clients.codex.enabled).toBe(false); + expect(projectHomeRouteIntent(localMaterializedConfig, "1", "lnk_0123456789abcdef", state).clients.codex.enabled).toBe(true); + setRouteClientIntent("pi", { enabled: false }); + expect(projectHomeRouteIntent(config, "1", undefined, readHomeRouteIntentState(config)).clients.pi.enabled).toBe(false); +}); + +test("an unowned file never becomes a Home routing decision", () => { + const spec = INTEGRATION_CLIENTS.pi; + const deviceHome = home.path("device"); + const path = spec.configPath({}, deviceHome); mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ providers: { opencodex: { baseUrl: "https://foreign.example.test" } } })); + const config = { port: 18274, providers: {} } as OcxConfig; + expect(projectHomeRouteIntent(config, "1", undefined, readHomeRouteIntentState(config)).clients.pi.enabled).toBe(false); +}); diff --git a/tests/clients/link-home-route-intent.test.ts b/tests/clients/link-home-route-intent.test.ts new file mode 100644 index 00000000000..cc2e01ec287 --- /dev/null +++ b/tests/clients/link-home-route-intent.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchHomeRouteIntent, homeRouteIntentPath, homeRouteIntentReceipt, readHomeRouteIntentRecord, writeHomeRouteIntentRecord, clearHomeRouteIntentRecord } from "../../src/client/link-home"; +import { projectHomeRouteIntent, ROUTE_CLIENT_IDS, routeIntentDigest } from "../../src/link/route-intent"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +const id = "lnk_0123456789abcdef"; +const connection = { transport: "link", apiKeyId: "key-id", link: { linkId: id, tunnelPort: 19200 } } as never; +const document = projectHomeRouteIntent({} as never, "test"); +let root: string; +let previous: string | undefined; +beforeEach(() => { previous = process.env.OPENCODEX_HOME; root = mkdtempSync(join(tmpdir(), "ocx-home-route-")); process.env.OPENCODEX_HOME = root; }); +afterEach(() => { if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; removeTreeWithRetry(root); }); +test("bounded authenticated fetch uses exact route, no redirects, and returns explicit unsupported/failed", async () => { + let url = ""; let init: RequestInit | undefined; + const deps = { fetchImpl: (async (input, options) => { url = String(input); init = options; return Response.json(document); }) as typeof fetch }; + expect(await fetchHomeRouteIntent(connection, "test-admission", deps)).toEqual({ kind: "fresh", document }); + expect(url).toBe("http://127.0.0.1:19200/v1/link-management/route-intent"); + expect(init?.redirect).toBe("manual"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-admission"); + for (const [status, kind] of [[404, "unsupported"], [302, "failed"], [500, "failed"]] as const) { + expect((await fetchHomeRouteIntent(connection, "key", { fetchImpl: (async () => new Response(null, { status })) as typeof fetch })).kind).toBe(kind); + } + expect((await fetchHomeRouteIntent(connection, "key", { fetchImpl: (async () => Response.json({ ...document, apiKey: "secret" })) as typeof fetch })).kind).toBe("failed"); + expect((await fetchHomeRouteIntent(connection, "key", { fetchImpl: (async () => new Response(" ".repeat(65537))) as typeof fetch })).kind).toBe("failed"); +}); +test("receipts are link-bound and only complete dispositions claim an applied digest", () => { + const time = "2026-11-07T00:00:00.000Z"; + const outcomes = ROUTE_CLIENT_IDS.map(client => ({ client, outcome: "current" as const })); + const receipt = homeRouteIntentReceipt(id, document, time, time, outcomes, null); + expect(receipt.appliedRouteIntentDigest).toBe(routeIntentDigest(document)); + writeHomeRouteIntentRecord(receipt); + expect(readHomeRouteIntentRecord(id)).toEqual(receipt); + expect(readHomeRouteIntentRecord("lnk_aaaaaaaaaaaaaaaa")).toBeNull(); + expect(homeRouteIntentReceipt(id, document, time, "2026-11-07T01:00:00.000Z", outcomes, receipt).appliedAt).toBe(time); + const partial = homeRouteIntentReceipt(id, document, time, time, [{ client: "aside", outcome: "failed" }], receipt); + expect(partial.appliedRouteIntentDigest).toBeNull(); + expect(partial.routeClientOutcomes).toEqual([{ client: "aside", outcome: "failed" }]); + expect(homeRouteIntentReceipt(id, document, time, time, outcomes.map(row => row.client === "codex" ? { ...row, outcome: "not_installed" } : row), null).appliedRouteIntentDigest).toBeNull(); + expect(homeRouteIntentReceipt(id, document, time, time, outcomes.map(row => row.client === "codex" ? { ...row, source: "local" } : row), null).appliedRouteIntentDigest).toBeNull(); + writeFileSync(homeRouteIntentPath(), JSON.stringify({ ...receipt, document: { ...document, path: "/private" } })); + expect(readHomeRouteIntentRecord(id)).toBeNull(); + writeHomeRouteIntentRecord(receipt); + expect(readFileSync(homeRouteIntentPath(), "utf8")).not.toContain("admission"); + clearHomeRouteIntentRecord(); + expect(readHomeRouteIntentRecord(id)).toBeNull(); +}); diff --git a/tests/clients/link-parity-reporter.test.ts b/tests/clients/link-parity-reporter.test.ts index 16069d6e97a..b5c2353f6be 100644 --- a/tests/clients/link-parity-reporter.test.ts +++ b/tests/clients/link-parity-reporter.test.ts @@ -31,6 +31,7 @@ function harness(outcomes: Array<"accepted" | "unsupported" | "failed">) { linkKey: () => "ocx_data_key", runtimeStartedAt: STARTED, readRecord: () => record, + readRouteRecord: () => null, readCommonReceipt: () => null, readLocalOff: () => [], send: async (_connection, _key, report) => { sent.push(report); return outcomes.shift() ?? "accepted"; }, setTimer: (callback, ms) => { delays.push(ms); pending = callback; return ms; }, clearTimer: () => { pending = null; }, diff --git a/tests/clients/link-pinned-sync-overwrite.test.ts b/tests/clients/link-pinned-sync-overwrite.test.ts new file mode 100644 index 00000000000..b47ea2728e0 --- /dev/null +++ b/tests/clients/link-pinned-sync-overwrite.test.ts @@ -0,0 +1,83 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inflateRawSync } from "node:zlib"; +import { remotePinnedSyncArgv, ROUTE_OVERWRITE_MIN_CHILD_VERSION } from "../../src/link/ssh-argv"; +import { POWERSHELL_ENCODED_MAX } from "../../src/link/remote-windows"; +import { classifySyncResult } from "../../src/cli/sync-fanout"; + +/** + * --overwrite-conflict and --overwrite-client reach only a Child that honours them: an older ocx + * ignores unknown sync flags and would report the plain sync as success, and ocx-child refuses them. + */ +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +const linkId = "lnk_0123456789abcdef"; + +function isolated(argv: string[]): string[] { + argv[2] = argv[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 argv; +} +function fake(path: string, version: string): void { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$*" >> "$HOME/calls"\nif [ "$1" = --version ]; then printf '%s\\n' '${version}'; exit 0; fi\nprintf 'synced\\n'\n`); + chmodSync(path, 0o755); +} +function run(argv: string[], setup: (home: string) => void) { + const home = mkdtempSync(join(tmpdir(), "ocx-overwrite-gate-")); dirs.push(home); + const bin = join(home, "bin"); mkdirSync(bin); + for (const command of ["sh", "uname", "mktemp", "sleep", "cat", "awk", "wc", "rm"]) symlinkSync(Bun.which(command)!, join(bin, command)); + setup(home); + const proc = Bun.spawnSync(isolated(argv), { env: { HOME: home, OPENCODEX_HOME: join(home, "ocx-home"), CODEX_HOME: join(home, "codex-home"), TMPDIR: home, PATH: bin }, stdout: "pipe", stderr: "pipe" }); + const calls = join(home, "calls"); + return { code: proc.exitCode, stderr: proc.stderr.toString().trim(), calls: existsSync(calls) ? readFileSync(calls, "utf8") : "" }; +} +const desktop = (version: string) => (home: string) => fake(join(home, ".opencodex-desktop", "bin", "ocx"), version); + +test("the overwrite gate names the first release that honours the flags", () => { + expect(ROUTE_OVERWRITE_MIN_CHILD_VERSION).toBe("2.100.0"); +}); + +test.skipIf(process.platform === "win32").each(["2.99.0", "2.93.0", "2.100.0-preview.1"])("an overwrite sync refuses an ocx %s that would ignore the flags", version => { + const result = run(remotePinnedSyncArgv(linkId, "posix", true, "pi"), desktop(version)); + expect(result).toEqual({ code: 121, stderr: "ocx-fanout:child-ocx-lacks-overwrite", calls: "--version\n" }); + expect(classifySyncResult({ code: 121, stdout: "", stderr: `${result.stderr}\n` })).toEqual({ state: "skipped", reason: "child-ocx-lacks-overwrite" }); +}); + +test.skipIf(process.platform === "win32")("an overwrite sync forwards the flags to a Child that honours them, and plain sync keeps the old gate", () => { + expect(run(remotePinnedSyncArgv(linkId, "posix", true, "pi"), desktop("2.100.0"))) + .toEqual({ code: 0, stderr: "ocx-fanout:selected:2.100.0", calls: `--version\nsync --expect-link ${linkId} --json --overwrite-conflict --overwrite-client pi\n` }); + expect(run(remotePinnedSyncArgv(linkId, "posix"), desktop("2.93.0"))) + .toEqual({ code: 0, stderr: "ocx-fanout:selected:2.93.0", calls: `--version\nsync --expect-link ${linkId} --json\n` }); +}); + +test.skipIf(process.platform === "win32")("an overwrite sync never runs ocx-child, while a plain sync still reaches it", () => { + const ephemeral = (home: string) => { + fake(join(home, ".local", "bin", "ocx-child"), "2.98.0"); + mkdirSync(join(home, "ocx-home", "child"), { recursive: true }); + writeFileSync(join(home, "ocx-home", "child", "state.json"), "{}"); + }; + expect(run(remotePinnedSyncArgv(linkId, "posix", true), ephemeral)).toEqual({ code: 121, stderr: "ocx-fanout:child-ephemeral-no-overwrite", calls: "" }); + expect(classifySyncResult({ code: 121, stdout: "", stderr: "ocx-fanout:child-ephemeral-no-overwrite\n" })).toEqual({ state: "skipped", reason: "child-ephemeral-no-overwrite" }); + expect(run(remotePinnedSyncArgv(linkId, "posix"), ephemeral)).toEqual({ code: 0, stderr: "ocx-fanout:selected:2.98.0", calls: `--version\nsync --expect-link ${linkId} --json\n` }); +}); + +function windowsScript(argv: string[]): string { + expect(argv[5]!.length).toBeLessThanOrEqual(POWERSHELL_ENCODED_MAX); + const outer = Buffer.from(argv[5]!, "base64").toString("utf16le"); + const packed = /\$ocxPacked='([^']+)'/.exec(outer)![1]!; + return inflateRawSync(Buffer.from(Array.from(packed, ch => ch.charCodeAt(0) - 256))).toString("utf8"); +} + +test("the Windows overwrite sync carries the same gate and ocx-child refusal within the SSH budget", () => { + const overwrite = windowsScript(remotePinnedSyncArgv(linkId, "windows", true, "pi")); + expect(overwrite).toContain("$b=@('2','100','0')"); + expect(overwrite).toContain("[Console]::Error.WriteLine('ocx-fanout:child-ocx-lacks-overwrite')"); + expect(overwrite).toContain("StartsWith('ocx-child')){[Console]::Error.WriteLine('ocx-fanout:child-ephemeral-no-overwrite');exit 121}"); + const plain = windowsScript(remotePinnedSyncArgv(linkId, "windows")); + expect(plain).toContain("$b=@('2','93','0')"); + expect(plain).toContain("ocx-fanout:child-ocx-lacks-expect-link"); + expect(plain).not.toContain("child-ephemeral-no-overwrite"); +}); diff --git a/tests/clients/route-intent-aside.test.ts b/tests/clients/route-intent-aside.test.ts new file mode 100644 index 00000000000..ec890fd07dd --- /dev/null +++ b/tests/clients/route-intent-aside.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { materializeRouteIntent, cleanupRouteIntent } from "../../src/integrations/route-intent"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { ROUTE_CLIENT_IDS, type HomeRouteIntentDocument } from "../../src/link/route-intent"; +import type { RouteIntentApplyInput } from "../../src/integrations/route-intent-contract"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { OcxConfig } from "../../src/types"; +import { readHomeRouteIntentState } from "../../src/routing/home-route-intent"; +import { emptyRouteIntentState } from "../../src/link/route-intent"; + +let home: TempHome; +let input: RouteIntentApplyInput; +function path(slot: number): string { return home.path("device", ".aside", "u", String(slot), "models.json"); } +function profile(slot: number, userId: string): void { + mkdirSync(join(path(slot), ".."), { recursive: true }); + writeFileSync(path(slot), JSON.stringify({ preferences: { userId, later: false }, providers: { personal: { apiKey: "fixture-personal" } } })); +} +function manifest(rows: Array<{ id: number; userId: string }>): void { + writeFileSync(home.path("device", ".aside", "accounts.json"), JSON.stringify({ currentAccountId: rows[0]!.id, accounts: rows })); +} +function read(slot: number) { return JSON.parse(readFileSync(path(slot), "utf8")); } +beforeEach(() => { + home = createTempHome("route-aside-"); + const document: HomeRouteIntentDocument = { version: 1, home: { name: "fixture-home", version: "1" }, + clients: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, { enabled: id === "aside" }])) as HomeRouteIntentDocument["clients"], + sources: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, "default"])) as HomeRouteIntentDocument["sources"] }; + input = { config: { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig, + document, models: [{ namespaced: "child/deepseek/model", provider: "child", id: "deepseek/model" }], + home: home.path("device"), env: {}, store: createIntegrationStateStore(home.path("integration-store")), statePath: home.path("route-state") }; + profile(0, "other-user"); profile(7, "selected-user"); + manifest([{ id: 0, userId: "other-user" }, { id: 7, userId: "selected-user" }]); +}); +afterEach(() => home.remove()); + +test("Aside selects local userIds instead of Home slot numbers, and refreshes exact served ids", async () => { + input.document.clients.aside.accounts = ["selected-user"]; + expect((await materializeRouteIntent(input)).outcomes.find(row => row.client === "aside")?.outcome).toBe("current"); + expect(read(0).providers.opencodex).toBeUndefined(); + const block = read(7).providers.opencodex; + expect(block.baseUrl).toBe("http://127.0.0.1:18274/v1"); + expect(block.apiKey).toBe("opencodex-loopback"); + expect(JSON.stringify(block)).toContain("child/deepseek/model"); + expect(readHomeRouteIntentState(input.config, { home: input.home, store: input.store, state: emptyRouteIntentState() }).defaults.aside) + .toEqual({ enabled: true, accounts: ["selected-user"] }); + input.models = [{ namespaced: "child/deepseek/new", provider: "child", id: "deepseek/new" }]; + await materializeRouteIntent(input); + expect(JSON.stringify(read(7).providers.opencodex)).toContain("child/deepseek/new"); + expect(JSON.stringify(read(7).providers.opencodex)).not.toContain('"child/deepseek/model"'); +}); + +test("all writes each local profile, none removes owned blocks and preserves later user edits", async () => { + input.document.clients.aside.accounts = "all"; + await materializeRouteIntent(input); + for (const slot of [0, 7]) expect(read(slot).providers.opencodex).toBeDefined(); + const edited = read(7); edited.preferences.later = true; writeFileSync(path(7), JSON.stringify(edited)); + input.document.clients.aside.accounts = "none"; + await materializeRouteIntent(input); + for (const slot of [0, 7]) { + expect(read(slot).providers.opencodex).toBeUndefined(); + expect(read(slot).providers.personal.apiKey).toBe("fixture-personal"); + } + expect(read(7).preferences.later).toBe(true); +}); + +test("disconnect cleanup removes every owned Aside block and retains later sibling edits", async () => { + await materializeRouteIntent(input); + const edited = read(0); edited.preferences.later = true; writeFileSync(path(0), JSON.stringify(edited)); + expect((await cleanupRouteIntent(input)).find(row => row.client === "aside")?.outcome).toBe("current"); + expect(read(0).providers.opencodex).toBeUndefined(); + expect(read(7).providers.opencodex).toBeUndefined(); + expect(read(0).preferences.later).toBe(true); +}); + +test("legacy Aside ownership follows its recorded path after the current account switches", async () => { + input.document.clients.aside.accounts = ["selected-user"]; + await materializeRouteIntent(input); + const profileStore = createIntegrationStateStore(join(input.store!.root, "aside-profiles", "7")); + input.store!.putRecord(profileStore.readRecordsStrict().aside!); + // The manifest's current account is slot 0, while the durable owner still names slot 7. + const migrated = readHomeRouteIntentState(input.config, { home: input.home, store: input.store, state: emptyRouteIntentState() }); + expect(migrated.defaults.aside).toEqual({ enabled: true, accounts: ["selected-user"] }); +}); diff --git a/tests/clients/route-intent-claude-surface.test.ts b/tests/clients/route-intent-claude-surface.test.ts new file mode 100644 index 00000000000..0a3836715bb --- /dev/null +++ b/tests/clients/route-intent-claude-surface.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createTempHome } from "../helpers/temp-home"; +import { repoRoot } from "../helpers/repo-root"; + +test.each([true, false])("Home-local Claude off preserves shared Messages exposure (%s)", enabled => { + const home = createTempHome("route-claude-surface-"); + try { + const script = ` + import { mkdirSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { materializeLocalRouteIntent } from './src/client/route-intent-apply'; + import { ROUTE_CLIENT_IDS } from './src/link/route-intent'; + import { resolveApiSurfaceSettings } from './src/protocols/settings'; + const config = getDefaultConfig(); config.port = 18274; config.claudeCode = { enabled:true }; + if (!${enabled}) config.apiSurfaces = {messages:{enabled:false}}; + saveConfig(config); mkdirSync(process.env.CLAUDE_CONFIG_DIR,{recursive:true}); + const document = {version:1,home:{name:'fixture-home',version:'1'}, + clients:Object.fromEntries(ROUTE_CLIENT_IDS.map(client=>[client,{enabled:false}])), + sources:Object.fromEntries(ROUTE_CLIENT_IDS.map(client=>[client,'default']))}; + const receipt=await materializeLocalRouteIntent(config,document,[]); + console.log(JSON.stringify({local:loadConfig().claudeCode.enabled, + messages:resolveApiSurfaceSettings(loadConfig()).messages.enabled, + live:resolveApiSurfaceSettings(config).messages.enabled, + outcome:receipt.outcomes.find(row=>row.client==='claude').outcome})); + `; + const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot(), encoding: "utf8", timeout: 20_000, + env: { ...process.env, HOME: home.root, USERPROFILE: home.root, OPENCODEX_HOME: home.configDir, CODEX_HOME: home.codexHome, + CLAUDE_CONFIG_DIR: home.path(".claude"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: home.path("desktop"), + XDG_CONFIG_HOME: home.path(".config"), GROK_HOME: home.path(".grok"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: result.status, stderr: result.stderr }).toMatchObject({ status: 0 }); + expect(JSON.parse(result.stdout.trim().split("\n").at(-1)!)).toEqual({ local: false, messages: enabled, live: enabled, outcome: "current" }); + } finally { home.remove(); } +}); diff --git a/tests/clients/route-intent-codex-connect-disabled.test.ts b/tests/clients/route-intent-codex-connect-disabled.test.ts new file mode 100644 index 00000000000..07dd6b4d1b8 --- /dev/null +++ b/tests/clients/route-intent-codex-connect-disabled.test.ts @@ -0,0 +1,294 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +// Run the real enrollment and injector with every native path bound to a disposable home. +function scenario(setup = "", testRunner = false, after = "") { + const home = mkdtempSync(join(tmpdir(), "ocx-route-codex-connect-disabled-")); + mkdirSync(join(home, ".codex")); + try { + const script = ` + import { writeFileSync, readFileSync, existsSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { connectClient } from './src/client/connect'; + import { readClientConnectionState } from './src/client/state'; + import { readServiceApiTokenState, replaceServiceApiTokenFile } from './src/lib/service-secrets'; + import { ROUTE_CLIENT_IDS } from './src/link/route-intent'; + import { setLocalRouteIntentOff, readRouteIntentReceipt } from './src/integrations/route-intent'; + import { acquireConfigWriteLock, releaseConfigWriteLock } from './src/codex/config-write-lock'; + import { journalOwner } from './src/codex/journal'; + import { readHomeCodexSettingsRecord } from './src/client/link-home'; + const configPath = join(process.env.CODEX_HOME, 'config.toml'); + const original = 'model = "user-model"\\n'; + writeFileSync(configPath, original); + const local = getDefaultConfig(); + local.port = 18274; local.fastMode = false; + local.clientIntegrations = {...local.clientIntegrations, codex:false}; saveConfig(local); + const requests = []; + let homeIntentAvailable = true; + const fetchImpl = async input => { + const url = String(input); requests.push(url); + if(url.endsWith('/readyz')) return Response.json({service:'opencodex',version:'1',uptime:1,pid:1,port:22001, + status:'ready',protocol:1,minimumClientProtocol:1,managementUrl:'http://127.0.0.1:22001'}); + if(url.endsWith('/v1/catalog')) return Response.json({models:[]}); + if(url.endsWith('/route-intent') && homeIntentAvailable) return Response.json({version:1,home:{name:'fixture-home',version:'1'}, + clients:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,{enabled:id==='codex'}])), + sources:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,'default']))}); + if(url.endsWith('/codex-settings')) return Response.json({version:1,home:{name:'fixture-home',version:'1'}, + settings:{fastMode:true,multiAgentMode:null,webSearchSidecarEnabled:false,subagentDefaults:null}}); + if(url.endsWith('/v1/models')) return Response.json({data:[]}); + return new Response(null,{status:404}); + }; + const deps = {fetchImpl,linkHome:{fetchImpl},catalogCompatibility:{supportedEfforts:()=>new Set()}, + lifecycleLockDeps:{lockPath:join(process.env.OPENCODEX_HOME,'lifecycle.sqlite')}}; + const options = {serverUrl:'http://127.0.0.1:22001',managementTransport:'direct',transport:'link', + link:{tunnelPort:22001,linkId:'lnk_0123456789abcdef'}, + credential:{kind:'link',apiKeyId:'fixture-key',key:'ocx_data_'+'a'.repeat(40)},selectedClients:['codex']}; + let cleanup = () => {}, observedEnabled, observedInjected; + ${setup} + const before=readFileSync(configPath,'utf8'); + let connection, error; + try {connection=await connectClient(options,deps);} catch(e) {error=e.message;} finally {cleanup();} + let afterError, tokenAfterFailure; ${after} + console.log(JSON.stringify({error,selected:connection?.selectedClients,enabled:loadConfig().clientIntegrations.codex, + fastMode:loadConfig().fastMode,after:existsSync(configPath)?readFileSync(configPath,'utf8'):null,before,original,owner:journalOwner(), + journal:existsSync(join(process.env.CODEX_HOME,'opencodex-journal.json')), + token:readServiceApiTokenState().kind,state:readClientConnectionState().kind, + settings:readHomeCodexSettingsRecord(options.link.linkId),receipt:readRouteIntentReceipt(),requests,observedEnabled,observedInjected, + afterError,tokenAfterFailure,pending:existsSync(join(process.env.OPENCODEX_HOME,'client-connect-pending'))})); + `; + let args = ["--eval", script]; + if (testRunner) { + const fixturePath = join(home, "post-publication.test.ts"); + const resolved = script.replace(/from '(\.\/src\/[^']+)'/g, + (_match, relative: string) => `from ${JSON.stringify(join(repoRoot(), relative))}`); + const start = resolved.indexOf(" const configPath ="); + writeFileSync(fixturePath, `import { test } from "bun:test";\n${resolved.slice(0, start)}\n` + + `test("isolated enrollment", async () => {\n${resolved.slice(start)}\n}, ${SPAWN_BUDGET_MS - 5_000});\n`); + args = ["test", "--preload", join(repoRoot(), "tests/preload.ts"), fixturePath]; + } + const child = spawnSync(process.execPath, args, { + cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), + XDG_CONFIG_HOME: join(home, ".config"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), + OCX_TEST_HOME_GUARD: "1" }, + }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + expect(child.stderr).not.toContain("local route intent needs reconciliation"); + return JSON.parse(child.stdout.trim().split("\n").at(-1)!); + } finally { removeTreeWithRetry(home); } +} + +test("post-publication failure rolls back the disabled flag before removing enrollment authority", () => { + const result = scenario(` + const {spyOn}=await import('bun:test'); + const registries=await import(${JSON.stringify(join(repoRoot(), "src/config/derived-registries.ts"))}); + const refresh=registries.refreshConfigDerivedRegistries; + let failed=false; + const failure=spyOn(registries,'refreshConfigDerivedRegistries').mockImplementation(config=>{ + if(!failed && config.clientIntegrations?.codex===true){ + failed=true;observedEnabled=JSON.parse(readFileSync(join(process.env.OPENCODEX_HOME,'config.json'),'utf8')).clientIntegrations.codex; + throw new Error('fixture post-publication bookkeeping failure'); + } + return refresh(config); + }); + cleanup=()=>failure.mockRestore(); + `, true); + expect(result.error).toContain("Config was published"); + expect(result.observedEnabled).toBe(true); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.token).toBe("absent"); + expect(result.state).toBe("disconnected"); +}, SPAWN_BUDGET_MS); + +test("fresh Link connect lets Home Codex on override a disabled standalone flag", () => { + const result = scenario(); + expect(result.error).toBeUndefined(); + expect(result.selected).toEqual(["codex"]); + expect(result.state).toBe("connected"); + expect(result.enabled).toBe(true); + expect(result.after).toContain('openai_base_url = "http://127.0.0.1:18274/v1"'); + expect(result.after).toContain("fast_mode = true"); + expect(result.fastMode).toBe(false); + expect(result.owner).toEqual({ kind: "client", apiKeyId: "fixture-key" }); + expect(result.settings.document.settings.fastMode).toBe(true); + expect(result.receipt.outcomes.find((row: { client: string }) => row.client === "codex").outcome).toBe("current"); +}, SPAWN_BUDGET_MS); + +for (const initiallyEnabled of [false, true]) { + test(`failed enrollment restores initial Codex intent and preserves disjoint edits (initial: ${initiallyEnabled})`, () => { + const result = scenario(` + local.clientIntegrations.codex=${initiallyEnabled}; saveConfig(local); + deps.now = () => { + observedEnabled=loadConfig().clientIntegrations.codex; + observedInjected=readFileSync(configPath,'utf8').includes('openai_base_url'); + const latest=loadConfig(); latest.fastMode=true; saveConfig(latest); + throw new Error('fixture enrollment commit failed'); + }; + `); + expect(result.error).toBe("fixture enrollment commit failed"); + expect(result.observedEnabled).toBe(true); + expect(result.observedInjected).toBe(true); + expect(result.enabled).toBe(initiallyEnabled); + expect(result.fastMode).toBe(true); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.token).toBe("absent"); + expect(result.state).toBe("disconnected"); + expect(result.settings).toBeNull(); + }, SPAWN_BUDGET_MS); +} + +test("failed enrollment preserves a later user edit to the temporary Codex intent", () => { + const result = scenario(` + deps.now=()=>{ + observedEnabled=loadConfig().clientIntegrations.codex; + const edited=loadConfig(); edited.clientIntegrations={...edited.clientIntegrations,grok:true}; + delete edited.clientIntegrations.codex; saveConfig(edited); + throw new Error('fixture enrollment commit failed'); + }; + `); + expect(result.error).toContain("fixture enrollment commit failed"); + expect(result.observedEnabled).toBe(true); + expect(result.enabled).toBeUndefined(); +}, SPAWN_BUDGET_MS); + +test("fresh Link connect with local Codex off preserves the disabled flag without injection", () => { + const result = scenario("setLocalRouteIntentOff('codex',true);"); + expect(result.error).toBeUndefined(); + expect(result.selected).toEqual([]); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.settings).toBeNull(); + expect(result.requests.some((url: string) => url.endsWith('/codex-settings'))).toBe(false); +}, SPAWN_BUDGET_MS); + +test("fresh Link connect with no-sync leaves standalone Codex disabled and untouched", () => { + const result = scenario("options.noSync=true;"); + expect(result.error).toBeUndefined(); + expect(result.selected).toEqual(["codex"]); + expect(result.state).toBe("connected"); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.settings).toBeNull(); + expect(result.receipt).toBeNull(); +}, SPAWN_BUDGET_MS); + +test("a refused Codex injection fails enrollment and rolls back its temporary enable", () => { + const result = scenario(` + const lock=await acquireConfigWriteLock(configPath,{timeoutMs:0}); + if(!lock.ok)throw new Error('fixture lock acquisition failed'); + cleanup=()=>releaseConfigWriteLock(lock.handle); + `); + expect(result.error).toContain("another opencodex process"); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.token).toBe("absent"); + expect(result.state).toBe("disconnected"); + expect(result.settings).toBeNull(); +}, SPAWN_BUDGET_MS); + +test("without effective Home intent a disabled standalone Codex still refuses injection", () => { + const result = scenario("homeIntentAvailable=false;"); + expect(result.error).toContain("Codex integration is OFF"); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.token).toBe("absent"); + expect(result.state).toBe("disconnected"); +}, SPAWN_BUDGET_MS); + +test("uncommitted Codex routing still fails enrollment and rolls back the temporary Home enable", () => { + const result = scenario(`writeFileSync(configPath,'openai_base_url = "https://user.invalid/v1"\\n');`); + expect(result.error).toBe("Codex routing target was not committed"); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.before); + expect(result.journal).toBe(false); + expect(result.token).toBe("absent"); + expect(result.state).toBe("disconnected"); +}, SPAWN_BUDGET_MS); + +test("an aborted enrollment restores the standalone flag under the rollback ownership fence", () => { + const result = scenario(` + const controller=new AbortController(); deps.signal=controller.signal; + deps.now=()=>{observedEnabled=loadConfig().clientIntegrations.codex;controller.abort();throw new Error('fixture aborted');}; + `); + expect(result.error).toBe("fixture aborted"); + expect(result.observedEnabled).toBe(true); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.token).toBe("absent"); + expect(result.state).toBe("disconnected"); +}, SPAWN_BUDGET_MS); + +test("changed enrollment credentials fence the early enable before Codex writes", () => { + const result = scenario(` + deps.linkHome.fetchImpl=async input=>{ + const response=await fetchImpl(input); + if(String(input).endsWith('/codex-settings'))replaceServiceApiTokenFile('ocx_data_'+'b'.repeat(40)); + return response; + }; + `); + expect(result.error).toContain("client_token_changed"); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.token).toBe("present"); + expect(result.state).toBe("disconnected"); +}, SPAWN_BUDGET_MS); + +test("a local opt-out arriving with Home settings fences the early Codex enable", () => { + const result = scenario(` + deps.linkHome.fetchImpl=async input=>{ + const response=await fetchImpl(input); + if(String(input).endsWith('/codex-settings'))setLocalRouteIntentOff('codex',true); + return response; + }; + `); + expect(result.error).toContain("Codex route intent or runtime changed"); + expect(result.enabled).toBe(false); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.token).toBe("absent"); + expect(result.state).toBe("disconnected"); +}, SPAWN_BUDGET_MS); + +test("a failed native restore keeps the enrollment token, and ocx disconnect finishes the rollback", () => { + const result = scenario(` + deps.now = () => { + // The write-ahead record is unreadable, so the rollback cannot restore the standalone switch. + writeFileSync(join(process.env.OPENCODEX_HOME,'link','native-intent-originals.json'), '{'); + throw new Error('fixture enrollment commit failed'); + }; + `, false, ` + tokenAfterFailure = readServiceApiTokenState().kind; + const recordPath = join(process.env.OPENCODEX_HOME,'link','native-intent-originals.json'); + const { disconnectClient } = await import('./src/client/connect'); + try { await disconnectClient({}, deps); } catch (e) { afterError = e.message; } + if (!afterError?.includes(recordPath)) throw new Error('disconnect did not name the record: ' + afterError); + writeFileSync(recordPath, JSON.stringify({ version: 1, fields: { codex: { original: false, published: true } } })); + afterError = undefined; + try { await disconnectClient({}, deps); } catch (e) { afterError = e.message; } + `); + expect(result.error).toContain("fixture enrollment commit failed"); + expect(result.error).toContain("native-intent-originals.json"); + expect(result.tokenAfterFailure).toBe("present"); + expect(result.afterError).toBeUndefined(); + expect(result.enabled).toBe(false); + expect(result.token).toBe("absent"); + expect(result.pending).toBe(false); + expect(result.state).toBe("disconnected"); +}, SPAWN_BUDGET_MS); diff --git a/tests/clients/route-intent-codex-transitions.test.ts b/tests/clients/route-intent-codex-transitions.test.ts new file mode 100644 index 00000000000..930caceb1ba --- /dev/null +++ b/tests/clients/route-intent-codex-transitions.test.ts @@ -0,0 +1,229 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +// Fresh modules bind every Codex path to this disposable home, including journal restoration. +function scenario(body: string, initialCodex?: boolean | "absent") { + const home = mkdtempSync(join(tmpdir(), "ocx-route-codex-transition-")); + mkdirSync(join(home, ".codex")); + try { + const child = spawnSync(process.execPath, ["--eval", ` + import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { connectClient, syncConnectedClient, disconnectClient } from './src/client/connect'; + import { readClientConnectionState } from './src/client/state'; + import { ROUTE_CLIENT_IDS } from './src/link/route-intent'; + import { setLocalRouteIntentOff } from './src/integrations/route-intent'; + import { acquireConfigWriteLock, releaseConfigWriteLock } from './src/codex/config-write-lock'; + import { journalOwner } from './src/codex/journal'; + const home = process.env.HOME; + const configPath = join(process.env.CODEX_HOME, 'config.toml'); + const journalPath = join(process.env.CODEX_HOME, 'opencodex-journal.json'); + const piPath = join(home, '.pi', 'agent', 'models.json'); + const original = 'model = "user-model"\\n'; + writeFileSync(configPath, original); + mkdirSync(join(home, '.pi', 'agent'), {recursive:true}); + const local = getDefaultConfig(); local.port = 18274; + if (${initialCodex === "absent"}) { if (local.clientIntegrations) delete local.clientIntegrations.codex; } + else if (${typeof initialCodex === "boolean"}) local.clientIntegrations = {...local.clientIntegrations, codex:${JSON.stringify(initialCodex === true)}}; + saveConfig(local); + const rawNativeBefore = JSON.parse(readFileSync(join(process.env.OPENCODEX_HOME,'config.json'),'utf8')); + let codexEnabled = true, modelId = 'fixture/first'; + const document = () => ({version:1, home:{name:'fixture-home',version:'1'}, + clients:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,{enabled:id==='pi'||id==='codex'&&codexEnabled}])), + sources:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,'default']))}); + const fetchImpl = async input => { + const url = String(input); + if(url.endsWith('/readyz')) return Response.json({service:'opencodex',version:'1',uptime:1,pid:1,port:22001, + status:'ready',protocol:1,minimumClientProtocol:1,managementUrl:'http://127.0.0.1:22001'}); + if(url.endsWith('/v1/catalog')) return Response.json({models:[]}); + if(url.endsWith('/route-intent')) return Response.json(document()); + if(url.endsWith('/v1/models')) return Response.json({data:[{id:modelId}]}); + return new Response(null,{status:404}); + }; + const deps = {fetchImpl,linkHome:{fetchImpl},catalogCompatibility:{supportedEfforts:()=>new Set()}, + lifecycleLockDeps:{lockPath:join(process.env.OPENCODEX_HOME,'lifecycle.sqlite')}}; + const connection = await connectClient({serverUrl:'http://127.0.0.1:22001',managementTransport:'direct',transport:'link', + link:{tunnelPort:22001,linkId:'lnk_0123456789abcdef'}, + credential:{kind:'link',apiKeyId:'fixture-key',key:'ocx_data_'+'a'.repeat(40)},selectedClients:['codex','claude']},deps); + ${body} + `], { + cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), + XDG_CONFIG_HOME: join(home, ".config"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), + OCX_TEST_HOME_GUARD: "1" }, + }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + expect(child.stderr).not.toContain("local route intent needs reconciliation"); + return JSON.parse(child.stdout.trim().split("\n").at(-1)!); + } finally { removeTreeWithRetry(home); } +} + +for (const initial of ["absent", false, true] as const) { + test(`disconnect restores native Codex intent after repeated Home sync (initial: ${String(initial)})`, () => { + const result = scenario(` + codexEnabled=false; await syncConnectedClient({},deps); + codexEnabled=true; await syncConnectedClient({},deps); + let error; try { await disconnectClient({},deps); } catch(e) { error=e.message; } + const raw=JSON.parse(readFileSync(join(process.env.OPENCODEX_HOME,'config.json'),'utf8')); + console.log(JSON.stringify({error,state:readClientConnectionState().kind, + hasCodex:Object.hasOwn(raw.clientIntegrations??{},'codex'),value:raw.clientIntegrations?.codex})); + `, initial); + expect(result.error).toBeUndefined(); + expect(result.state).toBe("disconnected"); + expect(result.hasCodex).toBe(initial !== "absent"); + if (initial !== "absent") expect(result.value).toBe(initial); + }, SPAWN_BUDGET_MS); +} + +// A value the route-intent writer did not publish is the person's own edit: disconnect keeps it. +test("disconnect retains the user's edit to native Codex intent after Home sync", () => { + const result = scenario(` + codexEnabled=false; await syncConnectedClient({},deps); + const edited=loadConfig(); edited.clientIntegrations={...edited.clientIntegrations,codex:true}; saveConfig(edited); + let error; try { await disconnectClient({},deps); } catch(e) { error=e.message; } + const raw=JSON.parse(readFileSync(join(process.env.OPENCODEX_HOME,'config.json'),'utf8')); + console.log(JSON.stringify({error,state:readClientConnectionState().kind,value:raw.clientIntegrations?.codex})); + `, "absent"); + expect(result.error).toBeUndefined(); + expect(result.state).toBe("disconnected"); + expect(result.value).toBe(true); +}, SPAWN_BUDGET_MS); + +test("disconnect restores native Claude, Desktop, Grok and Messages field presence", () => { + const result = scenario(` + await syncConnectedClient({},deps); + let error; try { await disconnectClient({},deps); } catch(e) { error=e.message; } + const after=JSON.parse(readFileSync(join(process.env.OPENCODEX_HOME,'config.json'),'utf8')); + const fields = raw => ({ + grok: Object.hasOwn(raw.clientIntegrations??{},'grok') ? raw.clientIntegrations.grok : 'absent', + desktop: Object.hasOwn(raw.clientIntegrations??{},'claude-desktop') ? raw.clientIntegrations['claude-desktop'] : 'absent', + claude: Object.hasOwn(raw.claudeCode??{},'enabled') ? raw.claudeCode.enabled : 'absent', + sentinel: Object.hasOwn(raw.claudeCode??{},'authModeMigratedAt') ? raw.claudeCode.authModeMigratedAt : 'absent', + messages: Object.hasOwn(raw.apiSurfaces??{},'messages') ? raw.apiSurfaces.messages : 'absent', + }); + console.log(JSON.stringify({error,state:readClientConnectionState().kind,before:fields(rawNativeBefore),after:fields(after)})); + `); + expect(result.error).toBeUndefined(); + expect(result.state).toBe("disconnected"); + expect(result.after).toEqual(result.before); +}, SPAWN_BUDGET_MS); + +for (const damage of ["malformed", "invalid_owner", "missing"] as const) { + test(`unknown Codex cleanup evidence retains credentials after failed off: ${damage}`, () => { + const result = scenario(` + const injected=readFileSync(configPath,'utf8'); + const lock=await acquireConfigWriteLock(configPath,{timeoutMs:0}); + if(!lock.ok)throw new Error('fixture lock acquisition failed'); + codexEnabled=false; + try{await syncConnectedClient({},deps);}finally{releaseConfigWriteLock(lock.handle);} + if(${JSON.stringify(damage)}==='malformed')writeFileSync(journalPath,'{malformed'); + else if(${JSON.stringify(damage)}==='missing')unlinkSync(journalPath); + else {const journal=JSON.parse(readFileSync(journalPath,'utf8'));journal.owner={kind:'unknown'};writeFileSync(journalPath,JSON.stringify(journal));} + let error;try{await disconnectClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,injected,after:readFileSync(configPath,'utf8'),journal:existsSync(journalPath), + token:existsSync(join(process.env.OPENCODEX_HOME,'service-api-token')),state:readClientConnectionState().kind})); + `); + expect(result.error).toMatch(/disconnect refused: Codex/); + expect(result.after).toBe(result.injected); + expect(result.journal).toBe(damage !== "missing"); + expect(result.token).toBe(true); + expect(result.state).toBe("connected"); + }, SPAWN_BUDGET_MS); +} + +test("Home Codex off then on reenables durable intent before injection and refreshes sibling clients", () => { + const result = scenario(` + codexEnabled=false; + const off=await syncConnectedClient({},deps); + const disabled=loadConfig().clientIntegrations.codex; + const restored=readFileSync(configPath,'utf8'); + codexEnabled=true;modelId='fixture/second'; + let synced,error; + try{synced=await syncConnectedClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,disabled,restored,original,selectedOff:off.connection.selectedClients, + enabled:loadConfig().clientIntegrations.codex,injected:synced?.injected,selected:synced?.connection.selectedClients, + after:readFileSync(configPath,'utf8'),owner:journalOwner(),pi:readFileSync(piPath,'utf8'), + outcomes:synced?.routeIntent?.outcomes.filter(row=>['codex','pi'].includes(row.client))})); + `); + expect(result.disabled).toBe(false); + expect(result.restored).toBe(result.original); + expect(result.selectedOff).toEqual(["pi"]); + expect(result.error).toBeUndefined(); + expect(result.enabled).toBe(true); + expect(result.injected).toBe(true); + expect(result.selected).toEqual(["codex", "pi"]); + expect(result.after).toContain('openai_base_url = "http://127.0.0.1:18274/v1"'); + expect(result.owner).toEqual({ kind: "client", apiKeyId: "fixture-key" }); + expect(result.pi).toContain("fixture/second"); + expect(result.outcomes.map((row: { outcome: string }) => row.outcome)).toEqual(["current", "current"]); +}, SPAWN_BUDGET_MS); + +test("local Codex off remains disabled when Home turns Codex on", () => { + const result = scenario(` + codexEnabled=false;await syncConnectedClient({},deps); + setLocalRouteIntentOff('codex',true); + codexEnabled=true;modelId='fixture/local-off'; + const synced=await syncConnectedClient({},deps); + console.log(JSON.stringify({enabled:loadConfig().clientIntegrations.codex,injected:synced.injected, + selected:synced.connection.selectedClients,after:readFileSync(configPath,'utf8'),original, + journal:existsSync(journalPath),pi:readFileSync(piPath,'utf8')})); + `); + expect(result.enabled).toBe(false); + expect(result.injected).toBe(false); + expect(result.selected).toEqual(["pi"]); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.pi).toContain("fixture/local-off"); +}, SPAWN_BUDGET_MS); + +for (const foreign of [false, true]) { + test(`failed Codex off retains cleanup ownership on disconnect (foreign journal: ${foreign})`, () => { + const result = scenario(` + const injected=readFileSync(configPath,'utf8'); + const lock=await acquireConfigWriteLock(configPath,{timeoutMs:0}); + if(!lock.ok)throw new Error('fixture lock acquisition failed'); + codexEnabled=false; + let off; + try{off=await syncConnectedClient({},deps);}finally{releaseConfigWriteLock(lock.handle);} + const retained=existsSync(journalPath),before=readFileSync(configPath,'utf8'),disabled=loadConfig().clientIntegrations?.codex; + if(${foreign}){const journal=JSON.parse(readFileSync(journalPath,'utf8'));journal.owner={kind:'client',apiKeyId:'foreign-key'};writeFileSync(journalPath,JSON.stringify(journal));} + let disconnected,error;try{disconnected=await disconnectClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({injected,before,retained,offOutcome:off.routeIntent.outcomes.find(row=>row.client==='codex'), + selected:off.connection.selectedClients,disabled,codexAfter:loadConfig().clientIntegrations?.codex??'absent',error,disconnected, + after:readFileSync(configPath,'utf8'),original,journal:existsSync(journalPath), + token:existsSync(join(process.env.OPENCODEX_HOME,'service-api-token')),state:readClientConnectionState().kind})); + `); + expect(result.offOutcome.outcome).toBe("failed"); + expect(result.offOutcome.message).toContain("another opencodex process"); + expect(result.disabled).toBe(false); + expect(result.selected).toEqual(["pi"]); + expect(result.retained).toBe(true); + expect(result.before).toBe(result.injected); + if (foreign) { + expect(result.error).toContain("journal ownership conflicts"); + expect(result.codexAfter).toBe(false); + expect(result.after).toBe(result.injected); + expect(result.journal).toBe(true); + expect(result.token).toBe(true); + expect(result.state).toBe("connected"); + } else { + expect(result.error).toBeUndefined(); + expect(result.disconnected.restored).toBe(true); + // The standalone flag was absent before the Home's off displaced it. + expect(result.codexAfter).toBe("absent"); + expect(result.after).toBe(result.original); + expect(result.journal).toBe(false); + expect(result.token).toBe(false); + expect(result.state).toBe("disconnected"); + } + }, SPAWN_BUDGET_MS); +} diff --git a/tests/clients/route-intent-connect-recovery.test.ts b/tests/clients/route-intent-connect-recovery.test.ts new file mode 100644 index 00000000000..e90b8925075 --- /dev/null +++ b/tests/clients/route-intent-connect-recovery.test.ts @@ -0,0 +1,170 @@ +import { expect, test, setDefaultTimeout } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); +function scenario(body: string) { + const root = mkdtempSync(join(tmpdir(), "ocx-connect-recovery-")); + const home = join(root, "home"); mkdirSync(home); + try { + const result = spawnSync(process.execPath, ["--eval", body], { + cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, PATH: "", + OPENCODEX_HOME: join(home, ".opencodex"), CODEX_HOME: join(home, ".codex"), + XDG_CONFIG_HOME: join(home, ".config"), CLAUDE_CONFIG_DIR: join(home, ".claude"), + GROK_HOME: join(home, ".grok"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), + OCX_TEST_HOME_GUARD: "1" }, + }); + expect({ status: result.status, stderr: result.stderr, error: result.error?.message }).toMatchObject({ status: 0 }); + return JSON.parse(result.stdout.trim().split("\n").at(-1)!); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +const cliSetup = ` + import { mock } from 'bun:test'; + import { Readable } from 'node:stream'; + import { mkdirSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + const calls = [], lines = [], errors = []; + mock.module('./src/client/connect', () => ({ connectClient: async options => { + calls.push({ selected: options.selectedClients, explicit: options.selectedClientsExplicit }); + return { serverUrl:'http://127.0.0.1:22001', apiKeyId:'fixture', selectedClients:[] }; + }, disconnectClient:async()=>{}, revokeConnectedClientKey:async()=>{}, rotateConnectedClientKey:async()=>{} })); + const { handleConnectCommand } = await import('./src/cli/connect'); + const output = console.log; console.log = (...args) => lines.push(args.join(' ')); + console.error = (...args) => errors.push(args.join(' ')); + const credential = JSON.stringify({ apiKeyId:'fixture', key:'ocx_data_'+'a'.repeat(40) }); +`; + +test("Hub rejects generic clients before reading a credential or attempting connect", () => { + const result = scenario(cliSetup + ` + let reads=0; + const input = new Readable({ read() { reads++; this.push('fixture\\n'); this.push(null); } }); + const code = await handleConnectCommand(['http://127.0.0.1:22001','--admin-token-stdin','--clients','pi'], { stdinImpl:input }); + output(JSON.stringify({code,reads,calls,errors})); + `); + expect(result.code).toBe(2); + expect(result.reads).toBe(0); + expect(result.calls).toEqual([]); + expect(result.errors.join(" ")).toContain("codex and claude"); +}); + +test("Link accepts generic clients and completion uses the effective connection selection", () => { + const result = scenario(cliSetup + ` + mkdirSync(process.env.CODEX_HOME,{recursive:true}); + const { DEFAULT_CATALOG_PATH } = await import('./src/codex/paths'); + writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify({ models:[{slug:'fixture', supported_reasoning_levels:[{effort:'max'}]}] })); + const code = await handleConnectCommand(['--link','--key-stdin','--tunnel-port','22001','--link-id','lnk_0123456789abcdef','--clients','codex,pi','--no-sync'], { + stdinImpl:Readable.from([credential]), catalogProbeDeps:{supportedEfforts:()=>new Set(['high'])}, + }); + output(JSON.stringify({code,calls,lines,errors})); + `); + expect(result.code).toBe(0); + expect(result.calls).toEqual([{ selected: ["codex", "pi"], explicit: true }]); + expect(result.lines.some((line: string) => line.startsWith("Connected to"))).toBe(true); + expect(result.lines.some((line: string) => line.startsWith("Codex autostart shim:"))).toBe(false); +}); + +const realSetup = ` + import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, setPersistedConfigMutationBeforeCommitForTests } from './src/config'; + import { connectClient } from './src/client/connect'; + import { readClientConnectionState } from './src/client/state'; + import { readServiceApiTokenState } from './src/lib/service-secrets'; + import { readInitialRouteIntent } from './src/integrations/route-intent-initial'; + import { routeIntentStatePath } from './src/integrations/route-intent-state'; + import { DEFAULT_CATALOG_PATH } from './src/codex/paths'; + const linkId='lnk_0123456789abcdef'; + saveConfig(getDefaultConfig()); mkdirSync(process.env.CODEX_HOME,{recursive:true}); + const prior='{"models":[{"slug":"prior"}]}'; writeFileSync(DEFAULT_CATALOG_PATH,prior); + const fetchImpl=async input=> String(input).endsWith('/readyz') + ? Response.json({service:'opencodex',version:'1',uptime:1,pid:1,port:22001,status:'ready',protocol:1,minimumClientProtocol:1,managementUrl:'http://127.0.0.1:22001'}) + : String(input).endsWith('/v1/catalog') ? Response.json({models:[]}) : new Response(null,{status:404}); + const deps={fetchImpl,linkHome:{fetchImpl},catalogCompatibility:{supportedEfforts:()=>new Set()},lifecycleLockDeps:{lockPath:join(process.env.OPENCODEX_HOME,'lifecycle.sqlite')}}; + const options={serverUrl:'http://127.0.0.1:22001',managementTransport:'direct',transport:'link',link:{tunnelPort:22001,linkId}, + credential:{kind:'link',apiKeyId:'fixture',key:'ocx_data_'+'a'.repeat(40)},selectedClients:['pi'],selectedClientsExplicit:true,noSync:true}; +`; + +test("initial-selection write refusal rolls back token and catalog without committing the connection", () => { + const result = scenario(realSetup + ` + mkdirSync(routeIntentStatePath(),{recursive:true}); + mkdirSync(join(routeIntentStatePath(),'initial-'+linkId+'.json')); + let error; try { await connectClient(options,deps); } catch(e) { error=e.message; } + console.log(JSON.stringify({error,state:readClientConnectionState().kind,token:readServiceApiTokenState().kind, + catalog:readFileSync(DEFAULT_CATALOG_PATH,'utf8'),pending:existsSync(join(process.env.OPENCODEX_HOME,'client-connect-pending'))})); + `); + expect(result.error).toBeTruthy(); + expect(result.state).toBe("disconnected"); + expect(result.token).toBe("absent"); + expect(result.catalog).toBe('{"models":[{"slug":"prior"}]}'); + expect(result.pending).toBe(false); +}); + +test("config commit refusal withdraws the newly recorded initial selection and rolls back enrollment", () => { + const result = scenario(realSetup + ` + setPersistedConfigMutationBeforeCommitForTests(()=>{throw new Error('fixture commit refused');}); + let error; try { await connectClient(options,deps); } catch(e) { error=e.message; } + console.log(JSON.stringify({error,state:readClientConnectionState().kind,token:readServiceApiTokenState().kind, + initial:readInitialRouteIntent(linkId),catalog:readFileSync(DEFAULT_CATALOG_PATH,'utf8')})); + `); + expect(result.error).toContain("fixture commit refused"); + expect(result.state).toBe("disconnected"); + expect(result.token).toBe("absent"); + expect(result.initial).toEqual({}); + expect(result.catalog).toBe('{"models":[{"slug":"prior"}]}'); +}); + +test.each(["older", "current"])("join preserves explicit local selections with a %s Home", homeVersion => { + const result = scenario(realSetup + ` + const { joinHome } = await import('./src/client/link-join'); + const commands=[]; let restarts=0; + const result=await joinHome({ + runner:{run:async argv=>{ + const command=argv.at(-1); commands.push(command); + if (${JSON.stringify(homeVersion)}==='older' && command.includes('--clients')) + return {code:2,stdout:'',stderr:'Error: Unexpected argument(s): --clients pi\\nUsage: ocx link issue'}; + return {code:0,stdout:JSON.stringify({linkId,apiKeyId:'fixture',key:options.credential.key,listenerPort:23001}),stderr:''}; + },spawnTunnel:()=>{throw new Error('unexpected real tunnel');}}, + knownHostsFile:join(process.env.OPENCODEX_HOME,'known-hosts'),confirmedHost:{alias:'home',fingerprint:'SHA256:fixture',probedAt:1}, + now:()=>1,choosePort:async()=>22001,hostname:()=> 'child', + readSidecar:()=>null,writeState:()=>{},clearState:()=>{}, + spawnTunnel:()=>({pid:123,exited:new Promise(()=>{}),stop:async()=>{}}),scanListenPids:()=>({ok:true,pids:[123]}), + fetchImpl:async(input,init)=>String(input).endsWith('/readyz') && !new Headers(init?.headers).has('x-opencodex-api-key') + ? new Response(null,{status:401}) : fetchImpl(input,init), + selectedClients:['pi'],connectDeps:deps,connect:(options,deps)=>connectClient({...options,noSync:true},deps), + scheduleRestart:()=>{restarts++;}, + },{alias:'home'}); + console.log(JSON.stringify({result,commands,restarts,selection:readClientConnectionState().value.selectedClients, + initial:readInitialRouteIntent(linkId),token:readServiceApiTokenState().kind})); + `); + expect(result.commands).toHaveLength(homeVersion === "older" ? 2 : 1); + expect(result.commands[0]).toContain("--clients"); + if (homeVersion === "older") expect(result.commands[1]).not.toContain("--clients"); + expect(result.selection).toEqual(["pi"]); + expect(result.initial).toEqual({ codex: { enabled: false, revision: 0 }, claude: { enabled: false, revision: 0 }, pi: { enabled: true, revision: 0 } }); + expect(result.restarts).toBe(1); + expect(result.token).toBe("present"); +}); + +test.each([ + { code: 1, stdout: "", stderr: "Unexpected argument(s): --clients pi" }, + { code: 2, stdout: "", stderr: "Unexpected argument(s): --alias child" }, + { code: 2, stdout: "possible issue receipt", stderr: "Unexpected argument(s): --clients pi" }, +])("join never retries an ambiguous or unrelated issue failure: %j", failure => { + const result = scenario(` + import { joinHome } from './src/client/link-join'; + let calls=0,error; + try { await joinHome({ + runner:{run:async()=>{calls++;return ${JSON.stringify(failure)};},spawnTunnel:()=>{throw new Error('unexpected tunnel');}}, + knownHostsFile:process.env.HOME+'/known-hosts',confirmedHost:{alias:'home',fingerprint:'SHA256:fixture',probedAt:1}, + now:()=>1,choosePort:async()=>22001,hostname:()=> 'child',readSidecar:()=>null,selectedClients:['pi'],scheduleRestart:()=>{}, + },{alias:'home'}); } catch(e) {error=e.message;} + console.log(JSON.stringify({calls,error})); + `); + expect(result).toEqual({ calls: 1, error: "join_issue_failed" }); +}); diff --git a/tests/clients/route-intent-connected-review.test.ts b/tests/clients/route-intent-connected-review.test.ts new file mode 100644 index 00000000000..ac4c8eeea09 --- /dev/null +++ b/tests/clients/route-intent-connected-review.test.ts @@ -0,0 +1,162 @@ +import { expect, test, setDefaultTimeout } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); + +function scenario(body: string, options: { noSync?: boolean; olderHome?: boolean; selectedClient?: "pi" | "droid" } = {}) { + const root = mkdtempSync(join(tmpdir(), "ocx-route-review-")); + const home = join(root, "home"); mkdirSync(home); + try { + const result = spawnSync(process.execPath, ["--eval", ` + import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs'; + import { join, dirname } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { connectClient, disconnectClient } from './src/client/connect'; + import { readClientConnectionState } from './src/client/state'; + import { readServiceApiTokenState } from './src/lib/service-secrets'; + import { createIntegrationStateStore } from './src/integrations/store'; + import { INTEGRATION_CLIENTS } from './src/integrations/registry'; + import { materializeLocalRouteIntent } from './src/client/route-intent-apply'; + import { applyConnectedRouteIntent } from './src/integrations/route-intent-connected'; + import { routeIntentStatePath, readRouteIntentReceipt, setLocalRouteIntentOff, readLocalRouteIntentOff } from './src/integrations/route-intent-state'; + import { homeRouteIntentPath, readHomeRouteIntentRecord } from './src/client/link-home'; + import { ROUTE_CLIENT_IDS } from './src/link/route-intent'; + const home = process.env.HOME; + const config = getDefaultConfig(); config.port = 18274; config.hostname = '127.0.0.1'; saveConfig(config); + const path = INTEGRATION_CLIENTS.pi.configPath({}, home); + mkdirSync(dirname(path), {recursive:true}); + const document = {version:1, home:{name:'review-fixture',version:'1'}, + clients:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,{enabled:id==='pi'}])), + sources:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,'default']))}; + const fetchImpl = async input => { + const url=String(input); + if(url.endsWith('/readyz'))return Response.json({service:'opencodex',version:'1',uptime:1,pid:1,port:22001, + status:'ready',protocol:1,minimumClientProtocol:1,managementUrl:'http://127.0.0.1:22001'}); + if(url.endsWith('/v1/catalog'))return Response.json({models:[]}); + if(url.endsWith('/route-intent'))return ${options.olderHome === true} ? new Response(null,{status:404}) : Response.json(document); + if(url.endsWith('/v1/models'))return Response.json({data:[{id:'fixture/model'}]}); + return new Response(null,{status:404}); + }; + const deps={fetchImpl,linkHome:{fetchImpl},catalogCompatibility:{supportedEfforts:()=>new Set()}, + lifecycleLockDeps:{lockPath:join(process.env.OPENCODEX_HOME,'lifecycle.sqlite')}}; + const connection=await connectClient({serverUrl:'http://127.0.0.1:22001',managementTransport:'direct',transport:'link', + link:{tunnelPort:22001,linkId:'lnk_0123456789abcdef'},credential:{kind:'link',apiKeyId:'fixture-key',key:'ocx_data_'+'a'.repeat(40)}, + selectedClients:[${JSON.stringify(options.selectedClient ?? "pi")}],selectedClientsExplicit:true,noSync:${options.noSync === true}},deps); + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, env: { ...process.env, + HOME: home, USERPROFILE: home, OPENCODEX_HOME: join(home, ".opencodex"), CODEX_HOME: join(home, ".codex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), + XDG_CONFIG_HOME: join(home, ".config"), GROK_HOME: join(home, ".grok"), PI_CODING_AGENT_DIR: join(home, ".pi", "agent"), + OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: result.status, signal: result.signal, error: result.error?.message, stderr: result.stderr }).toMatchObject({ status: 0, signal: null }); + return JSON.parse(result.stdout.trim().split("\n").at(-1)!); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test("disconnect restores the previously absent standalone Codex flag", () => { + const result = scenario(` + const during=loadConfig().clientIntegrations?.codex; + let error; try { await disconnectClient({},deps); } catch(e) { error=e.message; } + const after=loadConfig(); + console.log(JSON.stringify({during,error,state:readClientConnectionState().kind, + hasCodex:Object.hasOwn(after.clientIntegrations??{},'codex')})); + `); + expect(result.during).toBe(false); + expect(result.error).toBeUndefined(); + expect(result.state).toBe("disconnected"); + expect(result.hasCodex).toBe(false); +}); + +// The local opt-out list is the person's own choice on this machine, not a Home write. +test("disconnect keeps the Child's local route opt-outs", () => { + const result = scenario(` + setLocalRouteIntentOff('pi',true); + let error; try { await disconnectClient({},deps); } catch(e) { error=e.message; } + console.log(JSON.stringify({error,state:readClientConnectionState().kind,off:readLocalRouteIntentOff()})); + `); + expect(result.error).toBeUndefined(); + expect(result.state).toBe("disconnected"); + expect(result.off).toEqual(["pi"]); +}); + +test("review: isolated local-off fallback reads its own opt-out and previous document", () => { + const result = scenario(` + const statePath=join(home,'isolated-state'), store=createIntegrationStateStore(join(home,'isolated-store')); + const isolated=structuredClone(document); isolated.home.name='isolated-proof'; + await materializeLocalRouteIntent(loadConfig(),isolated,[{namespaced:'fixture/model',provider:'fixture',id:'model'}],{statePath,store,onlyClients:['pi'],lifecycleLockDeps:deps.lifecycleLockDeps}); + setLocalRouteIntentOff('pi',true,statePath); + const applied=await applyConnectedRouteIntent(connection,'fixture',undefined,{statePath,store,lifecycleLockDeps:deps.lifecycleLockDeps},deps.linkHome); + console.log(JSON.stringify({present:!!applied,name:applied?.document.home.name,source:applied?.sources.pi, + pi:applied?.outcomes.find(row=>row.client==='pi').outcome,file:JSON.parse(readFileSync(path,'utf8'))})); + `, { noSync: true }); + expect(result).toEqual({ present: true, name: "isolated-proof", source: "local", pi: "current", file: {} }); +}); + +test("review: aborted connected apply withdraws its prior Home confirmation", () => { + const result = scenario(` + const before=readHomeRouteIntentRecord(connection.link.linkId); + let calls=0,error; + try{await applyConnectedRouteIntent(connection,'fixture',{document,fetchedAt:new Date().toISOString(),stale:false}, + {beforeWrite:()=>{if(++calls>=2)throw new Error('fixture_abort');},lifecycleLockDeps:deps.lifecycleLockDeps},deps.linkHome);}catch(e){error=e.message;} + console.log(JSON.stringify({before:!!before?.appliedRouteIntentDigest,error,after:!!readHomeRouteIntentRecord(connection.link.linkId)})); + `); + expect(result).toEqual({ before: true, error: "fixture_abort", after: false }); +}); + +test.each([{ noSync: true }, { olderHome: true }, { noSync: true, recoverConfig: true }])("review: never-applied Pi disconnects safely (%j)", options => { + const result = scenario(` + const before={receipt:readRouteIntentReceipt(),file:existsSync(path)}; + let error;try{await disconnectClient({recoverConfig:${"recoverConfig" in options && options.recoverConfig === true}},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({before,error,state:readClientConnectionState().kind,token:readServiceApiTokenState().kind})); + `, options); + expect(result).toEqual({ before: { receipt: null, file: false }, state: "disconnected", token: "absent" }); +}); + +for (const residue of [true, false]) test(`review: missing Droid receipt proves roster-independent absence (residue: ${residue})`, () => { + const result = scenario(` + const droidPath=INTEGRATION_CLIENTS.droid.configPath({},home);mkdirSync(dirname(droidPath),{recursive:true}); + const rows=${residue}?[{model:'removed-provider/removed-model',displayName:'OpenCodex: Removed model',baseUrl:'http://127.0.0.1:18274/v1',provider:'generic-chat-completion-api'}]:[{model:'personal/model',displayName:'Personal',baseUrl:'https://personal.invalid/v1'}]; + writeFileSync(droidPath,JSON.stringify({customModels:rows}));const before=readFileSync(droidPath,'utf8'); + let error;try{await disconnectClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,unchanged:readFileSync(droidPath,'utf8')===before,state:readClientConnectionState().kind,token:readServiceApiTokenState().kind})); + `, { noSync:true, selectedClient:"droid" }); + expect(result.unchanged).toBe(true); + if (residue) { expect(result.error).toContain("receipt is missing");expect(result.state).toBe("connected");expect(result.token).toBe("present"); } + else { expect(result.error).toBeUndefined();expect(result.state).toBe("disconnected");expect(result.token).toBe("absent"); } +}); + +test("review: unowned Pi route without receipts cannot be mistaken for never-applied", () => { + const result = scenario(` + writeFileSync(path,JSON.stringify({providers:{opencodex:{baseUrl:'http://127.0.0.1:18274/v1',models:[]}}})); + const before=readFileSync(path,'utf8'); + let error;try{await disconnectClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,state:readClientConnectionState().kind,token:readServiceApiTokenState().kind,same:readFileSync(path,'utf8')===before})); + `, { noSync: true }); + expect(result.error).toContain("receipt is missing"); + expect(result).toMatchObject({ state: "connected", token: "present", same: true }); +}); + +test("review: never-applied Pi disconnect preserves unrelated settings", () => { + const result = scenario(` + writeFileSync(path,JSON.stringify({userSetting:'keep'})); + await disconnectClient({},deps); + console.log(JSON.stringify({state:readClientConnectionState().kind,file:JSON.parse(readFileSync(path,'utf8'))})); + `, { noSync: true }); + expect(result).toEqual({ state: "disconnected", file: { userSetting: "keep" } }); +}); + +test("review: missing receipt with injected Pi state retains the connection and token", () => { + const result = scenario(` + unlinkSync(join(routeIntentStatePath(),'receipt.json')); + const before=readFileSync(path,'utf8'); + let error;try{await disconnectClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,state:readClientConnectionState().kind,token:readServiceApiTokenState().kind,same:readFileSync(path,'utf8')===before})); + `); + expect(result.error).toContain("receipt is missing"); + expect(result).toMatchObject({ state: "connected", token: "present", same: true }); +}); diff --git a/tests/clients/route-intent-connected.test.ts b/tests/clients/route-intent-connected.test.ts new file mode 100644 index 00000000000..7786d7e148a --- /dev/null +++ b/tests/clients/route-intent-connected.test.ts @@ -0,0 +1,423 @@ +import { expect, test, setDefaultTimeout } from "bun:test"; +import { startRouteIntentTick } from "../../src/integrations/route-intent-tick"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +setDefaultTimeout(SPAWN_BUDGET_MS); + +// Isolated cold runtime startup can exceed 20s under concurrent CI/worktrees; protocol deadlines and all behavioral assertions remain unchanged. +function scenario(body: string, enabledIds = ["pi"], explicitClients?: string[], servedId = "command-code/deepseek/deepseek-v4.1-flash-fast", fixture: { olderHome?: boolean; combos?: boolean; canonical?: string } = {}) { + const root = mkdtempSync(join(tmpdir(), "ocx-route-connected-")); const home = join(root, "home"); mkdirSync(home); + try { + const result = spawnSync(process.execPath, ["--eval", ` + import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; + import { join, dirname } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig, setPersistedConfigMutationBeforeCommitForTests } from './src/config'; + import { connectClient, syncConnectedClient, disconnectClient } from './src/client/connect'; + import { readServiceApiTokenState, writeServiceApiTokenFile } from './src/lib/service-secrets'; + import { readDesktopDisconnectReceipt } from './src/claude/desktop-remote-store'; + import { writeRuntimePort, removeRuntimePort } from './src/config/process-state'; + import { resolveDesktop3pAlias, generateDesktop3pModels } from './src/claude/desktop-3p'; + import { materializeLocalRouteIntent } from './src/client/route-intent-apply'; + import { readInitialRouteIntent } from './src/integrations/route-intent-initial'; + import { createIntegrationStateStore } from './src/integrations/store'; + import { routeIntentStatePath } from './src/integrations/route-intent-state'; + import { homeRouteIntentPath } from './src/client/link-home'; + import { fetchLinkHomeManagement } from './src/client/link-home'; + import { handleLinkManagementRequest } from './src/server/index/link-management'; + import { writeLinkStore } from './src/link/store'; + import { linkStorePath } from './src/link/paths'; + import { readClientConnectionState } from './src/client/state'; + import { ROUTE_CLIENT_IDS } from './src/link/route-intent'; + import { setLocalRouteIntentOff, readRouteIntentReceipt, cleanupRouteIntent } from './src/integrations/route-intent'; + import { INTEGRATION_CLIENTS } from './src/integrations/registry'; + const home = process.env.HOME; + const local = getDefaultConfig(); local.port = 18274; local.hostname = '0.0.0.0'; saveConfig(local); + const path = INTEGRATION_CLIENTS.pi.configPath({}, home); + mkdirSync(INTEGRATION_CLIENTS.pi.detectDir({}, home), { recursive: true }); mkdirSync(dirname(path), { recursive: true }); + let id = ${JSON.stringify(servedId)}; + let enabled = true; + let routeSupported = ${!fixture.olderHome}; let clientRevisions = {}; let offline=false; let offObserved=false; + let modelsHook; + const enabledIds = ${JSON.stringify(enabledIds)}; + if(enabledIds.includes('grok')) mkdirSync(process.env.GROK_HOME,{recursive:true}); + if(enabledIds.includes('claude')) mkdirSync(process.env.CLAUDE_CONFIG_DIR,{recursive:true}); + if(enabledIds.includes('claude-desktop')) { + mkdirSync(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR,{recursive:true}); + writeFileSync(join(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR,'_meta.json'),JSON.stringify({entries:[]})); + } + const document = () => ({ version: 1, home: { name: 'fixture-home', version: '1' }, clientRevisions, + clients: Object.fromEntries(ROUTE_CLIENT_IDS.map(client => [client, { enabled: enabledIds.includes(client) && enabled }])), + sources: Object.fromEntries(ROUTE_CLIENT_IDS.map(client => [client, 'default'])) }); + const requests = []; + const factsConfig={...local,combos:{'fixture-bare':{alias:'gpt-5.6',nativeAlias:true,targets:[]},'fixture-slash':{alias:'a/vendor-model',targets:[]}}}; + if(${fixture.combos === true})writeLinkStore(linkStorePath(),{version:1,listenerPort:null,links:[{id:'lnk_0123456789abcdef',alias:'fixture-home',direction:'client-initiated',hostKeyFingerprint:null,tunnelPort:22001,apiKeyId:'fixture-key',createdAt:'2026-11-07T00:00:00.000Z'}]}); + const fetchImpl = async (input, init) => { + const url = String(input); requests.push({ method: init?.method ?? 'GET', url }); + if(offline){if(url.endsWith('/v1/models'))offObserved=JSON.parse(readFileSync(path,'utf8')).providers?.opencodex===undefined;throw new TypeError('fixture Home offline');} + if (url.endsWith('/readyz')) return Response.json({ service:'opencodex',version:'1',uptime:1,pid:1,port:22001, + status:'ready', protocol:1, minimumClientProtocol:1, managementUrl:'http://127.0.0.1:22001' }); + if (url.endsWith('/v1/catalog')) return Response.json({models:[]}); + if (url.endsWith('/route-intent')) return routeSupported ? Response.json(document()) : new Response(null,{status:404}); + if(url.endsWith('/route-catalog'))return handleLinkManagementRequest(new Request(url,init),new URL(url),factsConfig,{},undefined,'fixture-key'); + if (url.endsWith('/v1/models')) { await modelsHook?.(); return Response.json({data:${fixture.combos ? "[{id:'gpt-5.6',owned_by:'openai',is_combo:true},{id:'a/vendor-model',owned_by:'openai',is_combo:true}]" : `[{id,...(!id.includes('/')?{owned_by:'openai'}:{}),${fixture.canonical ? `native_id:${JSON.stringify(fixture.canonical)}` : ""}}]`}}); } + return new Response(null,{status:404}); + }; + const deps = { fetchImpl, linkHome:{fetchImpl}, catalogCompatibility:{supportedEfforts:()=>new Set()}, + lifecycleLockDeps: {lockPath:join(process.env.OPENCODEX_HOME,'lifecycle.sqlite')} }; + const connection = await connectClient({serverUrl:'http://127.0.0.1:22001',managementTransport:'direct',transport:'link', + link:{tunnelPort:22001,linkId:'lnk_0123456789abcdef'},credential:{kind:'link',apiKeyId:'fixture-key',key:'ocx_data_'+'a'.repeat(40)}, + selectedClients:${JSON.stringify(explicitClients ?? ["codex", "claude"])},selectedClientsExplicit:${explicitClients !== undefined}}, deps); + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, env: { ...process.env, + HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), + XDG_CONFIG_HOME: join(home, ".config"), GROK_HOME: join(home, ".grok"), OCX_TEST_HOME_GUARD: "1" } }); + expect(result.stderr).not.toContain("local route intent needs reconciliation"); + expect({ status: result.status, signal: result.signal, error: result.error?.message, stderr: result.stderr }).toMatchObject({ status: 0, signal: null }); + return JSON.parse(result.stdout.trim().split("\n").at(-1)!); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test("connect inherits non-native intent, sync refreshes exact served ids, off removes default-store owned keys", () => { + const result = scenario(` + const first = JSON.parse(readFileSync(path,'utf8')); + const receipt = readRouteIntentReceipt(); + id = 'child/new-model'; + const synced = await syncConnectedClient({}, deps); + const refreshed = readFileSync(path,'utf8'); + const edited = JSON.parse(refreshed); edited.userEdit = 'keep'; writeFileSync(path,JSON.stringify(edited)); + enabled = false; await syncConnectedClient({}, deps); + console.log(JSON.stringify({selected:connection.selectedClients,first,refreshed,after:JSON.parse(readFileSync(path,'utf8')), + receiptOutcome:receipt.outcomes.find(row=>row.client==='pi'),allReads:requests.every(row=>row.method==='GET'),syncSelected:synced.connection.selectedClients})); + `); + expect(result.selected).toEqual(["pi"]); + expect(JSON.stringify(result.first)).toContain("18274"); + expect(JSON.stringify(result.first)).toContain("command-code/deepseek/deepseek-v4.1-flash-fast"); + expect(result.refreshed).toContain("child/new-model"); + expect(result.refreshed).not.toContain("deepseek"); + expect(result.after).toEqual({ userEdit: "keep" }); + expect(result.allReads).toBe(true); + expect(result.receiptOutcome.outcome).toBe("current"); +}); + +test("a connection replaced during model fetch stops route writes and withdraws the old receipt digest", () => { + const result = scenario(` + const before=readFileSync(path,'utf8'); + modelsHook=()=>{const changed=loadConfig();delete changed.client;changed.runtimeRole='standalone';saveConfig(changed);}; + let error;try{await syncConnectedClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,same:readFileSync(path,'utf8')===before,digest:readRouteIntentReceipt().appliedRouteIntentDigest,state:readClientConnectionState().kind})); + `); + expect(result).toEqual({ error: "client_connection_changed", same: true, digest: null, state: "disconnected" }); +}); + +test("native Child adapters materialize Grok and Desktop locally and keep the Claude launcher gate", () => { + const result = scenario(` + const receipt=readRouteIntentReceipt(); + const desktop=receipt.outcomes.find(row=>row.client==='claude-desktop'); + console.log(JSON.stringify({selected:connection.selectedClients, + outcomes:receipt.outcomes.filter(row=>['claude','grok','claude-desktop'].includes(row.client)), + desktop:JSON.parse(readFileSync(desktop.target,'utf8')),grok:readFileSync(join(process.env.GROK_HOME,'config.toml'),'utf8'), + claudeEnabled:loadConfig().claudeCode?.enabled!==false})); + `, ["claude", "grok", "claude-desktop"]); + expect(result.selected).toEqual(["claude", "claude-desktop", "grok"]); + expect(result.outcomes.map((row: { outcome: string }) => row.outcome)).toEqual(["current", "current", "current"]); + expect(result.desktop.inferenceGatewayBaseUrl).toBe("http://127.0.0.1:18274"); + expect(result.desktop.inferenceGatewayApiKey).toBe("opencodex-loopback"); + expect(result.grok).toContain("18274"); + expect(result.grok).toContain("command-code/deepseek/deepseek-v4.1-flash-fast"); + expect(result.claudeEnabled).toBe(true); +}); + +test("runtime reconciliation waits for each sync, refreshes every tick, and stops without rearming", async () => { + const scheduled: Array<{ callback: () => void; ms: number }> = []; + let release!: () => void; let calls = 0; + const controller = startRouteIntentTick({ transport: "link", link: { linkId: "lnk_0123456789abcdef", tunnelPort: 22001 } } as never, { + sync: () => { calls += 1; return new Promise(resolve => { release = resolve; }); }, + setTimer: (callback, ms) => { const next = { callback, ms }; scheduled.push(next); return next; }, clearTimer: () => {}, + }); + expect(scheduled.shift()!.ms).toBe(2_000); + // Recreate the timer callback through the normal start API for an independent tick. + controller.stop(); + const tick = startRouteIntentTick({ transport: "link", link: { linkId: "lnk_0123456789abcdef", tunnelPort: 22001 } } as never, { + sync: () => { calls += 1; return new Promise(resolve => { release = resolve; }); }, + setTimer: (callback, ms) => { const next = { callback, ms }; scheduled.push(next); return next; }, clearTimer: () => {}, + }); + scheduled.shift()!.callback(); expect(calls).toBe(1); expect(scheduled).toHaveLength(0); + release(); await Promise.resolve(); await Promise.resolve(); + expect(scheduled[0]!.ms).toBe(60_000); + scheduled.shift()!.callback(); expect(calls).toBe(2); + tick.stop(); release(); await Promise.resolve(); await Promise.resolve(); + expect(scheduled).toHaveLength(0); +}); +test("watcher sync requests coalesce with a running periodic sync", async () => { + const queue: Array<{ callback: () => void; ms: number }> = []; + let calls = 0; let release!: () => void; + const tick = startRouteIntentTick({ transport: "link", link: { linkId: "lnk_0123456789abcdef", tunnelPort: 22001 } } as never, { + sync: () => { calls += 1; return new Promise(resolve => { release = resolve; }); }, + setTimer: (callback, ms) => { const timer = { callback, ms }; queue.push(timer); return timer; }, + clearTimer: timer => { const i = queue.indexOf(timer as typeof queue[number]); if (i >= 0) queue.splice(i, 1); }, + }); + queue.shift()!.callback(); + const first = tick.requestSync(); const second = tick.requestSync(); + expect(calls).toBe(1); expect(queue).toHaveLength(0); + release(); await Promise.resolve(); await Promise.resolve(); + expect(calls).toBe(2); expect(queue).toHaveLength(0); + release(); await Promise.all([first, second]); + expect(queue).toHaveLength(1); expect(queue[0]!.ms).toBe(60_000); + tick.stop(); expect(queue).toHaveLength(0); +}); + +test("local off persists across sync and inherits after clear; one-shot overwrite does not survive the next sync", () => { + const result = scenario(` + setLocalRouteIntentOff('pi',true); const off = await syncConnectedClient({},deps); + const offReceipt=readRouteIntentReceipt(); const offFile=JSON.parse(readFileSync(path,'utf8')); + setLocalRouteIntentOff('pi',false); await syncConnectedClient({},deps); + writeFileSync(path,JSON.stringify({providers:{opencodex:{baseUrl:'https://foreign.example.test',models:[]}}})); + const conflict = await syncConnectedClient({},deps); + const overwritten = await syncConnectedClient({overwriteConflict:true},deps); + writeFileSync(path,JSON.stringify({providers:{opencodex:{baseUrl:'https://foreign.example.test',models:[]}}})); + const again = await syncConnectedClient({},deps); + console.log(JSON.stringify({off:off.connection.selectedClients,offFile,source:offReceipt.sources.pi,outcome:offReceipt.outcomes.find(row=>row.client==='pi'), + conflict:conflict.routeIntent.outcomes.find(row=>row.client==='pi').outcome,overwritten:overwritten.routeIntent.outcomes.find(row=>row.client==='pi').outcome, + again:again.routeIntent.outcomes.find(row=>row.client==='pi').outcome})); + `); + expect(result.off).toEqual([]); + expect(result.offFile).toEqual({}); + expect(result.source).toBe("local"); + expect(result.outcome.source).toBe("local"); + expect([result.conflict, result.overwritten, result.again]).toEqual(["conflict", "current", "conflict"]); +}); + +test("explicit provisioning selection survives ticks/defaults, and Home inherit revision clears only that client", () => { + const result = scenario(` + const initial=readRouteIntentReceipt().outcomes.find(row=>row.client==='pi'); + enabled=false; + const kept=await syncConnectedClient({},deps); + clientRevisions={pi:1}; + const inherited=await syncConnectedClient({},deps); + const final=readRouteIntentReceipt().outcomes.find(row=>row.client==='pi'); + console.log(JSON.stringify({initial:initial.initialIntent,revision:initial.initialRevision,kept:kept.connection.selectedClients, + inherited:inherited.connection.selectedClients,initialCleared:final.initialIntent===undefined,after:JSON.parse(readFileSync(path,'utf8'))})); + `, ["pi"], ["pi"]); + expect(result).toEqual({ initial: { enabled: true }, revision: 0, kept: ["pi"], inherited: [], initialCleared: true, after: {} }); +}); + +test("local opt-out still removes owned routing and selection when Home has no route document", () => { + const result = scenario(` + routeSupported=false;setLocalRouteIntentOff('pi',true); + const synced=await syncConnectedClient({},deps); + console.log(JSON.stringify({selected:synced.connection.selectedClients,after:JSON.parse(readFileSync(path,'utf8')), + outcome:readRouteIntentReceipt().outcomes.find(row=>row.client==='pi').outcome})); + `); + expect(result).toEqual({ selected: [], after: {}, outcome: "current" }); +}); + +test("runtime/config port disagreement refuses before rewriting a client", () => { + const result = scenario(` + const before=readFileSync(path,'utf8');writeRuntimePort({pid:process.pid,port:18275}); + let error;try{await syncConnectedClient({},deps);}catch(e){error=e.message;} + removeRuntimePort(process.pid); + console.log(JSON.stringify({same:before===readFileSync(path,'utf8'),error})); + `); + expect(result.same).toBe(true); expect(result.error).toContain("client_runtime_changed"); +}); + +test("Claude off prunes marker-owned agent definitions and preserves user-authored definitions", () => { + const result = scenario(` + const agents=join(process.env.CLAUDE_CONFIG_DIR,'agents');mkdirSync(agents,{recursive:true}); + writeFileSync(join(agents,'ocx-owned.md'),'generated-by: opencodex');writeFileSync(join(agents,'ocx-personal.md'),'user authored'); + enabled=false;const synced=await syncConnectedClient({},deps); + console.log(JSON.stringify({owned:existsSync(join(agents,'ocx-owned.md')),personal:readFileSync(join(agents,'ocx-personal.md'),'utf8'), + outcome:synced.routeIntent.outcomes.find(row=>row.client==='claude').outcome})); + `, ["claude"]); + expect(result).toEqual({ owned: false, personal: "user authored", outcome: "current" }); +}); + +test("bare native model stays a native Desktop model rather than an opencodex provider alias", () => { + const result = scenario(` + const target=readRouteIntentReceipt().outcomes.find(row=>row.client==='claude-desktop').target; + const desktop=JSON.parse(readFileSync(target,'utf8')); + console.log(JSON.stringify({desktop,routes:desktop.inferenceModels.map(row=>resolveDesktop3pAlias(row.name))})); + `, ["claude-desktop"], undefined, "gpt-5.6"); + expect(JSON.stringify(result.desktop)).not.toContain("opencodex/gpt"); + expect(result.routes).toEqual(["native/gpt-5.6"]); +}); + +test("disconnect cleans file routes before removing the service token and preserves later user fields", () => { + const result = scenario(` + const edited=JSON.parse(readFileSync(path,'utf8'));edited.userEdit='survive';writeFileSync(path,JSON.stringify(edited)); + await disconnectClient({},deps); + console.log(JSON.stringify({after:JSON.parse(readFileSync(path,'utf8')),state:readClientConnectionState().kind,token:readServiceApiTokenState().kind, + common:readRouteIntentReceipt(),homeReceipt:existsSync(homeRouteIntentPath())})); + `); + expect(result).toEqual({ after: { userEdit: "survive" }, state: "disconnected", token: "absent", common: null, homeReceipt: false }); +}); + +test("a present corrupt route receipt refuses actual disconnect instead of silently skipping Pi cleanup", () => { + const result = scenario(` + const before=readFileSync(path,'utf8');writeFileSync(join(routeIntentStatePath(),'receipt.json'),'null'); + let error;try{await disconnectClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,same:before===readFileSync(path,'utf8'),state:readClientConnectionState().kind,token:readServiceApiTokenState().kind, + phase:readDesktopDisconnectReceipt().value.phase})); + `); + expect(result.same).toBe(true); expect(result.state).toBe("connected"); expect(result.token).toBe("present"); + expect(result.phase).toBe("prepared"); expect(result.error).toContain("receipt is malformed"); +}); + +test("explicit recovery snapshots only the owned edited block, removes it, and preserves sibling edits", () => { + const result = scenario(` + const changed=JSON.parse(readFileSync(path,'utf8'));changed.providers.opencodex.baseUrl='https://foreign.example.test';changed.userEdit='survive';writeFileSync(path,JSON.stringify(changed)); + let refused=false;try{await disconnectClient({},deps);}catch{refused=true;} + try { await disconnectClient({recoverConfig:true},deps); } catch (error) { throw new Error(JSON.stringify(readRouteIntentReceipt()?.outcomes.filter(row=>row.outcome==='conflict'||row.outcome==='failed'))); } + const store=createIntegrationStateStore();const overwritten=store.listOperations('pi').find(entry=>entry.kind==='overwrite'); + const snapshot=overwritten?store.readSnapshot(overwritten):null; + console.log(JSON.stringify({refused,after:JSON.parse(readFileSync(path,'utf8')),token:readServiceApiTokenState().kind, + snapshot:snapshot?.kind==='stored'?JSON.parse(snapshot.text):null})); + `); + expect(result.refused).toBe(true); expect(result.after).toEqual({ userEdit: "survive" }); expect(result.token).toBe("absent"); + expect(result.snapshot.userEdit).toBe("survive"); expect(result.snapshot.providers.opencodex.baseUrl).toBe("https://foreign.example.test"); +}); + +test("a cleanup conflict retains the connection, credential and pending disconnect evidence", () => { + const result = scenario(` + const changed=JSON.parse(readFileSync(path,'utf8'));changed.providers.opencodex.baseUrl='https://foreign.example.test';writeFileSync(path,JSON.stringify(changed)); + const before=readFileSync(path,'utf8');let error;try{await disconnectClient({},deps);}catch(e){error=e.message;} + console.log(JSON.stringify({error,same:before===readFileSync(path,'utf8'),state:readClientConnectionState().kind,token:readServiceApiTokenState().kind, + phase:readDesktopDisconnectReceipt().value.phase})); + `); + expect(result.same).toBe(true); expect(result.state).toBe("connected"); expect(result.token).toBe("present"); + expect(result.phase).toBe("prepared"); expect(result.error).toContain("cleanup was partial"); +}); + +test("combo aliases decode against the independently generated Home registry, even with owned_by openai", () => { + const result = scenario(` + const target=readRouteIntentReceipt().outcomes.find(row=>row.client==='claude-desktop').target; + const child=JSON.parse(readFileSync(target,'utf8')).inferenceModels; + const homeRegistry=generateDesktop3pModels([],[{provider:'combo',id:'fixture-bare'},{provider:'combo',id:'fixture-slash'}]); + let unsafeSent=0;let refused=false;try{await fetchLinkHomeManagement(connection,'fixture','/api/models',{}, {fetchImpl:async()=>{unsafeSent++;return Response.json([]);}});}catch{refused=true;} + const badUrl=new URL('http://127.0.0.1:22001/v1/link-management/api/models'); + const denied=await handleLinkManagementRequest(new Request(badUrl),badUrl,factsConfig,{},undefined,'fixture-key'); + const factsUrl=new URL('http://127.0.0.1:22001/v1/link-management/route-catalog'); + const facts=await (await handleLinkManagementRequest(new Request(factsUrl),factsUrl,factsConfig,{},undefined,'fixture-key')).json(); + const unauth=await handleLinkManagementRequest(new Request(factsUrl),factsUrl,factsConfig,{}); + const post=await handleLinkManagementRequest(new Request(factsUrl,{method:'POST'}),factsUrl,factsConfig,{},undefined,'fixture-key'); + console.log(JSON.stringify({routes:child.map(row=>resolveDesktop3pAlias(row.name)),same:child.every(row=>homeRegistry.some(expected=>expected.name===row.name)),refused,unsafeSent,denied:denied.status, + facts,unauth:unauth.status,post:post.status,factsOnly:requests.filter(row=>row.url.includes('route-catalog')).every(row=>row.method==='GET'),modelsApi:requests.some(row=>row.url.includes('/api/models'))})); + `, ["claude-desktop"], undefined, "gpt-5.6", { combos: true }); + expect(result.same).toBe(true); expect(result.routes).toEqual(["combo/fixture-bare", "combo/fixture-slash"]); + expect(result.refused).toBe(true); expect(result.unsafeSent).toBe(0); expect(result.denied).toBe(404); + expect(result.factsOnly).toBe(true); expect(result.modelsApi).toBe(false); + expect(result.unauth).toBe(403); expect(result.post).toBe(404); + expect(result.facts).toEqual({ version: 1, combos: [{ publicId: "gpt-5.6", comboId: "fixture-bare" }, { publicId: "a/vendor-model", comboId: "fixture-slash" }] }); +}); + +test.each([ + { canonical: "a/vendor-model", expected: "a/vendor-model", native: false }, + { canonical: "gpt-5.6", expected: "native/gpt-5.6", native: true }, +])("promoted public aliases preserve file ids and Desktop canonical routing: %s", fixture => { + const result = scenario(` + const target=readRouteIntentReceipt().outcomes.find(row=>row.client==='claude-desktop').target; + const child=JSON.parse(readFileSync(target,'utf8')).inferenceModels; + const homeRegistry=generateDesktop3pModels(${fixture.native ? "['gpt-5.6'],[]" : "[],[{provider:'a',id:'vendor-model'}]"}); + console.log(JSON.stringify({file:JSON.parse(readFileSync(path,'utf8')),routes:child.map(row=>resolveDesktop3pAlias(row.name)), + same:child.every(row=>homeRegistry.some(expected=>expected.name===row.name))})); + `, ["pi", "claude-desktop"], undefined, "public-alias", { canonical: fixture.canonical }); + expect(result.same).toBe(true); + expect(result.routes).toEqual([fixture.expected]); + expect(JSON.stringify(result.file)).toContain("public-alias"); +}); + +test("cached Home intent cannot block local off while catalog discovery is offline", () => { + const result = scenario(` + setLocalRouteIntentOff('pi',true);offline=true; + const synced=await syncConnectedClient({},deps);const receipt=readRouteIntentReceipt(); + console.log(JSON.stringify({offObserved,pi:receipt.outcomes.find(row=>row.client==='pi').outcome, + desktop:receipt.outcomes.find(row=>row.client==='claude-desktop').outcome,pending:receipt.catalogPending,stale:synced.stale,digest:receipt.appliedRouteIntentDigest})); + `, ["pi", "claude-desktop"]); + expect(result).toEqual({ offObserved: true, pi: "current", desktop: "stale", pending: true, stale: true, digest: null }); +}); + +test("explicit local sync reports deferred when Home is offline even with cached all-off receipts", () => { + const result = scenario(` + const {syncLocalRouteTo}=await import('./src/routing/route-to-local'); + offline=true; + const status=await syncLocalRouteTo({connection,linkId:connection.link.linkId,homePhrase:'fixture Home'},false, + {sync:options=>syncConnectedClient(options,deps)}); + console.log(JSON.stringify({deferred:status.deferred,allCurrent:status.devices[0].clients.every(row=>row.applied==='current')})); + `, []); + expect(result).toEqual({ deferred: { reason: "intent_or_catalog_pending", clients: [] }, allCurrent: true }); +}); + +test("older Home enrollment stores explicit initial choices and applies them after a revision-zero upgrade", () => { + const result = scenario(` + const initial=readInitialRouteIntent(connection.link.linkId); + routeSupported=true;enabled=false; + const synced=await syncConnectedClient({},deps); + console.log(JSON.stringify({initial:initial.pi,selected:synced.connection.selectedClients,pi:readRouteIntentReceipt().outcomes.find(row=>row.client==='pi').outcome})); + `, ["pi"], ["pi"], undefined, { olderHome: true }); + expect(result).toEqual({ initial: { enabled: true, revision: 0 }, selected: ["pi"], pi: "current" }); +}); + +test("a local-only opt-out never invents an applied Home receipt for an unsupported Home", () => { + const result = scenario(` + routeSupported=false;setLocalRouteIntentOff('pi',true); + await syncConnectedClient({},deps); + console.log(JSON.stringify({local:readRouteIntentReceipt().catalogPending,home:existsSync(homeRouteIntentPath()), + file:JSON.parse(readFileSync(path,'utf8'))})); + `); + expect(result).toEqual({ local: true, home: false, file: {} }); +}); + +test("disconnect resumes after token removal without rerunning completed generic cleanup", () => { + const result = scenario(` + // Fail the first config commit after the token is gone; earlier commits (the standalone + // native restore) proceed, so the fault lands exactly where the test name says. + const afterTokenRemoval=()=>{ + if(readServiceApiTokenState().kind==='present'){setPersistedConfigMutationBeforeCommitForTests(afterTokenRemoval);return;} + throw new Error('fixture after token removal'); + }; + setPersistedConfigMutationBeforeCommitForTests(afterTokenRemoval); + let failed=false;try{await disconnectClient({},deps);}catch{failed=true;} + const token=readServiceApiTokenState().kind; + const receiptPath=join(process.env.OPENCODEX_HOME,'desktop-remote','disconnect.json'); + const pending=JSON.parse(readFileSync(receiptPath,'utf8'));pending.phase='token_removed';writeFileSync(receiptPath,JSON.stringify(pending)); + await disconnectClient({},deps); + console.log(JSON.stringify({failed,token,state:readClientConnectionState().kind,common:readRouteIntentReceipt()})); + `); + expect(result).toEqual({ failed: true, token: "absent", state: "disconnected", common: null }); +}); + +test("ordinary Hub disconnect preserves an unrelated Home common receipt and its owned integration", () => { + const result = scenario(` + const homeDoc=document();const models=readRouteIntentReceipt().models; + await disconnectClient({},deps); + await materializeLocalRouteIntent(loadConfig(),homeDoc,models,{lifecycleLockDeps:deps.lifecycleLockDeps}); + const before=readFileSync(join(routeIntentStatePath(),'receipt.json'),'utf8');const beforePi=readFileSync(path,'utf8'); + const key='ocx_data_'+'b'.repeat(40);const token=writeServiceApiTokenFile(key); + const hub=loadConfig();hub.runtimeRole='client';hub.client={serverUrl:'https://hub.example.test',managementUrl:'https://hub.example.test',managementTransport:'direct', + apiKeyId:'fixture-hub',connectedAt:'2026-11-07T01:00:00.000Z',selectedClients:['claude'],tokenEnv:'OPENCODEX_API_AUTH_TOKEN',tokenFingerprint:token.fingerprint,protocolVersion:1,priorCatalog:''};saveConfig(hub); + await disconnectClient({},deps); + console.log(JSON.stringify({state:readClientConnectionState().kind,same:before===readFileSync(join(routeIntentStatePath(),'receipt.json'),'utf8'),samePi:beforePi===readFileSync(path,'utf8')})); + `); + expect(result).toEqual({ state: "disconnected", same: true, samePi: true }); +}); + +test("older Home Link disconnect preserves a valid unbound standalone receipt and owned Pi", () => { + const result = scenario(` + const homeDoc=document();const models=readRouteIntentReceipt().models; + await disconnectClient({},deps); + await materializeLocalRouteIntent(loadConfig(),homeDoc,models,{lifecycleLockDeps:deps.lifecycleLockDeps}); + const before=readFileSync(join(routeIntentStatePath(),'receipt.json'),'utf8');const beforePi=readFileSync(path,'utf8'); + routeSupported=false; + await connectClient({serverUrl:'http://127.0.0.1:22001',managementTransport:'direct',transport:'link', + link:{tunnelPort:22001,linkId:'lnk_abcdef0123456789'},credential:{kind:'link',apiKeyId:'fixture-old-home',key:'ocx_data_'+'c'.repeat(40)}, + selectedClients:['claude'],noSync:true},deps); + await disconnectClient({},deps); + console.log(JSON.stringify({state:readClientConnectionState().kind,same:before===readFileSync(join(routeIntentStatePath(),'receipt.json'),'utf8'),samePi:beforePi===readFileSync(path,'utf8')})); + `); + expect(result).toEqual({ state: "disconnected", same: true, samePi: true }); +}); diff --git a/tests/clients/route-intent-desktop.test.ts b/tests/clients/route-intent-desktop.test.ts new file mode 100644 index 00000000000..c6aa6a7ce36 --- /dev/null +++ b/tests/clients/route-intent-desktop.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { saveConfig, readConfigDiagnostics, loadConfig } from "../../src/config"; +import { withClientLifecycleSync, type ClientLifecycleHeld } from "../../src/client/lifecycle-lock"; +import { serviceApiTokenFingerprint, writeServiceApiTokenFile, replaceServiceApiTokenFile } from "../../src/lib/service-secrets"; +import { applyRemoteDesktopStore, replaceRemoteDesktopCredential, restoreRemoteDesktopStore } from "../../src/claude/desktop-remote-store"; +import type { OcxConfig } from "../../src/types"; + +let root: string; +let previous: Record; +const token = "ocx_data_route_intent_fixture"; +const owner = { serverUrl: "http://127.0.0.1:22001", apiKeyId: "fixture-key", connectedAt: "2026-11-07T00:00:00.000Z" }; +function locked(work: (held: ClientLifecycleHeld) => T): T { return withClientLifecycleSync(work, { lockPath: join(root, "lifecycle.sqlite") }); } +function config(): OcxConfig { + return { port: 18274, hostname: "127.0.0.1", defaultProvider: "test", providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture-model"] } }, runtimeRole: "client", client: { + ...owner, transport: "link", link: { tunnelPort: 22001, linkId: "lnk_0123456789abcdef" }, + managementUrl: owner.serverUrl, managementTransport: "direct", selectedClients: ["claude-desktop"], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + tokenFingerprint: serviceApiTokenFingerprint(token), protocolVersion: 1, + } } as OcxConfig; +} +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-route-desktop-")); + previous = Object.fromEntries(["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR"].map(key => [key, process.env[key]])); + process.env.OPENCODEX_HOME = join(root, "ocx"); process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); + saveConfig(config()); writeServiceApiTokenFile(token); + expect(readConfigDiagnostics().source).toBe("file"); + expect(loadConfig().client?.link?.linkId).toBe("lnk_0123456789abcdef"); + mkdirSync(join(root, "desktop")); + writeFileSync(join(root, "desktop", "_meta.json"), JSON.stringify({ entries: [], custom: "keep" })); +}); +afterEach(() => { + for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + rmSync(root, { recursive: true, force: true }); +}); +function apply(baseUrl = "http://127.0.0.1:18274", apiKey = "opencodex-loopback") { + return locked(held => applyRemoteDesktopStore(held, { owner, expectedTokenFingerprint: serviceApiTokenFingerprint(token), + baseUrl, apiKey, mode: "static", models: [{ name: "claude-sonnet-4-6-20260217", labelOverride: "Local", anthropicFamilyTier: "sonnet" }], + localListener: true, + })); +} +test("Child Desktop uses its own port and placeholder, then preserves unrelated edits on restore", () => { + const applied = apply(); expect(applied).toMatchObject({ ok: true }); + if (!applied.ok || !applied.path) throw new Error("missing applied path"); + const value = JSON.parse(readFileSync(applied.path, "utf8")); + expect(value.inferenceGatewayBaseUrl).toBe("http://127.0.0.1:18274"); + expect(value.inferenceGatewayApiKey).toBe("opencodex-loopback"); + expect(readFileSync(applied.path, "utf8")).not.toContain(token); + value.unrelated = "survive"; writeFileSync(applied.path, JSON.stringify(value)); + const restored = locked(held => restoreRemoteDesktopStore(held, { owner, knownTokenFingerprints: [serviceApiTokenFingerprint(token)] })); + expect(restored.ok).toBe(true); + expect(JSON.parse(readFileSync(applied.path, "utf8"))).toEqual({ unrelated: "survive" }); +}); +test("local listener permission rejects another port, host, key, and non-Link connection", () => { + expect(apply("http://127.0.0.1:18275").ok).toBe(false); + expect(apply("https://foreign.example.test").ok).toBe(false); + expect(apply("http://127.0.0.1:18274", token).ok).toBe(false); + const hub = config(); delete hub.client!.link; delete hub.client!.transport; saveConfig(hub); + expect(apply().ok).toBe(false); +}); +test("service key rotation updates ownership without replacing the loopback placeholder", () => { + const applied = apply(); expect(applied).toMatchObject({ ok: true }); + if (!applied.ok || !applied.path) throw new Error("missing applied path"); + const replacement = "ocx_data_route_intent_replacement"; + replaceServiceApiTokenFile(replacement); + const next = config(); next.client!.tokenFingerprint = serviceApiTokenFingerprint(replacement); saveConfig(next); + const rotated = locked(held => replaceRemoteDesktopCredential(held, { owner, expectedTokenFingerprint: serviceApiTokenFingerprint(token), replacementKey: replacement })); + expect(rotated.ok).toBe(true); + expect(JSON.parse(readFileSync(applied.path, "utf8")).inferenceGatewayApiKey).toBe("opencodex-loopback"); + expect(readFileSync(applied.path, "utf8")).not.toContain(replacement); +}); diff --git a/tests/clients/route-intent-file-originals.test.ts b/tests/clients/route-intent-file-originals.test.ts new file mode 100644 index 00000000000..94ca4b5e736 --- /dev/null +++ b/tests/clients/route-intent-file-originals.test.ts @@ -0,0 +1,113 @@ +import { expect, test, setDefaultTimeout } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); + +/** Join, live with the Home's intent, then disconnect: file integrations the Child owned before linking return. */ +function scenario(before: string, body: string, homePi: boolean) { + const root = mkdtempSync(join(tmpdir(), "ocx-route-file-originals-")); + const home = join(root, "home"); mkdirSync(home); + try { + const result = spawnSync(process.execPath, ["--eval", ` + import { expect } from 'bun:test'; + import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; + import { join, dirname } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { connectClient, disconnectClient, syncConnectedClient } from './src/client/connect'; + import { readClientConnectionState } from './src/client/state'; + import { createIntegrationStateStore } from './src/integrations/store'; + import { INTEGRATION_CLIENTS } from './src/integrations/registry'; + import { applyIntegration } from './src/integrations/writer'; + import { setLocalRouteIntentOff } from './src/integrations/route-intent-state'; + import { ROUTE_CLIENT_IDS } from './src/link/route-intent'; + const home = process.env.HOME; + const config = getDefaultConfig(); config.port = 18274; config.hostname = '127.0.0.1'; saveConfig(config); + const path = INTEGRATION_CLIENTS.pi.configPath({}, home); + mkdirSync(INTEGRATION_CLIENTS.pi.detectDir({}, home), { recursive: true }); mkdirSync(dirname(path), { recursive: true }); + const fileIntentOriginalsPath = () => join(process.env.OPENCODEX_HOME,'link','file-intent-originals.json'); + const owned = () => Boolean(createIntegrationStateStore().readRecords().pi); + const routed = () => existsSync(path) && JSON.parse(readFileSync(path,'utf8')).providers?.opencodex !== undefined; + const document = {version:1, home:{name:'originals-fixture',version:'1'}, + clients:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,{enabled:id==='pi'&&${homePi}}])), + sources:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,'default']))}; + const fetchImpl = async input => { + const url=String(input); + if(url.endsWith('/readyz'))return Response.json({service:'opencodex',version:'1',uptime:1,pid:1,port:22001, + status:'ready',protocol:1,minimumClientProtocol:1,managementUrl:'http://127.0.0.1:22001'}); + if(url.endsWith('/v1/catalog'))return Response.json({models:[]}); + if(url.endsWith('/route-intent'))return Response.json(document); + if(url.endsWith('/v1/models'))return Response.json({data:[{id:'fixture/home-model'}]}); + return new Response(null,{status:404}); + }; + const deps={fetchImpl,linkHome:{fetchImpl},catalogCompatibility:{supportedEfforts:()=>new Set()}, + lifecycleLockDeps:{lockPath:join(process.env.OPENCODEX_HOME,'lifecycle.sqlite')}}; + ${before} + await connectClient({serverUrl:'http://127.0.0.1:22001',managementTransport:'direct',transport:'link', + link:{tunnelPort:22001,linkId:'lnk_0123456789abcdef'},credential:{kind:'link',apiKeyId:'fixture-key',key:'ocx_data_'+'a'.repeat(40)}, + selectedClients:['codex','claude'],selectedClientsExplicit:false},deps); + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, env: { ...process.env, + HOME: home, USERPROFILE: home, OPENCODEX_HOME: join(home, ".opencodex"), CODEX_HOME: join(home, ".codex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), + XDG_CONFIG_HOME: join(home, ".config"), GROK_HOME: join(home, ".grok"), PI_CODING_AGENT_DIR: join(home, ".pi", "agent"), + OCX_TEST_HOME_GUARD: "1" } }); + expect(result.stderr).not.toContain("local route intent needs reconciliation"); + expect({ status: result.status, signal: result.signal, error: result.error?.message, stderr: result.stderr }).toMatchObject({ status: 0, signal: null }); + return JSON.parse(result.stdout.trim().split("\n").at(-1)!); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +const ownedBefore = ` + expect(applyIntegration({clientId:'pi',config,port:config.port,models:[{namespaced:'standalone/model',provider:'standalone',id:'model'}], + store:createIntegrationStateStore(),home,env:process.env}).ok).toBe(true); + expect(owned()).toBe(true); +`; +const disconnect = ` + const linked = { owned: owned(), routed: routed(), record: existsSync(fileIntentOriginalsPath()) }; + await disconnectClient({},deps); + console.log(JSON.stringify({ linked, state: readClientConnectionState().kind, owned: owned(), routed: routed(), + record: existsSync(fileIntentOriginalsPath()) })); +`; + +test("a pre-owned integration the Home turned off returns on disconnect", () => { + const result = scenario(ownedBefore, disconnect, false); + expect(result.linked).toEqual({ owned: false, routed: false, record: true }); + expect(result).toMatchObject({ state: "disconnected", owned: true, routed: true, record: false }); +}); + +test("a pre-owned integration the Home kept on stays after disconnect", () => { + const result = scenario(ownedBefore, disconnect, true); + expect(result.linked).toEqual({ owned: true, routed: true, record: true }); + expect(result).toMatchObject({ state: "disconnected", owned: true, routed: true, record: false }); +}); + +test("an integration the link created is removed on disconnect", () => { + const result = scenario("", disconnect, true); + expect(result.linked).toEqual({ owned: true, routed: true, record: false }); + expect(result).toMatchObject({ state: "disconnected", owned: false, routed: false, record: false }); +}); + +test("a local opt-out of a pre-owned integration is the person's choice and survives disconnect", () => { + const result = scenario(ownedBefore, ` + setLocalRouteIntentOff('pi', true); + await syncConnectedClient({}, deps); + ${disconnect} + `, true); + expect(result.linked).toMatchObject({ owned: false, routed: false }); + expect(result).toMatchObject({ state: "disconnected", owned: false, routed: false, record: false }); +}); + +test("a block the person wrote while linked is kept instead of being re-applied", () => { + const result = scenario(ownedBefore, ` + writeFileSync(path, JSON.stringify({ providers: { opencodex: { baseUrl: 'https://person.example.test' } } })); + const mine = readFileSync(path,'utf8'); + await disconnectClient({},deps); + console.log(JSON.stringify({ state: readClientConnectionState().kind, owned: owned(), same: readFileSync(path,'utf8') === mine })); + `, false); + expect(result).toEqual({ state: "disconnected", owned: false, same: true }); +}); diff --git a/tests/clients/route-intent-home-consent.test.ts b/tests/clients/route-intent-home-consent.test.ts new file mode 100644 index 00000000000..e5cb3eba7d5 --- /dev/null +++ b/tests/clients/route-intent-home-consent.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +/** A Home Desktop or Grok switch no write is deciding may refresh owned state, never create it. */ +function scenario(body: string) { + const home = mkdtempSync(join(tmpdir(), "ocx-home-consent-")); + for (const dir of [".codex", ".claude", "desktop"]) mkdirSync(join(home, dir)); + try { + const child = spawnSync(process.execPath, ["--eval", ` + import { expect } from 'bun:test'; + import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { materializeLocalRouteIntent } from './src/client/route-intent-apply'; + import { projectHomeRouteIntent } from './src/link/route-intent'; + import { readHomeRouteIntentState } from './src/routing/home-route-intent'; + import { setRouteClientIntent } from './src/routing/route-intent-state'; + const root = process.env.HOME; + const library = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const lockDeps = {lockPath:join(root,'lifecycle.sqlite')}; + const modelsA = [{namespaced:'fixture/claude-sonnet-4-6',provider:'fixture',id:'claude-sonnet-4-6'}]; + const modelsB = [...modelsA, {namespaced:'fixture/claude-opus-4-6',provider:'fixture',id:'claude-opus-4-6'}]; + const local = getDefaultConfig(); local.port=18274; local.hostname='127.0.0.1'; + local.defaultProvider='fixture';local.providers={fixture:{adapter:'openai-chat',baseUrl:'http://127.0.0.1:1/v1',allowPrivateNetwork:true,liveModels:false,models:['fixture-model']}}; + delete local.clientIntegrations; + saveConfig(local); + writeFileSync(join(library,'_meta.json'),JSON.stringify({entries:[],custom:'keep'})); + const homeDocument = (decidedClient) => projectHomeRouteIntent(loadConfig(),'1','home',readHomeRouteIntentState(loadConfig()),{decidedClient}); + async function reconcile(client, models=modelsA, document=homeDocument(), decidedClient) { + const receipt=await materializeLocalRouteIntent(loadConfig(),document,models,{onlyClients:[client],lifecycleLockDeps:lockDeps,decidedClient}); + return receipt.outcomes.find(row=>row.client===client); + } + const meta = () => JSON.parse(readFileSync(join(library,'_meta.json'),'utf8')); + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + } finally { removeTreeWithRetry(home); } +} + +test("implicit Home Desktop intent never creates a gateway profile", () => scenario(` + const document = homeDocument(); + expect(document.clients['claude-desktop'].enabled).toBe(true); + expect(document.sources['claude-desktop']).toBe('native'); + const before = readFileSync(join(library,'_meta.json'),'utf8'); + expect((await reconcile('claude-desktop')).outcome).toBe('current'); + expect(readFileSync(join(library,'_meta.json'),'utf8')).toBe(before); + expect(loadConfig().claudeCode?.desktopMode).toBeUndefined(); + expect(loadConfig().claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); +`), SPAWN_BUDGET_MS); + +test("implicit Home Desktop intent refreshes an owned gateway profile", () => scenario(` + setRouteClientIntent('claude-desktop',{enabled:true}); + expect((await reconcile('claude-desktop', modelsA, homeDocument('claude-desktop'), 'claude-desktop')).outcome).toBe('current'); + const appliedId = meta().appliedId; + expect(typeof appliedId).toBe('string'); + expect((await reconcile('claude-desktop', modelsB)).outcome).toBe('current'); + expect(meta().appliedId).toBe(appliedId); + expect(readFileSync(join(library, appliedId+'.json'),'utf8')).toContain('Claude Opus 4 6'); +`), SPAWN_BUDGET_MS); + +test("only a Home write deciding Desktop creates the gateway profile", () => scenario(` + setRouteClientIntent('claude-desktop',{enabled:true}); + // A default alone (a sync, or a write for another client) leaves the switch's startup scope. + expect((await reconcile('claude-desktop')).outcome).toBe('current'); + expect(meta().appliedId).toBeUndefined(); + expect((await reconcile('claude-desktop', modelsA, homeDocument('claude-desktop'), 'claude-desktop')).outcome).toBe('current'); + expect(typeof meta().appliedId).toBe('string'); + expect(loadConfig().claudeCode?.desktopMode).toBe('gateway'); +`), SPAWN_BUDGET_MS); + +test("implicit Home Desktop intent reports a changed owned profile as a conflict", () => scenario(` + setRouteClientIntent('claude-desktop',{enabled:true}); + expect((await reconcile('claude-desktop', modelsA, homeDocument('claude-desktop'), 'claude-desktop')).outcome).toBe('current'); + const profile = join(library, meta().appliedId+'.json'); + const edited = JSON.parse(readFileSync(profile,'utf8')); edited.inferenceGatewayBaseUrl = 'http://127.0.0.1:1'; writeFileSync(profile, JSON.stringify(edited)); + const before = readFileSync(profile,'utf8'); + for (const overwriteConflict of [false, true]) { + const receipt = await materializeLocalRouteIntent(loadConfig(), homeDocument(), modelsB, { onlyClients:['claude-desktop'], lifecycleLockDeps:lockDeps, overwriteConflict }); + expect(receipt.outcomes.find(row=>row.client==='claude-desktop').outcome).toBe('conflict'); + } + expect(readFileSync(profile,'utf8')).toBe(before); +`), SPAWN_BUDGET_MS); + +test("a full Home sync leaves a Desktop library OpenCodex never owned as current", () => scenario(` + // Unset Home switch, read as on: a refresh-only row over libraries with no OpenCodex ownership. + const uuid = '0f8b7c1e-4d3a-4b2c-9e1f-2a3b4c5d6e7f'; + for (const metadata of [JSON.stringify({ appliedId: uuid, entries: [{ id: uuid, name: 'Work' }] }), '{not json', + JSON.stringify({ appliedId: '../escape', entries: [] })]) { + writeFileSync(join(library,'_meta.json'), metadata); + const receipt = await materializeLocalRouteIntent(loadConfig(), homeDocument(), modelsA, { lifecycleLockDeps:lockDeps }); + const row = receipt.outcomes.find(entry=>entry.client==='claude-desktop'); + expect(row.outcome).toBe('current'); + expect(row.message ?? '').not.toContain('--overwrite-conflict'); + expect(readFileSync(join(library,'_meta.json'),'utf8')).toBe(metadata); + expect(existsSync(join(library, uuid+'.json'))).toBe(false); + } +`), SPAWN_BUDGET_MS); + +test("an owned Desktop profile whose file is gone stays a refresh-only conflict", () => scenario(` + setRouteClientIntent('claude-desktop',{enabled:true}); + expect((await reconcile('claude-desktop', modelsA, homeDocument('claude-desktop'), 'claude-desktop')).outcome).toBe('current'); + rmSync(join(library, meta().appliedId+'.json')); + expect((await reconcile('claude-desktop', modelsB)).outcome).toBe('conflict'); +`), SPAWN_BUDGET_MS); + +test("implicit Home Grok intent matches the startup gate on a gated hub", () => scenario(` + const c=loadConfig(); c.runtimeRole='hub'; saveConfig(c); + mkdirSync(process.env.GROK_HOME,{recursive:true}); + expect(homeDocument().sources.grok).toBe('native'); + expect((await reconcile('grok')).outcome).toBe('current'); + expect(existsSync(join(process.env.GROK_HOME,'config.toml'))).toBe(false); +`), SPAWN_BUDGET_MS); diff --git a/tests/clients/route-intent-local-receipt.test.ts b/tests/clients/route-intent-local-receipt.test.ts new file mode 100644 index 00000000000..e5756926941 --- /dev/null +++ b/tests/clients/route-intent-local-receipt.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { createTempHome } from "../helpers/temp-home"; +import { projectHomeRouteIntent, ROUTE_CLIENT_IDS, parseRouteClientOutcomes, routeClientOutcomesForPeer } from "../../src/link/route-intent"; +import { writeRouteIntentReceipt, readRouteIntentReceiptStrict } from "../../src/integrations/route-intent-state"; +import type { OcxConfig } from "../../src/types"; + +test("long local paths and multiline diagnostics remain readable locally and never cross the peer boundary", () => { + const home = createTempHome("route-local-diagnostic-"); + try { + const document = projectHomeRouteIntent({ port: 18274, providers: {} } as OcxConfig, "1"); + const target = `C:\\Users\\${"nested-path\\".repeat(40)}models.json`; + const message = "Write refused.\nPreserve the owned snapshot and retry."; + const outcomes = ROUTE_CLIENT_IDS.map(client => ({ client, outcome: "current" as const, + ...(client === "pi" ? { target, message } : {}) })); + writeRouteIntentReceipt({ version: 1, document, clients: document.clients, sources: document.sources, selectedClients: ["codex", "claude"], + outcomes, appliedRouteIntentDigest: null, appliedAt: new Date().toISOString() }, home.path("route-state")); + expect(readRouteIntentReceiptStrict(home.path("route-state"))?.outcomes.find(row => row.client === "pi")) + .toMatchObject({ target, message }); + expect(parseRouteClientOutcomes(outcomes)).toBeNull(); + const peer = routeClientOutcomesForPeer(outcomes); + expect(parseRouteClientOutcomes(peer)).not.toBeNull(); + expect(JSON.stringify(peer)).not.toContain("nested-path"); + expect(JSON.stringify(peer)).not.toContain("snapshot"); + } finally { home.remove(); } +}); diff --git a/tests/clients/route-intent-materializer.test.ts b/tests/clients/route-intent-materializer.test.ts new file mode 100644 index 00000000000..4902376be4c --- /dev/null +++ b/tests/clients/route-intent-materializer.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { materializeRouteIntent, cleanupRouteIntent, effectiveRouteIntent, readRouteIntentReceipt, setLocalRouteIntentOff } from "../../src/integrations/route-intent"; +import { ROUTE_CLIENT_IDS, routeIntentDigest, type HomeRouteIntentDocument } from "../../src/link/route-intent"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import type { RouteIntentApplyInput } from "../../src/integrations/route-intent-contract"; +import { readRouteIntentReceiptStrict, writeRouteIntentReceipt } from "../../src/integrations/route-intent-state"; +import type { OcxConfig } from "../../src/types"; + +let root: string; +let input: RouteIntentApplyInput; +function document(): HomeRouteIntentDocument { + return { version: 1, home: { name: "fixture-home", version: "1" }, + clients: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, { enabled: id === "pi" }])) as HomeRouteIntentDocument["clients"], + sources: Object.fromEntries(ROUTE_CLIENT_IDS.map(id => [id, "default"])) as HomeRouteIntentDocument["sources"] }; +} +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-route-intent-")); + const home = join(root, "home"); mkdirSync(home); + input = { config: { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig, + document: document(), models: [{ namespaced: "child/model", provider: "child", id: "model" }], + home, env: {}, store: createIntegrationStateStore(join(root, "integrations")), statePath: join(root, "route-state") }; +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); +function installPi() { + const spec = INTEGRATION_CLIENTS.pi; + mkdirSync(spec.detectDir(input.env, input.home), { recursive: true }); + const path = spec.configPath(input.env, input.home); mkdirSync(dirname(path), { recursive: true }); return path; +} +const outcome = (receipt: Awaited>, id = "pi") => receipt.outcomes.find(row => row.client === id)!; + +test("materializes the local roster and port, refreshes it, and cleans only owned keys", async () => { + const path = installPi(); + writeFileSync(path, JSON.stringify({ unrelated: "keep" })); + const first = await materializeRouteIntent(input); + expect(outcome(first).outcome).toBe("current"); + expect(readFileSync(path, "utf8")).toContain("18274"); + expect(readFileSync(path, "utf8")).toContain("child/model"); + input.models = [{ namespaced: "child/new", provider: "child", id: "new" }]; + await materializeRouteIntent(input); + expect(readFileSync(path, "utf8")).toContain("child/new"); + expect(readFileSync(path, "utf8")).not.toContain("child/model"); + const edited = JSON.parse(readFileSync(path, "utf8")); edited.after = "survive"; writeFileSync(path, JSON.stringify(edited)); + expect((await cleanupRouteIntent(input)).find(row => row.client === "pi")?.outcome).toBe("current"); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ unrelated: "keep", after: "survive" }); +}); +test("missing clients are not installed and cannot produce an applied digest", async () => { + const receipt = await materializeRouteIntent(input); + expect(outcome(receipt).outcome).toBe("not_installed"); + expect(receipt.appliedRouteIntentDigest).toBeNull(); + expect(readRouteIntentReceipt(input.statePath)?.appliedRouteIntentDigest).toBeNull(); +}); +test("conflicts refuse unless this operation explicitly overwrites; permission is not saved", async () => { + const path = installPi(); + writeFileSync(path, JSON.stringify({ providers: { opencodex: { baseUrl: "https://foreign.test", models: [] } } })); + const before = readFileSync(path, "utf8"); + expect(outcome(await materializeRouteIntent(input)).outcome).toBe("conflict"); + expect(readFileSync(path, "utf8")).toBe(before); + expect(outcome(await materializeRouteIntent({ ...input, overwriteConflict: true })).outcome).toBe("current"); + writeFileSync(path, before); + expect(outcome(await materializeRouteIntent(input)).outcome).toBe("conflict"); +}); +test("persistent local off overrides fetched Home intent, inherit removes it", async () => { + installPi(); await materializeRouteIntent(input); + setLocalRouteIntentOff("pi", true, input.statePath); + expect(effectiveRouteIntent(input.document, input.statePath).sources.pi).toBe("local"); + expect(effectiveRouteIntent(input.document, input.statePath).selectedClients).not.toContain("pi"); + expect(outcome(await materializeRouteIntent(input)).outcome).toBe("current"); + setLocalRouteIntentOff("pi", false, input.statePath); + expect(effectiveRouteIntent(input.document, input.statePath).sources.pi).toBe("default"); + expect(outcome(await materializeRouteIntent(input)).outcome).toBe("current"); +}); +test("connection fence rejects a writer before touching client files and clears prior success", async () => { + const path = installPi(); await materializeRouteIntent(input); + expect(readRouteIntentReceipt(input.statePath)?.appliedRouteIntentDigest).toBe(routeIntentDigest(input.document)); + const before = readFileSync(path, "utf8"); + input.models = [{ namespaced: "child/new", provider: "child", id: "new" }]; + input.beforeWrite = () => { throw new Error("client_connection_changed"); }; + await expect(materializeRouteIntent(input)).rejects.toThrow("client_connection_changed"); + expect(readFileSync(path, "utf8")).toBe(before); + expect(readRouteIntentReceipt(input.statePath)?.appliedRouteIntentDigest).toBeNull(); +}); + +test("review: local off rejects unreadable receipts before publishing an override", () => { + mkdirSync(join(input.statePath!, "receipt.json"), { recursive: true }); + expect(() => setLocalRouteIntentOff("pi", true, input.statePath)).toThrow("unreadable"); + expect(() => readFileSync(join(input.statePath!, "local-off.json"))).toThrow(); +}); + +test("review: local off rejects malformed receipts and preserves the previous override", () => { + setLocalRouteIntentOff("pi", true, input.statePath); + const before = readFileSync(join(input.statePath!, "local-off.json"), "utf8"); + writeFileSync(join(input.statePath!, "receipt.json"), JSON.stringify({ version: 1 })); + expect(() => setLocalRouteIntentOff("pi", false, input.statePath)).toThrow("malformed"); + expect(readFileSync(join(input.statePath!, "local-off.json"), "utf8")).toBe(before); +}); + +test("review: unmanaged Aside off needs no account manifest", async () => { + installPi(); + mkdirSync(join(input.home!, ".aside"), { recursive: true }); + const receipt = await materializeRouteIntent(input); + expect(outcome(receipt, "aside").outcome).toBe("current"); + expect(receipt.appliedRouteIntentDigest).toBe(routeIntentDigest(input.document)); +}); + +test("review: orphaned Aside profile ownership cannot claim clean disconnect", async () => { + const { aside, four } = installAside(); + expect(outcome(await materializeRouteIntent(input), "aside").outcome).toBe("current"); + const before = readFileSync(four, "utf8"); + writeFileSync(join(aside, "accounts.json"), JSON.stringify({ currentAccountId: 0, + accounts: [{ id: 0, userId: "local-beta" }] })); + const cleaned = await cleanupRouteIntent(input); + expect(cleaned.find(row => row.client === "aside")?.outcome).toBe("failed"); + expect(readFileSync(four, "utf8")).toBe(before); + expect(createIntegrationStateStore(join(input.store!.root, "aside-profiles", "4")).readRecordsStrict().aside).toBeDefined(); + expect(readRouteIntentReceipt(input.statePath)?.appliedRouteIntentDigest).toBeNull(); +}); + +test("review: missing Aside installation with retained ownership cannot claim clean", async () => { + const { aside } = installAside(); + expect(outcome(await materializeRouteIntent(input), "aside").outcome).toBe("current"); + rmSync(aside, { recursive: true }); + expect((await cleanupRouteIntent(input)).find(row => row.client === "aside")?.outcome).toBe("failed"); +}); + +function installAside() { + const aside = join(input.home!, ".aside"); + for (const id of [0, 4]) mkdirSync(join(aside, "u", String(id)), { recursive: true }); + writeFileSync(join(aside, "accounts.json"), JSON.stringify({ currentAccountId: 0, + accounts: [{ id: 0, userId: "local-beta" }, { id: 4, userId: "local-alpha" }] })); + input.document.clients.pi.enabled = false; + input.document.clients.aside = { enabled: true, accounts: "all" }; + return { aside, zero: join(aside, "u", "0", "models.json"), four: join(aside, "u", "4", "models.json") }; +} +test("Aside all applies each local profile, then userId selection removes excluded owned profiles", async () => { + const { zero, four } = installAside(); + input.config.apiKeys = [{ id: "fixture", name: "fixture", key: "ocx_data_do_not_copy_to_aside" }] as OcxConfig["apiKeys"]; + const all = await materializeRouteIntent(input); + expect(outcome(all, "aside").outcome).toBe("current"); + for (const path of [zero, four]) { + expect(readFileSync(path, "utf8")).toContain("child/model"); + expect(readFileSync(path, "utf8")).toContain("18274"); + expect(readFileSync(path, "utf8")).toContain("opencodex-loopback"); + expect(readFileSync(path, "utf8")).not.toContain("do_not_copy"); + } + input.document.clients.aside.accounts = ["local-alpha"]; + const selected = await materializeRouteIntent(input); + expect(outcome(selected, "aside").target).toBe("u4"); + expect(JSON.parse(readFileSync(zero, "utf8"))).toEqual({}); + expect(readFileSync(four, "utf8")).toContain("child/model"); + input.models = [{ namespaced: "child/new", provider: "child", id: "new" }]; + await materializeRouteIntent(input); + expect(readFileSync(four, "utf8")).toContain("child/new"); + input.document.clients.aside.accounts = "none"; + await materializeRouteIntent(input); + expect(JSON.parse(readFileSync(four, "utf8"))).toEqual({}); +}); +test("Aside account userId never falls back to the numeric local slot", async () => { + installAside(); input.document.clients.aside.accounts = ["4"]; + expect(outcome(await materializeRouteIntent(input), "aside").outcome).toBe("not_installed"); +}); +test("Aside refuses a manifest replaced between enumeration and userId mapping", async () => { + const { aside, zero, four } = installAside(); + const store = input.store!; + let replaced = false; + input.store = { ...store, listOperations: (...args) => { + if (args[0] === "aside" && !replaced) { + replaced = true; + writeFileSync(join(aside, "accounts.json"), JSON.stringify({ currentAccountId: 0, + accounts: [{ id: 0, userId: "local-alpha" }, { id: 4, userId: "local-beta" }] })); + } + return store.listOperations(...args); + } }; + input.document.clients.aside.accounts = ["local-alpha"]; + expect(outcome(await materializeRouteIntent(input), "aside").outcome).toBe("failed"); + expect(replaced).toBe(true); + expect(() => readFileSync(zero)).toThrow(); expect(() => readFileSync(four)).toThrow(); +}); +test("receipt reader accepts a bounded roster above 1 MiB and rejects overbound publication", async () => { + const receipt = await materializeRouteIntent(input); + const displayName = "x".repeat(2 * 1024 * 1024); + receipt.models = [{ namespaced: "child/model", provider: "child", id: "model", displayName }]; + writeRouteIntentReceipt(receipt, input.statePath); + expect(readRouteIntentReceiptStrict(input.statePath)?.models?.[0]?.displayName?.length).toBe(2 * 1024 * 1024); + const before = readFileSync(join(input.statePath!, "receipt.json"), "utf8"); + expect(() => writeRouteIntentReceipt({ ...receipt, models: [{ namespaced: "child/model", provider: "child", id: "model", displayName: "x".repeat(16 * 1024 * 1024) }] }, input.statePath)).toThrow("too large"); + expect(readFileSync(join(input.statePath!, "receipt.json"), "utf8")).toBe(before); +}); diff --git a/tests/clients/route-intent-native-originals.test.ts b/tests/clients/route-intent-native-originals.test.ts new file mode 100644 index 00000000000..84703a3d43a --- /dev/null +++ b/tests/clients/route-intent-native-originals.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig, loadConfig, saveConfig } from "../../src/config"; +import { mutatePersistedConfig, setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { persistNativeRouteIntent } from "../../src/client/route-intent-apply"; +import { + nativeIntentFields, nativeIntentOriginalsPath, prepareNativeIntentWrite, readNativeIntentOriginals, restoreNativeIntentOriginals, +} from "../../src/client/route-intent-native-originals"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let previous: string | undefined; +beforeEach(() => { + previous = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-native-originals-")); + process.env.OPENCODEX_HOME = root; + const config = getDefaultConfig(); + delete config.clientIntegrations; + delete config.claudeCode; + delete config.apiSurfaces; + saveConfig(config); +}); +afterEach(() => { + setPersistedConfigMutationBeforeCommitForTests(null); + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(root); +}); + +const raw = () => JSON.parse(readFileSync(join(root, "config.json"), "utf8")); +const codex = () => Object.hasOwn(raw().clientIntegrations ?? {}, "codex") ? raw().clientIntegrations.codex : "absent"; +const linkWrite = (client: "codex" | "claude" | "grok" | "claude-desktop", enabled: boolean) => + persistNativeRouteIntent(client, enabled, () => {}, loadConfig(), true); +const restore = () => restoreNativeIntentOriginals(() => {}); +const setCodex = (value: boolean | "absent") => { + const config = loadConfig(); + if (value === "absent") delete config.clientIntegrations?.codex; + else config.clientIntegrations = { ...config.clientIntegrations, codex: value }; + saveConfig(config); +}; + +test("the original is captured once and restored with its presence after repeated link writes", () => { + linkWrite("codex", false); + linkWrite("codex", true); + linkWrite("codex", false); + expect(readNativeIntentOriginals()?.fields.codex).toEqual({ original: null, published: false }); + expect(restore()).toBe(true); + expect(codex()).toBe("absent"); + expect(existsSync(nativeIntentOriginalsPath())).toBe(false); +}); + +test("a no-op link write claims no ownership", () => { + setCodex(true); + linkWrite("codex", true); + expect(existsSync(nativeIntentOriginalsPath())).toBe(false); + expect(prepareNativeIntentWrite(nativeIntentFields(loadConfig()), nativeIntentFields(loadConfig()))).toBe(false); + expect(restore()).toBe(false); + expect(codex()).toBe(true); +}); + +test("Home writes are not recorded", () => { + persistNativeRouteIntent("codex", false, () => {}, loadConfig(), false); + expect(existsSync(nativeIntentOriginalsPath())).toBe(false); + expect(codex()).toBe(false); +}); + +test("restore keeps a value the writer did not publish and still removes the record", () => { + setCodex(false); + linkWrite("codex", true); + setCodex("absent"); + expect(restore()).toBe(true); + expect(codex()).toBe("absent"); + expect(existsSync(nativeIntentOriginalsPath())).toBe(false); +}); + +test("Claude restore returns enabled, the migration stamp and the Messages surface to absent", () => { + linkWrite("claude", false); + expect(raw().claudeCode.enabled).toBe(false); + expect(typeof raw().claudeCode.authModeMigratedAt).toBe("string"); + expect(raw().apiSurfaces.messages).toEqual({ enabled: true }); + restore(); + expect(raw().claudeCode).toBeUndefined(); + expect(raw().apiSurfaces).toBeUndefined(); +}); + +test("crash after the write-ahead record but before the config write restores nothing and clears the record", () => { + setCodex(true); + setPersistedConfigMutationBeforeCommitForTests(() => { throw new Error("fixture crash before config commit"); }); + expect(() => linkWrite("codex", false)).toThrow("fixture crash before config commit"); + expect(codex()).toBe(true); + expect(readNativeIntentOriginals()?.fields.codex).toEqual({ original: true, pending: false }); + expect(restore()).toBe(true); + expect(codex()).toBe(true); + expect(existsSync(nativeIntentOriginalsPath())).toBe(false); +}); + +test("crash after the config write but before publish still restores the pending value", () => { + const before = nativeIntentFields(loadConfig()); + expect(prepareNativeIntentWrite(before, { ...before, codex: false })).toBe(true); + mutatePersistedConfig(config => { config.clientIntegrations = { ...config.clientIntegrations, codex: false }; return { changed: true, value: config }; }); + expect(readNativeIntentOriginals()?.fields.codex).toEqual({ original: null, pending: false }); + restore(); + expect(codex()).toBe("absent"); +}); + +test("an interrupted restore is retried safely", () => { + setCodex(true); + linkWrite("codex", false); + setPersistedConfigMutationBeforeCommitForTests(() => { throw new Error("fixture crash during restore"); }); + expect(() => restore()).toThrow("fixture crash during restore"); + expect(codex()).toBe(false); + expect(existsSync(nativeIntentOriginalsPath())).toBe(true); + const recorded = readFileSync(nativeIntentOriginalsPath(), "utf8"); + expect(restore()).toBe(true); + expect(codex()).toBe(true); + // Crash after the restored config committed but before the record was removed. + writeFileSync(nativeIntentOriginalsPath(), recorded, { mode: 0o600 }); + expect(restore()).toBe(true); + expect(codex()).toBe(true); + expect(existsSync(nativeIntentOriginalsPath())).toBe(false); +}); + +test("a different link after a crashed enrollment keeps the first original", () => { + // Crashed attempt: the config shows its value, the record still calls it pending. + const before = nativeIntentFields(loadConfig()); + prepareNativeIntentWrite(before, { ...before, codex: false }); + mutatePersistedConfig(config => { config.clientIntegrations = { codex: false }; return { changed: true, value: config }; }); + // The next link turns Codex on, then off again. + linkWrite("codex", true); + expect(readNativeIntentOriginals()?.fields.codex).toEqual({ original: null, published: true }); + linkWrite("codex", false); + restore(); + expect(codex()).toBe("absent"); +}); + +test("an unreadable record refuses both capture and restore without touching config", () => { + setCodex(true); + linkWrite("codex", false); + writeFileSync(nativeIntentOriginalsPath(), "{not json", { mode: 0o600 }); + expect(() => restore()).toThrow("Native intent originals record is unreadable"); + expect(() => linkWrite("codex", true)).toThrow("Native intent originals record is unreadable"); + expect(codex()).toBe(false); +}); diff --git a/tests/clients/route-intent-native-review.test.ts b/tests/clients/route-intent-native-review.test.ts new file mode 100644 index 00000000000..01addaca97a --- /dev/null +++ b/tests/clients/route-intent-native-review.test.ts @@ -0,0 +1,270 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +function scenario(body: string) { + const home = mkdtempSync(join(tmpdir(), "ocx-native-review-")); + for (const dir of [".codex", ".claude", "desktop"]) mkdirSync(join(home, dir)); + try { + const child = spawnSync(process.execPath, ["--eval", ` + import { expect, spyOn } from 'bun:test'; + import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, symlinkSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig, getConfigDir } from './src/config'; + import { materializeLocalRouteIntent } from './src/client/route-intent-apply'; + import { ROUTE_CLIENT_IDS } from './src/link/route-intent'; + import { serviceApiTokenFingerprint, writeServiceApiTokenFile, replaceServiceApiTokenFile } from './src/lib/service-secrets'; + import { withClientLifecycleSync } from './src/client/lifecycle-lock'; + import * as desktop from './src/claude/desktop-3p'; + import { applyRemoteDesktopStore, inspectRemoteDesktopStore } from './src/claude/desktop-remote-store'; + import { allowCodexProviderRouting, previewCodexProviderConsent } from './src/codex/provider-routing'; + const root = process.env.HOME; + const library = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const configPath = join(process.env.CODEX_HOME, 'config.toml'); + const token = 'ocx_data_native_review_fixture'; + const owner = { serverUrl:'http://127.0.0.1:22001', apiKeyId:'fixture-key', connectedAt:'2026-11-07T00:00:00.000Z' }; + const models = [{namespaced:'fixture/claude-sonnet-4-6',provider:'fixture',id:'claude-sonnet-4-6'}]; + const lockDeps = {lockPath:join(root,'lifecycle.sqlite')}; + const local = getDefaultConfig(); local.port=18274; local.hostname='127.0.0.1'; + local.defaultProvider='fixture';local.providers={fixture:{adapter:'openai-chat',baseUrl:'http://127.0.0.1:1/v1',allowPrivateNetwork:true,liveModels:false,models:['fixture-model']}}; + saveConfig(local); + expect(loadConfig().port).toBe(18274); + writeFileSync(join(library,'_meta.json'),JSON.stringify({entries:[],custom:'keep'})); + const document = enabled => ({version:1,home:{name:'fixture',version:'1'}, + clients:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,{enabled:id==='claude-desktop'&&enabled}])), + sources:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,'default']))}); + async function reconcile(enabled=true, options={}) { + const receipt=await materializeLocalRouteIntent(loadConfig(),document(enabled),models, + {onlyClients:['claude-desktop'], lifecycleLockDeps:lockDeps,...options}); + return receipt.outcomes.find(row=>row.client==='claude-desktop'); + } + function connect() { + const c=loadConfig();c.runtimeRole='client';c.client={...owner,transport:'link',link:{tunnelPort:22001,linkId:'lnk_0123456789abcdef'}, + managementUrl:owner.serverUrl,managementTransport:'direct',selectedClients:['claude-desktop'],tokenEnv:'OPENCODEX_API_AUTH_TOKEN', + tokenFingerprint:serviceApiTokenFingerprint(token),protocolVersion:1};saveConfig(c);writeServiceApiTokenFile(token); + } + const selected = () => { + const meta=JSON.parse(readFileSync(join(library,'_meta.json'),'utf8')); + return meta.appliedId ? {path:join(library,meta.appliedId+'.json'),value:JSON.parse(readFileSync(join(library,meta.appliedId+'.json'),'utf8'))}:null; + }; + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + } finally { removeTreeWithRetry(home); } +} + +test("Desktop off refuses first-party routing without changing its intent or files", () => scenario(` + const c=loadConfig(); delete c.clientIntegrations; + c.claudeCode={...c.claudeCode,desktopMode:'first-party'};saveConfig(c); + const before=readFileSync(join(library,'_meta.json'),'utf8'); + const result=await reconcile(false); + expect(result.outcome).toBe('conflict'); + expect(result.message).toContain('ocx integrations native claude-desktop off'); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); + expect(loadConfig().claudeCode.desktopMode).toBe('first-party'); + expect(readFileSync(join(library,'_meta.json'),'utf8')).toBe(before); +`), SPAWN_BUDGET_MS); + +test("unrelated Home routing change preserves default-on Grok and first-party Desktop", () => scenario(` + const {injectGrokConfig}=await import('./src/grok/inject'); + const {syncHomeRouteTo}=await import('./src/routing/route-to-local'); + const {initializeHomeRouteIntentDefaults,readHomeRouteIntentState}=await import('./src/routing/home-route-intent'); + const {setRouteClientIntent}=await import('./src/routing/route-intent-state'); + const {projectHomeRouteIntent}=await import('./src/link/route-intent'); + const current=loadConfig(); delete current.clientIntegrations; + current.claudeCode={...current.claudeCode,desktopMode:'first-party'}; saveConfig(current); + mkdirSync(process.env.GROK_HOME,{recursive:true}); + expect(injectGrokConfig(18274,[{id:'fixture/old'}],{grokHome:process.env.GROK_HOME,hostname:'127.0.0.1'}).ok).toBe(true); + const grokPath=join(process.env.GROK_HOME,'config.toml'); + const desktopBefore=readFileSync(join(library,'_meta.json'),'utf8'); + const localConfig=loadConfig(); + initializeHomeRouteIntentDefaults(localConfig,'1','opencode'); + setRouteClientIntent('opencode',{enabled:true}); + const outcomes=await syncHomeRouteTo(localConfig,'1',false,undefined,undefined,{ + loadModels:async()=>models, + materialize:async (config,doc,rows,opts)=>materializeLocalRouteIntent(config,doc,rows,{ + ...opts, onlyClients: opts.onlyClients ? opts.onlyClients.filter(x=>x==='grok'||x==='claude-desktop') : ['grok','claude-desktop'], + lifecycleLockDeps:lockDeps, + }), + }); + expect(readFileSync(grokPath,'utf8')).toContain('opencodex'); + expect(readFileSync(join(library,'_meta.json'),'utf8')).toBe(desktopBefore); + expect(loadConfig().claudeCode.desktopMode).toBe('first-party'); + expect(loadConfig().clientIntegrations?.grok).toBeUndefined(); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); + const child=projectHomeRouteIntent(localConfig,'1','lnk_0123456789abcdef',readHomeRouteIntentState(localConfig)); + expect(child.clients.grok.enabled).toBe(false); + expect(child.clients['claude-desktop'].enabled).toBe(false); + // First-party Desktop already routes through OpenCodex: enabled intent is satisfied, so Home sync stays green. + expect(outcomes.find(row=>row.client==='claude-desktop').outcome).toBe('current'); +`), SPAWN_BUDGET_MS); + +for (const evidence of ["marker", "settings"]) { + test(`connected Desktop inherited enable preserves first-party ${evidence}`, () => scenario(` + connect(); + const c=loadConfig(); + if (${JSON.stringify(evidence)}==='marker') {c.claudeCode={...c.claudeCode,desktopMode:'first-party'};saveConfig(c);} + else writeFileSync(join(root,'.claude','settings.json'),JSON.stringify({env:{HTTPS_PROXY:'http://127.0.0.1:18275',NODE_EXTRA_CA_CERTS:join(getConfigDir(),'claude-intercept','ca.pem')}})); + const before=readFileSync(join(library,'_meta.json'),'utf8'); + const settingsPath=join(root,'.claude','settings.json'); + const settings=existsSync(settingsPath)?readFileSync(settingsPath,'utf8'):null; + expect((await reconcile()).outcome).toBe('current'); + expect((await reconcile(true,{overwriteConflict:true,overwriteClient:'claude-desktop'})).outcome).toBe('current'); + const off=await reconcile(false); + expect(off.outcome).toBe('conflict'); + expect(off.message).toContain('ocx claude desktop apply --gateway'); + expect(readFileSync(join(library,'_meta.json'),'utf8')).toBe(before); + if(settings!==null)expect(readFileSync(settingsPath,'utf8')).toBe(settings); + expect(existsSync(join(getConfigDir(),'desktop-remote','state.json'))).toBe(false); + `), SPAWN_BUDGET_MS); +} + +test("connected Desktop off restores the remote-owned loopback profile without a standalone fingerprint", () => scenario(` + connect();expect((await reconcile()).outcome).toBe('current'); + expect(selected().value.inferenceGatewayBaseUrl).toBe('http://127.0.0.1:18274'); + expect(loadConfig().claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + expect((await reconcile(false)).outcome).toBe('current'); + expect(selected()?.value.inferenceProvider).not.toBe('gateway'); +`), SPAWN_BUDGET_MS); + +test("connected Desktop off refuses a drifted remote projection", () => scenario(` + connect();expect((await reconcile()).outcome).toBe('current'); + const file=selected();file.value.inferenceGatewayBaseUrl='https://edited.example.test';writeFileSync(file.path,JSON.stringify(file.value)); + const before=readFileSync(file.path,'utf8'); + expect((await reconcile(false)).outcome).toBe('conflict'); + expect(readFileSync(file.path,'utf8')).toBe(before); +`), SPAWN_BUDGET_MS); + +test("Desktop explicit overwrite snapshots edited owned fields and consent is operation scoped", () => scenario(` + connect();expect((await reconcile()).outcome).toBe('current'); + const file=selected();file.value.inferenceGatewayBaseUrl='https://edited.example.test';file.value.unrelated='keep'; + writeFileSync(file.path,JSON.stringify(file.value)); + expect((await reconcile()).outcome).toBe('conflict'); + expect((await reconcile(true,{overwriteConflict:true,overwriteClient:'pi'})).outcome).toBe('conflict'); + expect((await reconcile(true,{overwriteConflict:true,overwriteClient:'claude-desktop'})).outcome).toBe('current'); + expect(selected().value.inferenceGatewayBaseUrl).toBe('http://127.0.0.1:18274'); + expect(selected().value.unrelated).toBe('keep'); + const snapshots=join(getConfigDir(),'desktop-remote','overwrites'); + expect(readdirSync(snapshots).map(name=>JSON.parse(readFileSync(join(snapshots,name),'utf8')))).toContainEqual(file.value); + const snapshotCount=readdirSync(snapshots).length; + writeFileSync(file.path,JSON.stringify(file.value)); + expect((await reconcile(true,{overwriteConflict:true,overwriteClient:'claude-desktop'})).outcome).toBe('current'); + expect(readdirSync(snapshots)).toHaveLength(snapshotCount); + file.value.inferenceGatewayBaseUrl='https://edited-again.example.test';writeFileSync(file.path,JSON.stringify(file.value)); + expect((await reconcile()).outcome).toBe('conflict'); +`), SPAWN_BUDGET_MS); + +for (const obstruction of ["renamed", "unselected", "pending", "snapshot", "token", "owner"]) { + test(`Desktop overwrite retains safety checks: ${obstruction}`, () => scenario(` + connect();expect((await reconcile()).outcome).toBe('current'); + const file=selected();file.value.inferenceGatewayBaseUrl='https://edited.example.test';writeFileSync(file.path,JSON.stringify(file.value)); + const store=join(getConfigDir(),'desktop-remote'); + const metaPath=join(library,'_meta.json'), statePath=join(store,'state.json'); + const meta=JSON.parse(readFileSync(metaPath,'utf8')); + if(${JSON.stringify(obstruction)}==='renamed'){meta.entries[0].name='foreign';writeFileSync(metaPath,JSON.stringify(meta));} + if(${JSON.stringify(obstruction)}==='unselected'){delete meta.appliedId;writeFileSync(metaPath,JSON.stringify(meta));} + if(${JSON.stringify(obstruction)}==='pending'){ + const state=JSON.parse(readFileSync(statePath,'utf8')); + state.pending={kind:'rotate',before:state.lastProjectionHash,after:state.lastProjectionHash, + beforeSelection:meta.appliedId,afterSelection:meta.appliedId,tokenFingerprint:state.tokenFingerprint}; + writeFileSync(statePath,JSON.stringify(state),{mode:0o600}); + } + if(${JSON.stringify(obstruction)}==='snapshot'){ + mkdirSync(join(root,'foreign-snapshots'));symlinkSync(join(root,'foreign-snapshots'),join(store,'overwrites')); + } + if(${JSON.stringify(obstruction)}==='token')replaceServiceApiTokenFile('ocx_data_changed_fixture'); + const before=readFileSync(file.path,'utf8'); + const result=withClientLifecycleSync(held=>applyRemoteDesktopStore(held,{ + owner:${JSON.stringify(obstruction)}==='owner'?{...owner,apiKeyId:'foreign-key'}:owner, + expectedTokenFingerprint:serviceApiTokenFingerprint(token),baseUrl:'http://127.0.0.1:18274',apiKey:'opencodex-loopback', + mode:'static',models:[{name:'claude-sonnet-4-6-20260217',labelOverride:'Fixture',anthropicFamilyTier:'sonnet'}], + localListener:true,overwriteConflict:true}),lockDeps); + expect(result).toMatchObject({ok:false,reason:${JSON.stringify(obstruction)}==='snapshot'?'unsafe':'conflict'}); + expect(readFileSync(file.path,'utf8')).toBe(before); + if(${JSON.stringify(obstruction)}==='snapshot')expect(readdirSync(join(root,'foreign-snapshots'))).toEqual([]); + `), SPAWN_BUDGET_MS); +} + +test("Home gateway file and marker stay inside the existing picker transition", () => scenario(` + const runtime=await import('./src/claude/intercept/runtime'); + let inside=false,transitions=0; + spyOn(runtime,'getClaudePickerController').mockReturnValue({transition:async work=>{ + transitions++;inside=true; + try {const row=await work({});expect(loadConfig().claudeCode.desktopMode).toBe('gateway');return row;} + finally{inside=false;} + }}); + const writer=desktop.writeDesktop3pConfig; + spyOn(desktop,'writeDesktop3pConfig').mockImplementation((...args)=>{ + expect(inside).toBe(true); + expect(()=>withClientLifecycleSync(()=>{},lockDeps)).toThrow('client_lifecycle_busy'); + return writer(...args); + }); + expect((await reconcile()).outcome).toBe('current');expect(transitions).toBe(1); + expect(loadConfig().claudeCode.desktopProfile.appliedFingerprint).toBeDefined(); +`), SPAWN_BUDGET_MS); + +test("Home gateway writer refusal leaves the mode and fingerprint unchanged", () => scenario(` + const before=loadConfig().claudeCode; + spyOn(desktop,'writeDesktop3pConfig').mockReturnValue({written:false,path:library,reason:'fixture_refusal'}); + const row=await reconcile();expect(row.outcome).toBe('failed');expect(row.message).toBe('fixture_refusal'); + expect(loadConfig().claudeCode).toEqual(before);expect(selected()).toBeNull(); +`), SPAWN_BUDGET_MS); + +test("Desktop writer refuses forged and expired lifecycle leases", () => scenario(` + const expired=withClientLifecycleSync(held=>held,lockDeps); + for(const held of [{},expired]){ + const result=desktop.writeDesktop3pConfig(18274,[],[{provider:'fixture',id:'claude-sonnet-4-6'}], + undefined,'static',undefined,undefined,lockDeps,undefined,held); + expect(result.written).toBe(false);expect(result.reason).toBe('desktop_lifecycle_busy_or_unsafe'); + expect(selected()).toBeNull(); + } +`), SPAWN_BUDGET_MS); + +test("gateway marker admission refuses a newer first-party mode inside its config transaction", () => scenario(` + const {persistCommittedDesktopGateway}=await import('./src/claude/desktop-gateway-state'); + const {observeClaudeDesktopMode,resolveClaudeDesktopMode}=await import('./src/claude/desktop-first-party'); + const stale=loadConfig(); + const latest=loadConfig();latest.claudeCode={...latest.claudeCode,desktopMode:'first-party'};saveConfig(latest); + const result=persistCommittedDesktopGateway(stale,stale.claudeCode?.desktopProfile,'fixture-fingerprint', + current=>resolveClaudeDesktopMode(current,observeClaudeDesktopMode(current))!=='first-party'); + expect(result).toEqual({ok:false,reason:'conflict'}); + expect(loadConfig().claudeCode.desktopMode).toBe('first-party'); + expect(loadConfig().claudeCode.desktopProfile?.appliedFingerprint).toBeUndefined(); +`), SPAWN_BUDGET_MS); + +for (const approved of [false, true]) { + test(`Codex external provider routing approval is respected: ${approved}`, () => scenario(` + const source='model_provider = "external"\\n[model_providers.external]\\nname = "Fixture"\\nbase_url = "https://api.example.test/v1"\\nwire_api = "responses"\\nrequires_openai_auth = true\\nsupports_websockets = true\\n'; + writeFileSync(configPath,source); + if(${approved})allowCodexProviderRouting('external',previewCodexProviderConsent('external')); + const doc=document(false);doc.clients.codex.enabled=true; + const result=await materializeLocalRouteIntent(loadConfig(),doc,models,{onlyClients:['codex'],lifecycleLockDeps:lockDeps}); + expect(result.outcomes.find(row=>row.client==='codex').outcome).toBe(${approved}?'current':'conflict'); + const after=readFileSync(configPath,'utf8'); + if(${approved}){expect(after).toContain('http://127.0.0.1:18274/v1');expect(after).toContain('model_provider = "external"');expect(after).toContain('supports_websockets = true');} + else expect(after).toBe(source); + `), SPAWN_BUDGET_MS); +} + +test("Home stale gateway apply rechecks first-party mode at write admission", () => scenario(` + const c=loadConfig();c.claudeCode={...c.claudeCode,desktopMode:'gateway'};saveConfig(c); + let inspected=false,switched=false; + const original=desktop.inspectDesktop3pConfigLibrary; + spyOn(desktop,'inspectDesktop3pConfigLibrary').mockImplementation((...args)=>{inspected=true;return original(...args)}); + const row=await reconcile(true,{beforeWrite:()=>{ + if(inspected&&!switched){switched=true;const c=loadConfig();c.claudeCode={...c.claudeCode,desktopMode:'first-party'};saveConfig(c);} + }}); + // The admission recheck sees first-party: enabled intent is satisfied without writing a gateway. + expect(switched).toBe(true);expect(row.outcome).toBe('current'); + expect(loadConfig().claudeCode.desktopMode).toBe('first-party'); + expect(selected()?.value.inferenceProvider).not.toBe('gateway'); +`), SPAWN_BUDGET_MS); diff --git a/tests/clients/route-intent-offline-mode-switch.test.ts b/tests/clients/route-intent-offline-mode-switch.test.ts new file mode 100644 index 00000000000..935a4792b34 --- /dev/null +++ b/tests/clients/route-intent-offline-mode-switch.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +function offlineScenario(body: string) { + const home = mkdtempSync(join(tmpdir(), "ocx-offline-mode-switch-")); + for (const dir of [".codex", ".claude", "desktop"]) mkdirSync(join(home, dir)); + try { + const result = spawnSync(process.execPath, ["--eval", ` + import {expect,spyOn} from 'bun:test'; + import {readFileSync,writeFileSync,existsSync} from 'node:fs'; + import {join} from 'node:path'; + import {spawnSync} from 'node:child_process'; + import {getDefaultConfig,saveConfig,loadConfig} from './src/config'; + import {materializeLocalRouteIntent} from './src/client/route-intent-apply'; + import {ROUTE_CLIENT_IDS} from './src/link/route-intent'; + import {applyDesktop} from './src/cli/claude-desktop'; + import * as desktop from './src/claude/desktop-3p'; + import {getClaudePickerController} from './src/claude/intercept/runtime'; + import {withClientLifecycle,withClientLifecycleSync} from './src/client/lifecycle-lock'; + const root=process.env.HOME, library=process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + const lockDeps={lockPath:join(root,'lifecycle.sqlite')}; + const deps={lifecycleLockDeps:lockDeps,findLiveProxyImpl:async()=>null}; + const config=getDefaultConfig();config.port=18274;config.hostname='127.0.0.1';config.defaultProvider='fixture'; + config.providers={fixture:{adapter:'openai-chat',baseUrl:'http://127.0.0.1:1/v1',allowPrivateNetwork:true,liveModels:false,models:['fixture-model']}}; + config.claudeCode={...config.claudeCode,desktopMode:'gateway',intercept:{enabled:true}};saveConfig(config); + writeFileSync(join(library,'_meta.json'),JSON.stringify({entries:[]})); + const document={version:1,home:{name:'fixture',version:'1'}, + clients:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,{enabled:id==='claude-desktop'}])), + sources:Object.fromEntries(ROUTE_CLIENT_IDS.map(id=>[id,'default']))}; + const models=[{namespaced:'fixture/claude-sonnet-4-6',provider:'fixture',id:'claude-sonnet-4-6'}]; + const options={onlyClients:['claude-desktop'],lifecycleLockDeps:lockDeps}; + const initial=await materializeLocalRouteIntent(loadConfig(),document,models,options); + expect(initial.outcomes.find(row=>row.client==='claude-desktop').outcome).toBe('current'); + const selected=()=>{ + const meta=JSON.parse(readFileSync(join(library,'_meta.json'),'utf8')); + return meta.appliedId?{path:join(library,meta.appliedId+'.json'),value:JSON.parse(readFileSync(join(library,meta.appliedId+'.json'),'utf8'))}:null; + }; + expect(selected().value.inferenceProvider).toBe('gateway'); + expect(getClaudePickerController()).toBeNull(); + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: result.status, stderr: result.stderr, error: result.error?.message }).toMatchObject({ status: 0 }); + } finally { removeTreeWithRetry(home); } +} + +test("offline CLI first-party switch excludes a separate-process materializer after gateway removal", () => offlineScenario(` + let contender,removed=false; + const remove=desktop.removeDesktop3pStandardPivot; + spyOn(desktop,'removeDesktop3pStandardPivot').mockImplementation(options=>{ + const result=remove({...options,lifecycleLockDeps:lockDeps}); + expect(result.ok).toBe(true);removed=true; + expect(selected()?.value.inferenceProvider).not.toBe('gateway'); + // Real competing materialization in another process: this is exactly the former + // await gap between the remover's return and saveDesktopMode's admission. + const code= + "import {loadConfig} from './src/config';"+ + "import {materializeLocalRouteIntent} from './src/client/route-intent-apply';"+ + "try { const result=await materializeLocalRouteIntent(loadConfig(),"+JSON.stringify(document)+","+JSON.stringify(models)+","+JSON.stringify({...options,onlyClients:['claude-desktop'],lifecycleLockDeps:lockDeps})+");console.log(JSON.stringify({row:result.outcomes.find(row=>row.client==='claude-desktop')})); }"+ + "catch(error){console.log(JSON.stringify({error:error.message}));}"; + const child=spawnSync(process.execPath,['--eval',code],{cwd:process.cwd(),env:process.env,encoding:'utf8',timeout:10000}); + expect({status:child.status,stderr:child.stderr}).toMatchObject({status:0}); + contender=JSON.parse(child.stdout.trim().split('\\n').at(-1)); + return result; + }); + const applied=await applyDesktop(undefined,{kind:'first-party'},deps); + expect(removed).toBe(true);expect(applied.ok).toBe(true);expect(applied.warning).toBeUndefined(); + expect(loadConfig().claudeCode.desktopMode).toBe('first-party'); + expect(selected()?.value.inferenceProvider).not.toBe('gateway'); + expect(contender.error).toBe('client_lifecycle_busy'); + const settings=JSON.parse(readFileSync(join(root,'.claude','settings.json'),'utf8')); + expect(settings.env.HTTPS_PROXY).toContain('127.0.0.1'); + const retry=await materializeLocalRouteIntent(loadConfig(),document,models,options); + // First-party already routes Desktop: the enabled intent is current and writes no gateway. + expect(retry.outcomes.find(row=>row.client==='claude-desktop').outcome).toBe('current'); + expect(selected()?.value.inferenceProvider).not.toBe('gateway'); +`), SPAWN_BUDGET_MS); + +test("offline CLI rolls back first-party settings when gateway retirement refuses", () => offlineScenario(` + const file=selected(); + const before=readFileSync(file.path,'utf8'); + const settingsPath=join(root,'.claude','settings.json'); + writeFileSync(settingsPath,JSON.stringify({theme:'keep',env:{USER_CHOICE:'keep'}})); + spyOn(desktop,'removeDesktop3pStandardPivot').mockReturnValue({ok:false,changed:false,kind:'unsafe',libraryPath:library,reason:'fixture_refusal'}); + const result=await applyDesktop(undefined,{kind:'first-party'},deps); + expect(result.ok).toBe(false);expect(result.reason).toContain('gateway_profile_active'); + expect(result.applied).toBe(false);expect(result.saved).toBe(false); + expect(readFileSync(file.path,'utf8')).toBe(before); + expect(JSON.parse(readFileSync(settingsPath,'utf8'))).toEqual({theme:'keep',env:{USER_CHOICE:'keep'}}); + expect(loadConfig().claudeCode.desktopMode).toBe('gateway'); + expect(withClientLifecycleSync(()=>true,lockDeps)).toBe(true); +`), SPAWN_BUDGET_MS); + +test("offline CLI retains first-party mode after a completed pivot with incomplete cleanup", () => offlineScenario(` + const remove=desktop.removeDesktop3pStandardPivot; + spyOn(desktop,'removeDesktop3pStandardPivot').mockImplementation(options=>remove({...options,unlink:()=>{throw new Error('fixture_unlink_failure')}})); + const result=await applyDesktop(undefined,{kind:'first-party'},deps); + expect(result).toMatchObject({ok:false,reason:'gateway_cleanup_incomplete',applied:true,saved:true}); + expect(loadConfig().claudeCode.desktopMode).toBe('first-party'); + expect(selected()?.value.inferenceProvider).not.toBe('gateway'); + expect(JSON.parse(readFileSync(join(root,'.claude','settings.json'),'utf8')).env.HTTPS_PROXY).toContain('127.0.0.1'); + expect(withClientLifecycleSync(()=>true,lockDeps)).toBe(true); +`), SPAWN_BUDGET_MS); + +test("offline CLI reports an incomplete conditional rollback and preserves a newer user env", () => offlineScenario(` + const before=readFileSync(selected().path,'utf8'); + const settingsPath=join(root,'.claude','settings.json'); + spyOn(desktop,'removeDesktop3pStandardPivot').mockImplementation(()=>{ + writeFileSync(settingsPath,JSON.stringify({env:{HTTPS_PROXY:'http://new-user.example.test:3128'},theme:'new-choice'})); + return {ok:false,changed:false,kind:'unsafe',libraryPath:library,reason:'fixture_refusal'}; + }); + const result=await applyDesktop(undefined,{kind:'first-party'},deps); + expect(result.ok).toBe(false);expect(result.warning).toContain('rollback did not complete'); + expect(readFileSync(selected().path,'utf8')).toBe(before); + expect(JSON.parse(readFileSync(settingsPath,'utf8'))).toEqual({env:{HTTPS_PROXY:'http://new-user.example.test:3128'},theme:'new-choice'}); + expect(loadConfig().claudeCode.desktopMode).toBe('gateway'); +`), SPAWN_BUDGET_MS); + +test("offline CLI preserves foreign first-party settings and releases its lease on refusal", () => offlineScenario(` + const before=readFileSync(selected().path,'utf8'); + const settingsPath=join(root,'.claude','settings.json'); + const settings=JSON.stringify({env:{HTTPS_PROXY:'http://foreign.example.test:3128'},theme:'keep'}); + writeFileSync(settingsPath,settings); + const result=await applyDesktop(undefined,{kind:'first-party'},deps); + expect(result).toMatchObject({ok:false,reason:'foreign_env',applied:false}); + expect(readFileSync(settingsPath,'utf8')).toBe(settings); + expect(readFileSync(selected().path,'utf8')).toBe(before); + expect(loadConfig().claudeCode.desktopMode).toBe('gateway'); + expect(withClientLifecycleSync(()=>true,lockDeps)).toBe(true); +`), SPAWN_BUDGET_MS); + +test("offline CLI cannot begin a mode switch while another lifecycle lease is active", () => offlineScenario(` + const before=readFileSync(selected().path,'utf8'); + const result=await withClientLifecycle(()=>applyDesktop(undefined,{kind:'first-party'},deps),lockDeps); + expect(result).toMatchObject({ok:false,reason:'client_lifecycle_busy'}); + expect(readFileSync(selected().path,'utf8')).toBe(before); + expect(existsSync(join(root,'.claude','settings.json'))).toBe(false); + expect(loadConfig().claudeCode.desktopMode).toBe('gateway'); +`), SPAWN_BUDGET_MS); + +test("Desktop remover refuses forged and expired lifecycle leases", () => offlineScenario(` + const before=readFileSync(selected().path,'utf8'); + const expired=withClientLifecycleSync(held=>held,lockDeps); + for(const held of [{},expired]){ + const result=desktop.removeDesktop3pStandardPivot({replaceWhileEnabled:true,lifecycleLockDeps:lockDeps,lifecycleHeld:held, + appliedFingerprint:loadConfig().claudeCode.desktopProfile.appliedFingerprint}); + expect(result).toMatchObject({ok:false,changed:false,kind:'write_failed'}); + expect(readFileSync(selected().path,'utf8')).toBe(before); + } +`), SPAWN_BUDGET_MS); diff --git a/tests/clients/route-intent-overwrite.test.ts b/tests/clients/route-intent-overwrite.test.ts new file mode 100644 index 00000000000..04804064e48 --- /dev/null +++ b/tests/clients/route-intent-overwrite.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { materializeRouteIntent } from "../../src/integrations/route-intent"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { ROUTE_CLIENT_IDS, type HomeRouteIntentDocument } from "../../src/link/route-intent"; +import type { OcxConfig } from "../../src/types"; + +let home: TempHome; +beforeEach(() => { home = createTempHome("route-overwrite-scope-"); }); +afterEach(() => home.remove()); + +test("a Pi takeover cannot grant takeover of another enabled integration", async () => { + const document = { version: 1, home: { name: "test-home", version: "1" }, + clients: Object.fromEntries(ROUTE_CLIENT_IDS.map(client => [client, { enabled: client === "pi" || client === "opencode" }])), + sources: Object.fromEntries(ROUTE_CLIENT_IDS.map(client => [client, "default"])) } as HomeRouteIntentDocument; + const deviceHome = home.path("device"); + const files = {} as Record<"pi" | "opencode", string>; + for (const client of ["pi", "opencode"] as const) { + const spec = INTEGRATION_CLIENTS[client]; + files[client] = spec.configPath({}, deviceHome); + mkdirSync(spec.detectDir({}, deviceHome), { recursive: true }); mkdirSync(dirname(files[client]), { recursive: true }); + } + const input = { config: { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig, document, + models: [{ namespaced: "child/model", provider: "child", id: "model" }], home: deviceHome, env: {}, + store: createIntegrationStateStore(home.path("store")), statePath: home.path("route-state") }; + await materializeRouteIntent(input); + for (const client of ["pi", "opencode"] as const) { + const parsed = JSON.parse(readFileSync(files[client], "utf8")); + (parsed.providers ?? parsed.provider).opencodex.baseUrl = "https://foreign.example.test/v1"; + writeFileSync(files[client], JSON.stringify(parsed)); + } + const otherBefore = readFileSync(files.opencode, "utf8"); + const result = await materializeRouteIntent({ ...input, overwriteConflict: true, overwriteClient: "pi" }); + expect(result.outcomes.find(row => row.client === "pi")?.outcome).toBe("current"); + expect(result.outcomes.find(row => row.client === "opencode")?.outcome).toBe("conflict"); + expect(readFileSync(files.opencode, "utf8")).toBe(otherBefore); +}); diff --git a/tests/clients/route-intent-parity-fallback.test.ts b/tests/clients/route-intent-parity-fallback.test.ts new file mode 100644 index 00000000000..54b7d1a1390 --- /dev/null +++ b/tests/clients/route-intent-parity-fallback.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test"; +import { homeRouteIntentReceipt, sendLinkChildParityReport, type HomeRouteIntentRecord } from "../../src/client/link-home"; +import { createLinkParityReporter, LINK_PARITY_PERIOD_MS, LINK_PARITY_RETRY_MS } from "../../src/client/link-parity-reporter"; +import { projectHomeRouteIntent, ROUTE_CLIENT_IDS } from "../../src/link/route-intent"; +import type { LinkChildParityReport } from "../../src/link/child-parity"; +import type { OcxClientConnectionConfig } from "../../src/types"; + +const linkId = "lnk_0123456789abcdef"; +const at = "2026-11-07T00:00:00.000Z"; +const connection = { transport: "link", apiKeyId: "fixture", link: { linkId, tunnelPort: 22001 } } as OcxClientConnectionConfig; +const receipt = homeRouteIntentReceipt(linkId, projectHomeRouteIntent({} as never, "fixture"), at, at, + ROUTE_CLIENT_IDS.map(client => ({ client, outcome: "current" })), null); + +function harness(statuses: number[], routeRecord: HomeRouteIntentRecord | null = receipt) { + const sent: LinkChildParityReport[] = []; + let pending: (() => void) | undefined; + const delays: number[] = []; + const reporter = createLinkParityReporter({ connection, runtimeStartedAt: at, linkKey: () => "fixture-key", + readRecord: () => null, readRouteRecord: () => routeRecord, + readCommonReceipt: () => routeRecord && ({ version: 1, linkId, document: routeRecord.document, + clients: routeRecord.document.clients, sources: routeRecord.document.sources, + selectedClients: ROUTE_CLIENT_IDS.filter(id => routeRecord.document.clients[id].enabled), + outcomes: routeRecord.routeClientOutcomes, appliedRouteIntentDigest: routeRecord.appliedRouteIntentDigest, appliedAt: at }), + readLocalOff: () => [], + send: (connection, key, report) => sendLinkChildParityReport(connection, key, report, { + fetchImpl: (async (_input, init) => { + sent.push(JSON.parse(String(init?.body))); + return new Response(null, { status: statuses.shift() ?? 204 }); + }) as typeof fetch, + }), + setTimer: (callback, ms) => { pending = callback; delays.push(ms); return callback; }, + clearTimer: () => { pending = undefined; }, + }); + const fire = async () => { + const callback = pending; pending = undefined; callback?.(); + for (let i = 0; i < 30; i++) await Promise.resolve(); + }; + return { reporter, sent, delays, fire, pending: () => pending }; +} + +test.each([400, 413, 422])("HTTP %i on extended parity retries base once and probes extended again next tick", async status => { + const h = harness([status, 204, 204]); + try { + h.reporter.start(); await h.fire(); + expect(h.sent).toHaveLength(2); + const { appliedRouteIntentDigest, routeClientOutcomes, ...base } = h.sent[0]!; + expect(appliedRouteIntentDigest).toBeDefined(); + expect(routeClientOutcomes).toHaveLength(ROUTE_CLIENT_IDS.length); + expect(h.sent[1]).toEqual(base); + expect(h.delays.at(-1)).toBe(LINK_PARITY_PERIOD_MS); + await h.fire(); + expect(h.sent).toHaveLength(3); + expect(h.sent[2]?.appliedRouteIntentDigest).toBe(appliedRouteIntentDigest); + expect(h.sent[2]!.sequence).toBeGreaterThan(base.sequence); + } finally { h.reporter.stop(); } +}); + +test.each([401, 403, 409, 429, 500])("HTTP %i never triggers a schema fallback", async status => { + const h = harness([status]); + try { + h.reporter.start(); await h.fire(); + expect(h.sent).toHaveLength(1); + expect(h.delays.at(-1)).toBe(LINK_PARITY_RETRY_MS[1]); + } finally { h.reporter.stop(); } +}); + +test("missing parity route stops reporting without a fallback", async () => { + const h = harness([404]); + h.reporter.start(); await h.fire(); + expect(h.sent).toHaveLength(1); + expect(h.pending()).toBeUndefined(); + h.reporter.stop(); +}); + +test("base rejection backs off and each later tick still probes the extended schema", async () => { + const h = harness([400, 500, 204]); + try { + h.reporter.start(); await h.fire(); + expect(h.sent).toHaveLength(2); + expect(h.delays.at(-1)).toBe(LINK_PARITY_RETRY_MS[1]); + await h.fire(); + expect(h.sent[2]?.appliedRouteIntentDigest).toBeDefined(); + expect(h.delays.at(-1)).toBe(LINK_PARITY_PERIOD_MS); + } finally { h.reporter.stop(); } +}); + +test("a withdrawal falls back once after schema refusal and remains explicit next tick", async () => { + const h = harness([400, 204, 204], null); + try { + h.reporter.start(); await h.fire(); + expect(h.sent).toHaveLength(2); + expect(h.sent[0]?.appliedRouteIntentDigest).toBeNull(); + expect(h.sent[0]?.routeClientOutcomes).toEqual([]); + expect(h.sent[1]?.appliedRouteIntentDigest).toBeUndefined(); + expect(h.delays.at(-1)).toBe(LINK_PARITY_PERIOD_MS); + await h.fire(); + expect(h.sent).toHaveLength(3); + expect(h.sent[2]?.appliedRouteIntentDigest).toBeNull(); + expect(h.sent[2]?.routeClientOutcomes).toEqual([]); + } finally { h.reporter.stop(); } +}); diff --git a/tests/clients/route-intent-parity-withdrawal.test.ts b/tests/clients/route-intent-parity-withdrawal.test.ts new file mode 100644 index 00000000000..62f1f1ad7cf --- /dev/null +++ b/tests/clients/route-intent-parity-withdrawal.test.ts @@ -0,0 +1,94 @@ +import { expect, test, setDefaultTimeout } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); +function scenario(mutation: string) { + const root = mkdtempSync(join(tmpdir(), "ocx-parity-withdrawal-")); + const home = join(root, "home"); mkdirSync(home); + try { + const result = spawnSync(process.execPath, ["--eval", ` + import { writeFileSync, unlinkSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig } from './src/config'; + import { createLinkParityReporter } from './src/client/link-parity-reporter'; + import { createLinkChildParityStore, parseLinkChildParityReport } from './src/link/child-parity'; + import { projectHomeRouteIntent, ROUTE_CLIENT_IDS, routeIntentDigest } from './src/link/route-intent'; + import { homeRouteIntentReceipt, writeHomeRouteIntentRecord, readHomeRouteIntentRecord, clearHomeRouteIntentRecord, sendLinkChildParityReport } from './src/client/link-home'; + import { writeRouteIntentReceipt, readRouteIntentReceiptStrict, routeIntentStatePath, setLocalRouteIntentOff } from './src/integrations/route-intent-state'; + import { applyConnectedRouteIntent } from './src/integrations/route-intent-connected'; + saveConfig(getDefaultConfig()); + const linkId='lnk_0123456789abcdef', at='2026-11-07T00:00:00.000Z'; + const connection={transport:'link',apiKeyId:'fixture',link:{linkId,tunnelPort:22001}}; + const document=projectHomeRouteIntent(getDefaultConfig(),'fixture'); + const outcomes=ROUTE_CLIENT_IDS.map(client=>({client,outcome:'current'})); + const receipt={version:1,linkId,document,clients:document.clients,sources:document.sources, + selectedClients:ROUTE_CLIENT_IDS.filter(id=>document.clients[id].enabled),outcomes, + appliedRouteIntentDigest:routeIntentDigest(document),appliedAt:at}; + writeRouteIntentReceipt(receipt); + writeHomeRouteIntentRecord(homeRouteIntentReceipt(linkId,document,at,at,outcomes,null)); + const store=createLinkChildParityStore(()=>Date.parse(at)), sent=[]; + let pending; + const reporter=createLinkParityReporter({connection,runtimeStartedAt:at,linkKey:()=> 'fixture-key',readRecord:()=>null, + send:(connection,key,report)=>sendLinkChildParityReport(connection,key,report,{fetchImpl:async(_input,init)=>{ + const raw=JSON.parse(String(init.body));sent.push(raw); + const parsed=parseLinkChildParityReport(raw);if(!parsed)throw new Error('invalid reporter payload'); + store.record(parsed,new Set([linkId]));return new Response(null,{status:204}); + }}),setTimer:callback=>{pending=callback;return callback;},clearTimer:()=>{pending=undefined;}}); + const fire=async()=>{const callback=pending;pending=undefined;callback();for(let i=0;i<40;i++)await Promise.resolve();}; + try { + reporter.start();await fire();const before=store.get(linkId); + ${mutation} + await fire(); + console.log(JSON.stringify({before,after:store.get(linkId),last:sent.at(-1),homeMissing:readHomeRouteIntentRecord(linkId)===null})); + } finally {reporter.stop();} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, PATH: "", + OPENCODEX_HOME: join(home, ".opencodex"), CODEX_HOME: join(home, ".codex"), + XDG_CONFIG_HOME: join(home, ".config"), CLAUDE_CONFIG_DIR: join(home, ".claude"), + GROK_HOME: join(home, ".grok"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), + OCX_TEST_HOME_GUARD: "1" }, + }); + expect({ status: result.status, stderr: result.stderr, error: result.error?.message }).toMatchObject({ status: 0 }); + return JSON.parse(result.stdout.trim().split("\n").at(-1)!); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test.each([ + ["local-off invalidates the common receipt", `setLocalRouteIntentOff('codex',true);`], + ["current local-off differs from the common receipt", `writeFileSync(join(routeIntentStatePath(),'local-off.json'),JSON.stringify({version:1,clients:['codex']}));`], + ["common receipt is missing", `unlinkSync(join(routeIntentStatePath(),'receipt.json'));`], + ["common receipt is malformed", `writeFileSync(join(routeIntentStatePath(),'receipt.json'),'{');`], + ["local-off is malformed", `writeFileSync(join(routeIntentStatePath(),'local-off.json'),'{');`], + ["common receipt belongs to another link", `writeRouteIntentReceipt({...receipt,linkId:'lnk_aaaaaaaaaaaaaaaa'});`], + ["common receipt describes a different intent", `writeRouteIntentReceipt({...receipt,document:{...document,clients:{...document.clients,pi:{enabled:true}}}});`], + ["common receipt still awaits catalog", `writeRouteIntentReceipt({...receipt,catalogPending:true});`], + ["Home receipt is missing", `clearHomeRouteIntentRecord();`], +])("reporter withdraws Home parity when %s", (_name, mutation) => { + const result = scenario(mutation!); + expect(result.before.appliedRouteIntentDigest).toMatch(/^[a-f0-9]{64}$/); + expect(result.last.appliedRouteIntentDigest).toBeNull(); + expect(result.last.routeClientOutcomes).toEqual([]); + expect(result.after).toMatchObject({ appliedRouteIntentDigest: null, routeClientOutcomes: [] }); +}); + +test("an aborted connected materialization withdraws Home proof through the actual reporter", () => { + const result = scenario(` + let writes=0,error; + try {await applyConnectedRouteIntent(connection,'fixture-key',{document,fetchedAt:at,stale:false},{ + beforeWrite:()=>{if(++writes===2)throw new Error('fixture apply aborted');}, + lifecycleLockDeps:{lockPath:join(process.env.OPENCODEX_HOME,'lifecycle.sqlite')}, + });}catch(e){error=e.message;} + if(error!=='fixture apply aborted')throw new Error('abort fixture failed: '+error); + if(readRouteIntentReceiptStrict().appliedRouteIntentDigest!==receipt.appliedRouteIntentDigest)throw new Error('fixture must retain prior common receipt'); + `); + expect(result.before.appliedRouteIntentDigest).toMatch(/^[a-f0-9]{64}$/); + expect(result.homeMissing).toBe(true); + expect(result.last.appliedRouteIntentDigest).toBeNull(); + expect(result.last.routeClientOutcomes).toEqual([]); + expect(result.after).toMatchObject({ appliedRouteIntentDigest: null, routeClientOutcomes: [] }); +}); diff --git a/tests/clients/route-intent-parity.test.ts b/tests/clients/route-intent-parity.test.ts new file mode 100644 index 00000000000..778ffd7b8d4 --- /dev/null +++ b/tests/clients/route-intent-parity.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { createLinkChildParityStore, parseLinkChildParityReport, LINK_CHILD_PARITY_MAX_BYTES } from "../../src/link/child-parity"; +import { ROUTE_CLIENT_IDS } from "../../src/link/route-intent"; +const report = { linkId: "lnk_0123456789abcdef", lastApplyAt: null, runtimeStartedAt: 1794000000000, appliedCodexSettingsDigest: null, sequence: 1 }; +test("extended peer schema parses route-only apply, opt-out, and strips local diagnostic content", () => { + const extended = { ...report, appliedRouteIntentDigest: "a".repeat(64), routeClientOutcomes: [{ client: "aside", outcome: "current", source: "local", target: "/private/client/models.json", message: "secret-token" }] }; + const parsed = parseLinkChildParityReport(extended); + expect(parsed).not.toBeNull(); + expect(parsed).toMatchObject({ appliedRouteIntentDigest: "a".repeat(64), routeClientOutcomes: [{ client: "aside", outcome: "current", source: "local" }] }); + expect(JSON.stringify(parsed)).not.toContain("secret-token"); + expect(JSON.stringify(parsed)).not.toContain("/private"); + const store = createLinkChildParityStore(() => 1794000001000); + store.record(parsed!, new Set([report.linkId])); + expect(store.get(report.linkId)).toMatchObject({ appliedRouteIntentDigest: "a".repeat(64), routeClientOutcomes: [{ client: "aside", outcome: "current", source: "local" }] }); + expect(parseLinkChildParityReport(report)).toEqual(report); +}); +test("strict route report rejects malformed digest, duplicates, missing paired fields and secrets", () => { + const extended = { ...report, appliedRouteIntentDigest: null, routeClientOutcomes: [{ client: "aside", outcome: "failed" }] }; + expect(parseLinkChildParityReport(extended)).not.toBeNull(); + for (const extra of [{ appliedRouteIntentDigest: "bad" }, { routeClientOutcomes: [{ client: "unknown", outcome: "current" }] }, { routeClientOutcomes: [{ client: "aside", outcome: "failed", apiKey: "secret" }] }, { routeClientOutcomes: [...extended.routeClientOutcomes, ...extended.routeClientOutcomes] }]) { + expect(parseLinkChildParityReport({ ...extended, ...extra })).toBeNull(); + } + expect(parseLinkChildParityReport({ ...report, appliedRouteIntentDigest: null })).toBeNull(); +}); +test("bounded report fits the entire client roster", () => { + const extended = { ...report, appliedRouteIntentDigest: "b".repeat(64), routeClientOutcomes: ROUTE_CLIENT_IDS.map(client => ({ client, outcome: "not_installed", intentDigest: "c".repeat(64), initialIntent: { enabled: true }, initialRevision: Number.MAX_SAFE_INTEGER })) }; + expect(Buffer.byteLength(JSON.stringify(extended))).toBeLessThanOrEqual(LINK_CHILD_PARITY_MAX_BYTES); + expect(parseLinkChildParityReport(extended)).not.toBeNull(); +}); diff --git a/tests/clients/route-intent-reporter.test.ts b/tests/clients/route-intent-reporter.test.ts new file mode 100644 index 00000000000..eba3462ff79 --- /dev/null +++ b/tests/clients/route-intent-reporter.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { createLinkParityReporter, linkChildParityReport } from "../../src/client/link-parity-reporter"; +import { homeRouteIntentReceipt, sendLinkChildParityReport } from "../../src/client/link-home"; +import { projectHomeRouteIntent, ROUTE_CLIENT_IDS, routeIntentDigest } from "../../src/link/route-intent"; +const id = "lnk_0123456789abcdef"; +const connection = { transport: "link", apiKeyId: "key-id", link: { linkId: id, tunnelPort: 19200 } } as never; +const at = "2026-11-07T00:00:00.000Z"; +const doc = projectHomeRouteIntent({} as never, "test"); +const receipt = homeRouteIntentReceipt(id, doc, at, at, ROUTE_CLIENT_IDS.map(client => ({ client, outcome: "current", ...(client === "aside" ? { source: "local", target: "u0,u1", message: "sensitive" } : {}) })) as never, null); +const readCommonReceipt = () => ({ version: 1 as const, linkId: id, document: doc, clients: doc.clients, sources: doc.sources, + selectedClients: ROUTE_CLIENT_IDS.filter(client => doc.clients[client].enabled), outcomes: receipt.routeClientOutcomes, + appliedRouteIntentDigest: receipt.appliedRouteIntentDigest, appliedAt: at }); +test("report carries receipt digest, local opt-out and only safe slot targets", async () => { + const report = linkChildParityReport(connection, at, null, 1, receipt); + expect(report?.appliedRouteIntentDigest).toBe(routeIntentDigest(doc)); + expect(Array.isArray(report?.routeClientOutcomes)).toBe(true); + expect(report?.routeClientOutcomes?.find(row => row.client === "aside")).toEqual({ client: "aside", outcome: "current", source: "local", target: "u0,u1" }); + expect(JSON.stringify(report)).not.toContain("sensitive"); + let sent = ""; + expect(await sendLinkChildParityReport(connection, "key", { ...report!, routeClientOutcomes: [{ client: "aside", outcome: "failed", target: "/private/config.json", message: "sensitive" }] }, { + fetchImpl: (async (_input, init) => { sent = String(init?.body); return new Response(null, { status: 204 }); }) as typeof fetch, + })).toBe("accepted"); + expect(sent).not.toContain("/private"); + expect(sent).not.toContain("sensitive"); +}); +test("runtime reporter reads route receipt and ignores a receipt for another link", async () => { + expect(linkChildParityReport(connection, at, null, 1, { ...receipt, linkId: "lnk_aaaaaaaaaaaaaaaa" })?.appliedRouteIntentDigest).toBeUndefined(); + let pending: (() => void) | undefined; + let sent: unknown; + const reporter = createLinkParityReporter({ connection, runtimeStartedAt: at, linkKey: () => "key", readRecord: () => null, + readRouteRecord: () => receipt, + readCommonReceipt, readLocalOff: () => [], + send: async (_connection, _key, report) => { sent = report; return "accepted"; }, + setTimer: callback => { pending = callback; return 1; }, clearTimer: () => {}, + }); + reporter.start(); pending?.(); + for (let i = 0; i < 5; i++) await Promise.resolve(); + reporter.stop(); + expect(sent).toMatchObject({ appliedRouteIntentDigest: routeIntentDigest(doc) }); +}); + +test("an immediate report queues behind an in-flight send and never overlaps", async () => { + const timers: Array<{ run: () => void; delay: number; cancelled: boolean }> = []; + const completions: Array<(value: "accepted") => void> = []; + const sequences: number[] = []; + const reporter = createLinkParityReporter({ connection, runtimeStartedAt: at, linkKey: () => "key", readRecord: () => null, + readRouteRecord: () => receipt, + readCommonReceipt, readLocalOff: () => [], + send: async (_connection, _key, report) => { + sequences.push(report.sequence); + return await new Promise<"accepted">(resolve => completions.push(resolve)); + }, + setTimer: (run, delay) => { const entry = { run, delay, cancelled: false }; timers.push(entry); return entry; }, + clearTimer: timer => { (timer as { cancelled: boolean }).cancelled = true; }, + }); + reporter.start(); + reporter.requestReport(); reporter.requestReport(); + expect(sequences).toHaveLength(1); + expect(timers[0]?.cancelled).toBe(true); + completions.shift()!("accepted"); + for (let i = 0; i < 8; i++) await Promise.resolve(); + expect(timers.at(-1)?.delay).toBe(0); + timers.at(-1)!.run(); + expect(sequences).toHaveLength(2); + expect(sequences[1]!).toBeGreaterThan(sequences[0]!); + reporter.stop(); completions.shift()!("accepted"); + for (let i = 0; i < 8; i++) await Promise.resolve(); + expect(timers).toHaveLength(2); +}); diff --git a/tests/clients/route-intent-runtime-stop.test.ts b/tests/clients/route-intent-runtime-stop.test.ts new file mode 100644 index 00000000000..fd06c88384c --- /dev/null +++ b/tests/clients/route-intent-runtime-stop.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createTempHome } from "../helpers/temp-home"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +test("runtime recycle cancels its watch and tick before replacement handoff", () => { + const home = createTempHome("route-runtime-stop-"); + try { + const fixture = home.path("runtime-stop.test.ts"); + writeFileSync(fixture, ` + import { test, expect, spyOn } from 'bun:test'; + test('isolated recycle', async () => { + const { writeFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const configApi = await import(${JSON.stringify(repoPath("src/config.ts"))}); + const { serviceApiTokenFingerprint } = await import(${JSON.stringify(repoPath("src/lib/service-secrets.ts"))}); + const ticks = await import(${JSON.stringify(repoPath("src/integrations/route-intent-tick.ts"))}); + const watches = await import(${JSON.stringify(repoPath("src/client/route-intent-watch.ts"))}); + const reporters = await import(${JSON.stringify(repoPath("src/client/link-parity-reporter.ts"))}); + const originalWatch = watches.startRouteIntentWatch; + const events = []; let active = false; + const tick = spyOn(ticks, 'startRouteIntentTick').mockImplementation(() => ({ + stop() { events.push('tick-stop'); }, requestSync: async () => {} })); + const reporter = spyOn(reporters, 'createLinkParityReporter').mockImplementation(() => ({ + start() {}, stop() { events.push('reporter-stop'); }, requestReport() {} })); + const watch = spyOn(watches, 'startRouteIntentWatch').mockImplementation((connection, key, deps) => originalWatch(connection, key, { + ...deps, fetchImpl: async (_url, init) => new Promise((_resolve, reject) => { + active = true; + init.signal.addEventListener('abort', () => { active = false; events.push('watch-cancelled'); reject(new DOMException('aborted', 'AbortError')); }, { once: true }); + }), + })); + const runtime = await import(${JSON.stringify(repoPath("src/client/runtime.ts"))}); + const probe = Bun.serve({hostname:'127.0.0.1',port:0,fetch:()=>new Response(null)}); + const port = probe.port; await probe.stop(true); + const token = 'ocx_data_'+'c'.repeat(40); + const config = configApi.getDefaultConfig(); config.port = port; config.runtimeRole = 'client'; + config.client = {serverUrl:'http://127.0.0.1:22001',managementUrl:'http://127.0.0.1:22001',managementTransport:'direct', + transport:'link',link:{linkId:'lnk_0123456789abcdef',tunnelPort:22001},selectedClients:[], + tokenEnv:'OPENCODEX_API_AUTH_TOKEN',apiKeyId:'fixture-key',tokenFingerprint:serviceApiTokenFingerprint(token), + protocolVersion:1,connectedAt:'2026-11-07T00:00:00.000Z'}; + configApi.saveConfig(config); writeFileSync(join(process.env.OPENCODEX_HOME,'service-api-token'), token); + try { + await runtime.startClientRuntime({block:false}, {restartSupervisor:async()=>null, + startListener: port => ({port,stop(){events.push('listener-stop');}})}); + for(let i=0;i<100&&!active;i++)await Bun.sleep(5); + expect(active).toBe(true); + await runtime.recycleStandalone(serviceApiTokenFingerprint(token), { + isDesktopSupervised:()=>false, + spawnReplacement:async()=>{expect(active).toBe(false);events.push('replacement');}, + exitProcess:code=>events.push('exit:'+code), + }); + expect(events.indexOf('watch-cancelled')).toBeGreaterThanOrEqual(0); + expect(events.indexOf('tick-stop')).toBeGreaterThanOrEqual(0); + expect(events.indexOf('watch-cancelled')).toBeLessThan(events.indexOf('listener-stop')); + expect(events.indexOf('tick-stop')).toBeLessThan(events.indexOf('listener-stop')); + expect(events.at(-1)).toBe('exit:0'); + } finally { tick.mockRestore(); watch.mockRestore(); reporter.mockRestore(); } + }, ${SPAWN_BUDGET_MS - 5_000}); + `); + const child = spawnSync(process.execPath, ["test", "--preload", repoPath("tests/preload.ts"), fixture], { + cwd: repoRoot(), timeout: SPAWN_BUDGET_MS - 5_000, encoding: "utf8", + env: { ...process.env, HOME: home.root, USERPROFILE: home.root, OPENCODEX_HOME: home.root, CODEX_HOME: home.codexHome, + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home.root, "desktop"), OCX_SERVICE: "", OCX_DESKTOP_SUPERVISED: "" }, + }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + } finally { home.remove(); } +}, SPAWN_BUDGET_MS); diff --git a/tests/clients/route-intent-scheduler.test.ts b/tests/clients/route-intent-scheduler.test.ts new file mode 100644 index 00000000000..2d1432d5da7 --- /dev/null +++ b/tests/clients/route-intent-scheduler.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { startRouteIntentTick } from "../../src/integrations/route-intent-tick"; + +const connection = { transport: "link", link: { linkId: "lnk_0123456789abcdef", tunnelPort: 22001 } } as never; +const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; + +test("watch requests share periodic ownership and a request during apply receives a fresh subsequent apply", async () => { + const timers: Array<{ run: () => void; ms: number; cancelled?: boolean }> = []; + const complete: Array<(value: number) => void> = []; + let calls = 0, reported = 0; + const scheduler = startRouteIntentTick(connection, { + sync: () => { calls++; return new Promise(resolve => complete.push(resolve)); }, + onApplied: () => { reported++; }, + setTimer: (run, ms) => { const timer = { run, ms }; timers.push(timer); return timer; }, + clearTimer: timer => { (timer as { cancelled: boolean }).cancelled = true; }, + }); + const first = scheduler.requestSync(); + const second = scheduler.requestSync(); + expect(calls).toBe(1); + expect(timers[0]?.cancelled).toBe(true); + complete.shift()!(1); expect(await first).toBe(1); await flush(); + expect(calls).toBe(2); expect(reported).toBe(1); + complete.shift()!(2); expect(await second).toBe(2); await flush(); + expect(reported).toBe(2); + expect(timers.at(-1)?.ms).toBe(60_000); + scheduler.stop(); + await expect(scheduler.requestSync()).rejects.toThrow("client_runtime_stopping"); +}); + +test("busy applies reject the request and retry on the short cadence without overlapping", async () => { + const timers: Array<{ run: () => void; ms: number }> = []; + let calls = 0; + const scheduler = startRouteIntentTick(connection, { + sync: async () => { if (++calls === 1) throw new Error("link_apply_busy"); return "applied"; }, + setTimer: (run, ms) => { timers.push({ run, ms }); return timers.length; }, clearTimer: () => {}, + }); + await expect(scheduler.requestSync()).rejects.toThrow("link_apply_busy"); await flush(); + expect(timers.at(-1)?.ms).toBe(5_000); + timers.at(-1)!.run(); await flush(); + expect(calls).toBe(2); + expect(timers.at(-1)?.ms).toBe(60_000); + scheduler.stop(); +}); diff --git a/tests/clients/route-intent-state.test.ts b/tests/clients/route-intent-state.test.ts new file mode 100644 index 00000000000..3cbf8202d81 --- /dev/null +++ b/tests/clients/route-intent-state.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearRouteClientIntent, readRouteIntentState, routeIntentStatePath, setRouteClientIntent } from "../../src/routing/route-intent-state"; +import { projectHomeRouteIntent } from "../../src/link/route-intent"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +let root: string; +let previous: string | undefined; +beforeEach(() => { previous = process.env.OPENCODEX_HOME; root = mkdtempSync(join(tmpdir(), "ocx-route-state-")); process.env.OPENCODEX_HOME = root; }); +afterEach(() => { if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; removeTreeWithRetry(root); }); +test("default/device writes preserve disjoint clients and inherit restores default", () => { + expect(readRouteIntentState()).toEqual({ version: 1, defaults: {}, devices: {} }); + setRouteClientIntent("aside", { enabled: true, accounts: "all" }); + setRouteClientIntent("aside", { enabled: false }, "lnk_0123456789abcdef"); + setRouteClientIntent("pi", { enabled: true }, "lnk_0123456789abcdef"); + let state = readRouteIntentState(); + expect(projectHomeRouteIntent({} as never, "test", "lnk_0123456789abcdef", state).clients.aside).toEqual({ enabled: false, accounts: "all" }); + clearRouteClientIntent("aside", "lnk_0123456789abcdef"); + state = readRouteIntentState(); + expect(state.devices.lnk_0123456789abcdef?.pi).toEqual({ enabled: true }); + expect(projectHomeRouteIntent({} as never, "test", "lnk_0123456789abcdef", state).clients.aside).toEqual({ enabled: true, accounts: "all" }); +}); +test("corrupt or unknown state refuses update without replacing existing bytes", () => { + for (const body of ['{"version":2,"defaults":{},"devices":{}}', '{"version":1,"defaults":{},"devices":{},"secret":"x"}', "not json"]) { + writeFileSync(routeIntentStatePath(), body); + expect(() => readRouteIntentState()).toThrow(); + expect(() => setRouteClientIntent("codex", { enabled: false })).toThrow(); + expect(readFileSync(routeIntentStatePath(), "utf8")).toBe(body); + } + expect(() => setRouteClientIntent("aside", { enabled: true }, "__proto__")).toThrow(); +}); diff --git a/tests/clients/route-intent-watch-client.test.ts b/tests/clients/route-intent-watch-client.test.ts new file mode 100644 index 00000000000..32af8940f42 --- /dev/null +++ b/tests/clients/route-intent-watch-client.test.ts @@ -0,0 +1,204 @@ +import { afterEach, expect, test } from "bun:test"; +import { watchHomeRouteIntent, startRouteIntentWatch } from "../../src/client/route-intent-watch"; +import { projectHomeRouteIntent } from "../../src/link/route-intent"; +import type { OcxClientConnectionConfig } from "../../src/types"; +import { createLinkRelaySessions } from "../../src/server/index/link-relay-sessions"; +import { serviceApiTokenFingerprint } from "../../src/lib/service-secrets"; + +const connection = { transport: "link", apiKeyId: "test-key-id", link: { linkId: "lnk_0123456789abcdef", tunnelPort: 19200 } } as OcxClientConnectionConfig; +const document = projectHomeRouteIntent({} as never, "test"); +const handles: Array<{ stop(): void }> = []; +afterEach(() => { for (const handle of handles.splice(0)) handle.stop(); }); +const turn = () => new Promise(resolve => setTimeout(resolve, 0)); +function reply(watch = "1") { return Response.json(document, { headers: watch ? { "x-ocx-route-intent-watch": watch } : {} }); } + +test("watch sends only exact read with closed headers, manual redirects and a cancellable deadline", async () => { + let calls = 0; + const result = await watchHomeRouteIntent(connection, "test-admission-key", "a".repeat(64), { fetchImpl: (async (url, init) => { + calls++; + expect(String(url)).toBe("http://127.0.0.1:19200/v1/link-management/route-intent"); + expect(init?.method).toBe("GET"); expect(init?.redirect).toBe("manual"); expect(init?.body).toBeUndefined(); + const h = new Headers(init?.headers); + expect([...h.keys()].sort()).toEqual(["accept", "authorization", "x-ocx-route-intent-after", "x-ocx-route-intent-watch"]); + expect(h.get("authorization")).toBe("Bearer test-admission-key"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + return reply(); + }) as typeof fetch }); + expect(calls).toBe(1); expect(result).toMatchObject({ kind: "fresh", watchSupported: true, document }); +}); + +test("unknown identities and malformed digest send nothing", async () => { + const deps = { fetchImpl: (async () => { throw new Error("must not send"); }) as typeof fetch }; + expect(await watchHomeRouteIntent({ ...connection, apiKeyId: undefined }, "key", undefined, deps)).toMatchObject({ kind: "failed" }); + expect(await watchHomeRouteIntent(connection, "key", "invalid", deps)).toMatchObject({ kind: "failed" }); +}); + +test("untrusted responses have strict size, type, UTF-8, schema and support-header bounds; diagnostics stay fixed", async () => { + const responses = [new Response("private-body", { status: 302, headers: { location: "http://secret" } }), + new Response("private-body", { headers: { "content-type": "text/html" } }), + new Response("{}", { headers: { "content-type": "application/json", "content-length": "65537" } }), + new Response("x".repeat(65537), { headers: { "content-type": "application/json" } }), + new Response(new Uint8Array([0xff]), { headers: { "content-type": "application/json" } }), + Response.json({ secret: "private-body" }), reply("secret-invalid"), + Response.json(document, { headers: { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-wake": "unknown" } }), + Response.json(document, { headers: { "x-extra": "x".repeat(8193) } })]; + for (const response of responses) { + const result = await watchHomeRouteIntent(connection, "secret-key", undefined, { fetchImpl: (async () => response) as typeof fetch }); + expect(result.kind).toBe("failed"); + expect(JSON.stringify(result)).not.toMatch(/private-body|secret-key|secret-invalid/); + } + expect(await watchHomeRouteIntent(connection, "key", undefined, { fetchImpl: (async () => { throw new Error("secret-error"); }) as typeof fetch })) + .toEqual({ kind: "failed", reason: "route_intent_watch_unavailable" }); +}); + +test("abort and stalled body cancel promptly and clean the body", async () => { + let cancelled = false; + const body = new ReadableStream({ cancel() { cancelled = true; } }); + const result = await watchHomeRouteIntent(connection, "key", undefined, { timeoutMs: 10, + fetchImpl: (async () => new Response(body, { headers: { "content-type": "application/json" } })) as typeof fetch }); + expect(result.kind).toBe("failed"); expect(cancelled).toBe(true); + const abort = new AbortController(); abort.abort(new Error("private-reason")); + expect(await watchHomeRouteIntent(connection, "key", undefined, { signal: abort.signal })).toEqual({ kind: "cancelled" }); +}); + +test("old Home without watch support (including 404) defers to periodic tick without repeat reads or writes", async () => { + for (const response of [reply(""), new Response(null, { status: 404 })]) { + let reads = 0, applies = 0; + const timers: Array<() => void> = []; + const handle = startRouteIntentWatch(connection, "key", { requestSync: async () => { applies++; }, + fetchImpl: (async () => { reads++; return response; }) as typeof fetch, + setTimer: cb => { timers.push(cb); return cb; }, clearTimer() {} }); handles.push(handle); + timers.shift()?.(); await turn(); await turn(); + expect(reads).toBe(1); expect(applies).toBe(0); expect(timers).toHaveLength(0); + handle.stop(); + } +}); + +test("unchanged watch timeout skips sync but explicit and legacy wakeups still apply", async () => { + const timers: Array<() => void> = []; + const wakes: Array = ["changed", "timeout", "notified", null]; + let reads = 0, applies = 0; + const handle = startRouteIntentWatch(connection, "key", { + requestSync: async () => { applies++; }, + fetchImpl: (async () => { + const wake = wakes[reads++]; + if (wake === undefined) return new Promise(() => {}); + return Response.json(document, { headers: { "x-ocx-route-intent-watch": "1", + ...(wake === null ? {} : { "x-ocx-route-intent-wake": wake }) } }); + }) as typeof fetch, + setTimer: cb => { timers.push(cb); return cb; }, clearTimer() {}, + }); handles.push(handle); + timers.shift()?.(); await turn(); await turn(); expect(applies).toBe(1); + timers.shift()?.(); await turn(); await turn(); expect(applies).toBe(1); + timers.shift()?.(); await turn(); await turn(); expect(applies).toBe(2); + timers.shift()?.(); await turn(); await turn(); expect(applies).toBe(3); +}); + +test("a changed digest is applied even when the Home reports a timeout", async () => { + const timers: Array<() => void> = []; + const changed = structuredClone(document); + changed.clients.grok.enabled = !changed.clients.grok.enabled; + let reads = 0, applies = 0; + const handle = startRouteIntentWatch(connection, "key", { + requestSync: async () => { applies++; }, + fetchImpl: (async () => Response.json(reads++ === 0 ? document : changed, + { headers: { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-wake": "timeout" } })) as typeof fetch, + setTimer: cb => { timers.push(cb); return cb; }, clearTimer() {}, + }); handles.push(handle); + timers.shift()?.(); await turn(); await turn(); expect(applies).toBe(1); + timers.shift()?.(); await turn(); await turn(); expect(applies).toBe(2); +}); + +test("watch keeps reading while apply busy, coalesces wakeups and retries without overlapping local writes", async () => { + const timers: Array<() => void> = []; + const releases: Array<() => void> = []; + let reads = 0, active = 0, maximum = 0, applies = 0; + let finalSignal: AbortSignal | null | undefined; + const handle = startRouteIntentWatch(connection, "key", { + requestSync: async () => { applies++; maximum = Math.max(maximum, ++active); await new Promise(r => releases.push(r)); active--; }, + fetchImpl: (async (_url, init) => { reads++; if (reads <= 3) return reply(); + finalSignal = init?.signal; + return new Promise((_r, reject) => init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true })); + }) as typeof fetch, + setTimer: cb => { timers.push(cb); return cb; }, clearTimer() {}, + }); handles.push(handle); + // A duplicate runtime registration cannot create a second long-poll for the same link. + expect(startRouteIntentWatch(connection, "key", { requestSync: async () => { throw new Error("duplicate"); } })).toBe(handle); + for (let i = 0; i < 4; i++) { timers.shift()?.(); await turn(); } + expect(reads).toBe(4); expect(applies).toBe(1); + releases.shift()?.(); await turn(); + expect(applies).toBe(2); expect(maximum).toBe(1); + releases.shift()?.(); await turn(); expect(applies).toBe(2); + handle.stop(); await turn(); expect(finalSignal?.aborted).toBe(true); +}); + +test("failed apply retains the event for a bounded retry", async () => { + const timers: Array<{ cb: () => void; ms: number }> = []; + let calls = 0; + const handle = startRouteIntentWatch(connection, "key", { requestSync: async () => { if (++calls === 1) throw new Error("client_lifecycle_busy"); }, + fetchImpl: (async () => reply()) as typeof fetch, + setTimer: (cb, ms) => { const timer = { cb, ms }; timers.push(timer); return timer; }, clearTimer() {} }); handles.push(handle); + timers.shift()?.cb(); await turn(); + expect(calls).toBe(1); + const retry = timers.find(timer => timer.ms === 5000); expect(retry).toBeDefined(); + retry?.cb(); await turn(); expect(calls).toBe(2); +}); + +test("production watch proves the actual socket before sending a key and never falls back to ordinary fetch", async () => { + const key = "test-watch-admission-key"; + const seen: Array<{ port: number; path: string; auth: string | null }> = []; + const sessions = createLinkRelaySessions({ fingerprints: () => [serviceApiTokenFingerprint(key)] }); + const home = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (req, server) => { + seen.push({ port: server.requestIP(req)!.port, path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return sessions.dispatch(req, server, async () => Response.json(document, + { headers: { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-wake": "timeout" } })); + } }); + try { + const bound = { ...connection, link: { ...connection.link!, tunnelPort: home.port! } }; + expect(await watchHomeRouteIntent(bound, key)).toMatchObject({ kind: "fresh", watchSupported: true, wakeReason: "timeout" }); + expect(seen).toHaveLength(2); expect(seen[0]!.port).toBe(seen[1]!.port); + expect(seen[0]!.auth).toBeNull(); expect(seen[1]!.auth).toBe(`Bearer ${key}`); + expect(seen[1]!.path).toBe("/v1/link-management/route-intent"); + } finally { await home.stop(true); } + let attempts = 0; + const impostor = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + attempts++; expect(req.headers.has("authorization")).toBe(false); return reply(); + } }); + try { + expect((await watchHomeRouteIntent({ ...connection, link: { ...connection.link!, tunnelPort: impostor.port! } }, key)).kind).toBe("failed"); + expect(attempts).toBe(1); + } finally { await impostor.stop(true); } +}); + +test("failed polls back off and immediately answering same-digest peers cannot tight-loop", async () => { + for (const good of [false, true]) { + const timers: Array<{ cb: () => void; ms: number }> = []; + const handle = startRouteIntentWatch(connection, "key", { requestSync: async () => {}, + fetchImpl: (async () => good ? reply() : new Response(null, { status: 503 })) as typeof fetch, + setTimer: (cb, ms) => { const timer = { cb, ms }; timers.push(timer); return timer; }, clearTimer() {} }); handles.push(handle); + timers.shift()?.cb(); await turn(); + if (good) { expect(timers[0]?.ms).toBe(0); timers.shift()?.cb(); await turn(); expect(timers[0]!.ms).toBeGreaterThan(0); } + else expect(timers[0]?.ms).toBe(5000); + handle.stop(); + } +}); + +test("runtime resolves a fresh key per GET and skips missing credentials without changing its pinned owner", async () => { + let key: string | null = "before-rotation"; + const timers: Array<() => void> = []; + const authorizations: Array = []; + const owner = structuredClone(connection); + const handle = startRouteIntentWatch(owner, () => key, { requestSync: async () => {}, + fetchImpl: (async (url, init) => { + expect(String(url)).toBe("http://127.0.0.1:19200/v1/link-management/route-intent"); + authorizations.push(new Headers(init?.headers).get("authorization")); return reply(); + }) as typeof fetch, + setTimer: cb => { timers.push(cb); return cb; }, clearTimer() {} }); handles.push(handle); + timers.shift()?.(); await turn(); + owner.link!.tunnelPort = 19999; owner.apiKeyId = "different-owner"; + key = null; timers.shift()?.(); await turn(); + expect(authorizations).toEqual(["Bearer before-rotation"]); + key = "after-rotation"; timers.shift()?.(); await turn(); + expect(authorizations).toEqual(["Bearer before-rotation", "Bearer after-rotation"]); + expect(await watchHomeRouteIntent(connection, () => { throw new Error("secret-key-error"); })).toEqual({ kind: "failed", reason: "route_intent_watch_unavailable" }); +}); diff --git a/tests/clients/route-intent.test.ts b/tests/clients/route-intent.test.ts new file mode 100644 index 00000000000..482c61ce4d0 --- /dev/null +++ b/tests/clients/route-intent.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { EXPORT_CLIENT_IDS } from "../../src/clients/client-ids"; +import { parseHomeRouteIntentDocument, projectHomeRouteIntent, ROUTE_CLIENT_IDS, routeIntentDigest } from "../../src/link/route-intent"; + +const config = {} as never; +describe("Home route intent contract", () => { + test("all routable clients with builtin defaults; policy precedence is device > default > builtin", () => { + const base = projectHomeRouteIntent(config, "test"); + expect(ROUTE_CLIENT_IDS).toEqual(["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS]); + expect(base.clients.codex).toEqual({ enabled: true }); + expect(base.clients.claude).toEqual({ enabled: true }); + expect(base.clients.aside).toEqual({ enabled: false }); + const state = { version: 1 as const, defaults: { aside: { enabled: true, accounts: "all" as const } }, devices: { child: { aside: { enabled: true, accounts: ["user-b"] } } } }; + const home = projectHomeRouteIntent(config, "test", undefined, state); + const child = projectHomeRouteIntent(config, "test", "child", state); + expect(home.clients.aside).toEqual({ enabled: true, accounts: "all" }); + expect(home.sources.aside).toBe("default"); + expect(child.clients.aside).toEqual({ enabled: true, accounts: ["user-b"] }); + expect(child.sources.aside).toBe("device"); + expect(child.sources.codex).toBe("builtin"); + }); + test("strict parser rejects paths, ports, secrets, slots, unknown or missing clients and unsafe labels", () => { + const doc = projectHomeRouteIntent(config, "test"); + expect(parseHomeRouteIntentDocument(doc)).toEqual(doc); + for (const extra of [{ path: "/tmp/client" }, { apiKey: "secret" }, { port: 10100 }, { profileId: "u0" }]) { + expect(parseHomeRouteIntentDocument({ ...doc, ...extra })).toBeNull(); + expect(parseHomeRouteIntentDocument({ ...doc, clients: { ...doc.clients, aside: { enabled: true, ...extra } } })).toBeNull(); + } + for (const accounts of [["u/0"], ["../escape"], ["/tmp/path"], ["user", "user"], ["user\n"]]) { + expect(parseHomeRouteIntentDocument({ ...doc, clients: { ...doc.clients, aside: { enabled: true, accounts } } })).toBeNull(); + } + const { codex: _, ...missing } = doc.clients; + expect(parseHomeRouteIntentDocument({ ...doc, clients: missing })).toBeNull(); + expect(parseHomeRouteIntentDocument({ ...doc, clients: { ...doc.clients, unknown: { enabled: true } } })).toBeNull(); + expect(parseHomeRouteIntentDocument({ ...doc, home: { name: "bad\nname", version: "test" } })).toBeNull(); + expect(parseHomeRouteIntentDocument({ ...doc, clients: { ...doc.clients, pi: { enabled: true, accounts: "all" } } })).toBeNull(); + }); + test("digest canonically covers policies and sources, ignores labels and account ordering", () => { + const doc = projectHomeRouteIntent(config, "test"); + // Independently computed using Python hashlib/json over the documented wire order. + expect(routeIntentDigest(doc)).toBe("afcea5dd5c7c45071fe8c1620c98985cd460475ebc986e8cefe3df9a4f2d2dd7"); + doc.clients.aside = { enabled: true, modelNames: "alias", accounts: ["user-b", "user-a"] }; + const reordered = { ...doc, home: { name: "renamed", version: "new" }, clients: Object.fromEntries(Object.entries(doc.clients).reverse()) }; + reordered.clients.aside = { accounts: ["user-a", "user-b"], modelNames: "alias", enabled: true }; + expect(routeIntentDigest(reordered as typeof doc)).toBe(routeIntentDigest(doc)); + expect(routeIntentDigest({ ...doc, sources: { ...doc.sources, aside: "device" } })).not.toBe(routeIntentDigest(doc)); + expect(routeIntentDigest({ ...doc, clients: { ...doc.clients, aside: { enabled: false } } })).not.toBe(routeIntentDigest(doc)); + }); +}); + +test("device switch inherits unspecified Home account and model-name policy", () => { + const state = { version: 1 as const, defaults: { aside: { enabled: true, accounts: "all" as const, modelNames: "alias" as const } }, devices: { child: { aside: { enabled: false } } } }; + expect(projectHomeRouteIntent(config, "test", "child", state).clients.aside).toEqual({ enabled: false, accounts: "all", modelNames: "alias" }); +}); diff --git a/tests/codex-integration/codex-disconnect-fields.test.ts b/tests/codex-integration/codex-disconnect-fields.test.ts new file mode 100644 index 00000000000..ceb88adf80d --- /dev/null +++ b/tests/codex-integration/codex-disconnect-fields.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { captureJournalConfigFields, restoreJournalConfigFields } from "../../src/codex/journal-config-restore"; +import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../../src/codex/subagent-defaults"; + +const url = "http://127.0.0.1:10100/v1"; +const ownership = { injectedOpenaiBaseUrl: url, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null }; +const original = 'model = "before"\nmodel_provider = "openai"\n'; +const injected = `openai_base_url = "${url}"\nmodel = "before"\n[model_providers.opencodex]\nname = "OpenCodex"\nbase_url = "${url}"\n`; +const journal = { ...ownership, injectedConfigFields: captureJournalConfigFields(injected, ownership) }; + +test("recorded blocks restore independently of edited preferences and opaque multiline data", () => { + const edits = '\n[hooks]\nnotify = """\n[model_providers.opencodex]\nopenai_base_url = "not a key"\n"""\n'; + const result = restoreJournalConfigFields(injected.replace('model = "before"', 'model = "provider/user-edit"') + edits, original, journal); + expect(result).toContain('model = "provider/user-edit"'); + expect(result).toContain('model_provider = "openai"'); + expect(result).toContain(edits.trimStart()); + expect(result).not.toContain('name = "OpenCodex"'); + expect(result).not.toContain(url); +}); + +test("a modified owned block refuses all writes", () => { + expect(restoreJournalConfigFields(injected.replace('name = "OpenCodex"', 'name = "Edited"'), original, journal)).toBeNull(); +}); + +test("quoted keys and cosmetic comments compare by value", () => { + const current = injected.replace('openai_base_url =', "'openai_base_url' =").replace('name = "OpenCodex"', "name='OpenCodex' # cosmetic"); + const result = restoreJournalConfigFields(current, original, journal); + expect(result).not.toBeNull(); + expect(result).not.toContain(url); +}); + +test("legacy records without per-key evidence cannot guess ownership from comments", () => { + expect(restoreJournalConfigFields(injected, original, {})).toBeNull(); +}); + +test("a model edited before reinjection never becomes cleanup-owned", () => { + const edited = injected.replace('model = "before"', 'model = "provider/user-edit"'); + const refreshed = { ...ownership, injectedConfigFields: captureJournalConfigFields(edited, ownership) }; + expect(restoreJournalConfigFields(edited, original, refreshed)).toContain('model = "provider/user-edit"'); +}); + +test("an unchanged user web-search override is not captured as injection-owned", () => { + const edited = injected.replace('model = "before"', 'model = "before"\nweb_search = "live"'); + const refreshed = { ...ownership, injectedConfigFields: captureJournalConfigFields(edited, ownership) }; + expect(restoreJournalConfigFields(edited, original, refreshed)).toContain('web_search = "live"'); +}); + +test("already removed owned keys stay absent; duplicated owned keys refuse", () => { + const removed = restoreJournalConfigFields(injected.replace(`openai_base_url = "${url}"\n`, ""), original, journal); + expect(removed).not.toBeNull(); + expect(removed).not.toContain("openai_base_url"); + expect(restoreJournalConfigFields(`openai_base_url = "${url}"\n` + injected, original, journal)).toBeNull(); +}); + +test("restoring a removed original block does not swallow unrelated root settings", () => { + const baseline = 'model = "before"\n[profiles.opencodex]\nmodel = "native"\n'; + const routed = `openai_base_url = "${url}"\nmodel = "before"\n`; + const snapshot = { ...ownership, injectedConfigFields: captureJournalConfigFields(routed, ownership) }; + const result = restoreJournalConfigFields(routed + 'approval_policy = "never"\n', baseline, snapshot)!; + expect(Bun.TOML.parse(result)).toEqual({ model: "before", approval_policy: "never", profiles: { opencodex: { model: "native" } } }); +}); + +test("managed defaults remove their generated empty table while user extensions survive", () => { + const defaults = `${MANAGED_AGENTS_TABLE_MARKER}\n[agents]\n${MANAGED_SUBAGENT_DEFAULT_MARKER}\ndefault_subagent_model = "managed-model"\n`; + const record = { ...journal, injectedSubagentDefaults: { default_subagent_model: "managed-model" } }; + expect(restoreJournalConfigFields(injected + defaults, original, record)).not.toContain("[agents]"); + const extended = restoreJournalConfigFields(injected + defaults + 'max_threads = 7\n', original, record)!; + expect(extended).toContain("[agents]\nmax_threads = 7"); + expect(extended).not.toContain("Managed by opencodex"); +}); + +test("a user-edited subagent default survives with its stale ownership marker removed", () => { + const defaults = `${MANAGED_AGENTS_TABLE_MARKER}\n[agents]\n${MANAGED_SUBAGENT_DEFAULT_MARKER}\ndefault_subagent_model = "user-edit"\n`; + const record = { ...journal, injectedSubagentDefaults: { default_subagent_model: "managed-model" } }; + const result = restoreJournalConfigFields(injected + defaults, original, record)!; + expect(result).toContain('default_subagent_model = "user-edit"'); + expect(result).not.toContain("Managed by opencodex"); +}); diff --git a/tests/codex-integration/codex-disconnect-owned-recovery.test.ts b/tests/codex-integration/codex-disconnect-owned-recovery.test.ts new file mode 100644 index 00000000000..121a6126720 --- /dev/null +++ b/tests/codex-integration/codex-disconnect-owned-recovery.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { captureJournalConfigFields, restoreJournalConfigFields } from "../../src/codex/journal-config-restore"; + +const url = "http://127.0.0.1:10100/v1"; +const ownership = { injectedOpenaiBaseUrl: url, injectedRealtimeWsBaseUrl: url, injectedCatalogPath: "/fixture/catalog.json" }; +const original = 'model = "before"\nopenai_base_url = "https://native.example/v1"\n'; +const injected = `openai_base_url = "${url}"\nexperimental_realtime_ws_base_url = "${url}"\nmodel_catalog_json = "/fixture/catalog.json"\nmodel = "before"\n[model_providers.opencodex]\nname = "OCX"\nbase_url = "${url}"\n`; +const journal = { ...ownership, injectedConfigFields: captureJournalConfigFields(injected, ownership) }; + +test("explicit recovery restores only recorded routing fields and blocks, preserving unrelated bytes", () => { + const edits = 'model = "user/model"\nweb_search = "live"\n'; + const suffix = '[hooks]\nnotify = ["hook"]\n[projects.\'/fixture/work\']\ntrust_level = "trusted"\n'; + const current = injected.replaceAll(url, "https://edited.example/v1") + .replace('model = "before"\n', edits).replace('/fixture/catalog.json', '/user/catalog.json') + suffix; + expect(restoreJournalConfigFields(current, original, journal)).toBeNull(); + expect(restoreJournalConfigFields(current, original, journal, true)).toBe('openai_base_url = "https://native.example/v1"\n' + edits + suffix); +}); + +test("one-operation force never claims an unrecorded routing key by URL resemblance", () => { + const current = 'openai_base_url = "http://127.0.0.1:10100/v1"\nmodel = "user"\n'; + expect(restoreJournalConfigFields(current, original, {}, true)).toBeNull(); +}); + +test("marker text inside multiline data cannot authorize legacy cleanup", () => { + const current = 'note = """\n# Auto-injected by opencodex\n"""\nopenai_base_url = "https://edited.example/v1"\n'; + expect(restoreJournalConfigFields(current, original, {}, true)).toBeNull(); +}); + +test("legacy explicit recovery recognizes exactly marked blocks, preserving neighboring tables", () => { + const current = '# Auto-injected by opencodex\n[model_providers.opencodex]\nbase_url = "https://edited.example/v1"\n[hooks]\nnotify = ["user"]\n'; + expect(restoreJournalConfigFields(current, '', {}, true)).toBe('[hooks]\nnotify = ["user"]\n'); + expect(restoreJournalConfigFields(current.replace('# Auto-injected by opencodex', '# user mentions Auto-injected by opencodex'), '', {}, true)).toBeNull(); +}); + +test("unsupported nested and duplicated owned tables refuse rather than stripping a partial block", () => { + const child = injected + '[model_providers.opencodex.http_headers]\ncustom = "user"\n'; + expect(restoreJournalConfigFields(child, original, journal, true)).toBeNull(); + expect(restoreJournalConfigFields(injected + '[model_providers.opencodex]\nname = "duplicate"\n', original, journal, true)).toBeNull(); +}); + +test("recovery preserves the user's changed web-search preference", () => { + const before = 'web_search = "cached"\n'; + const record = {injectedRootWebSearch: "disabled", injectedConfigFields: {web_search: 'web_search = "disabled"'}}; + expect(restoreJournalConfigFields('web_search = "live"\n', before, record, true)).toBe('web_search = "live"\n'); +}); + +test("consecutive marked owned blocks are both removed without consuming the next block's marker", () => { + const current = '# Auto-injected by opencodex\n[model_providers.opencodex]\nbase_url = "https://edited.example/v1"\n' + + '# Auto-injected by opencodex\n[profiles.opencodex]\nmodel_provider = "opencodex"\n[hooks]\nnotify = ["user"]\n'; + expect(restoreJournalConfigFields(current, '', {}, true)).toBe('[hooks]\nnotify = ["user"]\n'); +}); diff --git a/tests/codex-integration/codex-journal-provider-reserialization.test.ts b/tests/codex-integration/codex-journal-provider-reserialization.test.ts new file mode 100644 index 00000000000..207bc645310 --- /dev/null +++ b/tests/codex-integration/codex-journal-provider-reserialization.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { captureJournalConfigFields, restoreJournalConfigFields } from "../../src/codex/journal-config-restore"; + +const original = 'model = "native"\n'; +const injected = 'model_provider = "opencodex"\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:10100/v1"\n'; +const ownership = { injectedConfigFields: captureJournalConfigFields(injected, {}) }; + +for (const [name, representation] of Object.entries({ + inline: 'model_providers = { opencodex = { base_url = "http://127.0.0.1:10100/v1" } }\n', + dotted: 'model_providers.opencodex.base_url = "http://127.0.0.1:10100/v1"\n', + quoted: '"model_providers"."opencodex".base_url = "http://127.0.0.1:10100/v1"\n', + parent: '[model_providers]\nopencodex = { base_url = "http://127.0.0.1:10100/v1" }\n', + parentDotted: '[model_providers]\nopencodex.base_url = "http://127.0.0.1:10100/v1"\n', + profile: 'profiles = { opencodex = { model_provider = "opencodex" } }\n', +})) { + test(`reserialized ${name} owned table refuses field restoration`, () => { + const current = 'model_provider = "opencodex"\nmodel = "user-edited"\n' + representation; + expect(restoreJournalConfigFields(current, original, ownership)).toBeNull(); + expect(restoreJournalConfigFields(current, original, ownership, true)).toBeNull(); + }); +} + +test("unrelated dotted providers and header-shaped multiline values retain their bytes", () => { + const user = 'model_providers.other.base_url = "https://other.example/v1"\nnotes = """\nmodel_providers.opencodex.base_url = "data"\n"""\n'; + const current = 'model_provider = "opencodex"\n' + user + injected.slice(injected.indexOf("[")); + expect(restoreJournalConfigFields(current, original, ownership)).toBe(user); +}); + +test("wide integers in unrelated dotted provider settings remain opaque", () => { + const user = 'model_providers.other.counter = 9223372036854775807\n'; + const current = 'model_provider = "opencodex"\n' + user + injected.slice(injected.indexOf("[")); + expect(restoreJournalConfigFields(current, original, ownership)).toBe(user); +}); + +test.each(["inline", "dotted"])("%s provider refusal preserves the actual journal and config", representation => { + const root = mkdtempSync(join(tmpdir(), "ocx-journal-reserialized-")); + const codex = join(root, "codex"), ocx = join(root, "ocx"); + mkdirSync(codex); mkdirSync(ocx); + writeFileSync(join(codex, "config.toml"), original); + const current = 'model_provider = "opencodex"\n' + (representation === "inline" + ? 'model_providers = { opencodex = { base_url = "http://127.0.0.1:10100/v1" } }\n' + : 'model_providers.opencodex.base_url = "http://127.0.0.1:10100/v1"\n'); + const script = ` + const {readFileSync,writeFileSync}=require("node:fs"); + const {writeJournal,markJournalInjectedState,restoreJournalState}=require("./src/codex/journal"); + const c=process.env.CODEX_HOME; + writeJournal({currentStateIsNative:true,owner:{kind:"client",apiKeyId:"fixture"}}); + writeFileSync(c+"/config.toml",${JSON.stringify(injected)}); + markJournalInjectedState(${JSON.stringify(injected)},null,{ + injectedOpenaiBaseUrl:null,injectedRealtimeWsBaseUrl:null,injectedCatalogPath:null}); + const before=readFileSync(c+"/opencodex-journal.json","utf8"); + writeFileSync(c+"/config.toml",${JSON.stringify(current)}); + const result=restoreJournalState({preserveConfigEdits:true}); + console.log(JSON.stringify({result,config:readFileSync(c+"/config.toml","utf8"), + retained:readFileSync(c+"/opencodex-journal.json","utf8")===before})); + `; + try { + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot(), encoding: "utf8", timeout: 30_000, + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: ocx, CODEX_HOME: codex, CODEX_SQLITE_HOME: codex }, + }); + expect(child.status, child.stderr).toBe(0); + const result = JSON.parse(child.stdout.trim()); + expect(result.result.complete).toBe(false); + expect(result.result.configRewritten).toBe(false); + expect(result.config).toBe(current); + expect(result.retained).toBe(true); + } finally { removeTreeWithRetry(root); } +}); diff --git a/tests/codex-integration/native-claude-code-toggle.test.ts b/tests/codex-integration/native-claude-code-toggle.test.ts index e0ae5dffb34..ac45de6a116 100644 --- a/tests/codex-integration/native-claude-code-toggle.test.ts +++ b/tests/codex-integration/native-claude-code-toggle.test.ts @@ -217,6 +217,8 @@ test("a held REAL config transaction refuses 409 config_busy, and release lets a const refusedBody = await refused!.json() as { code: string; reason: string }; expect(refusedBody.code).toBe("native_integration_refused"); expect(refusedBody.reason).toBe("config_busy"); + const { existsSync } = await import("node:fs"); + expect(existsSync(join(fixtureRoot, "route-intent.json"))).toBe(false); holder.exec("ROLLBACK"); holder.close(); diff --git a/tests/codex-integration/native-codex-toggle.test.ts b/tests/codex-integration/native-codex-toggle.test.ts index fb45ac311c5..3ab49309175 100644 --- a/tests/codex-integration/native-codex-toggle.test.ts +++ b/tests/codex-integration/native-codex-toggle.test.ts @@ -20,6 +20,8 @@ import { handleManagementAPI } from "../../src/server/management-api"; import type { ManagementApiDeps } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; import * as serviceManagerProbe from "../../src/service-manager-probe"; +import * as codexSync from "../../src/codex/sync"; +import { injectCodexConfig } from "../../src/codex/inject"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let fixtureRoot = ""; @@ -27,6 +29,9 @@ let codexHome = ""; let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; let restoreServiceManagerProbe = () => {}; +let restoreSyncDiscovery = () => {}; +let catalogReads = 0; +const realSyncModelsToCodex = codexSync.syncModelsToCodex; const cleanup: string[] = []; function baseConfig(): OcxConfig { @@ -75,6 +80,19 @@ function persistedCodexIntent(): unknown { } beforeEach(() => { + catalogReads = 0; + // This route test exercises real gate/injection/restore, with catalog discovery owned by + // the fixture. The native route's standalone sync does not consume fetchAllModels above. + const sync = spyOn(codexSync, "syncModelsToCodex").mockImplementation((port, config, log) => + realSyncModelsToCodex(port, config, log, { + injectCodexConfig, + refreshCodexModelCatalog: async () => { + catalogReads++; + return { added: 0, path: "", catalogExists: false, catalogWritten: false, cacheSynced: false } as never; + }, + refreshReasoningMetadata: async () => {}, + })); + restoreSyncDiscovery = () => sync.mockRestore(); // The route fixture owns no service, and HOME cannot redirect launchctl/systemctl. // Without this the host's own opencodex registration decides the case. const probe = spyOn(serviceManagerProbe, "inspectServiceManagerInstallation") @@ -100,6 +118,7 @@ beforeEach(() => { }); afterEach(() => { + restoreSyncDiscovery(); restoreServiceManagerProbe(); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; @@ -168,6 +187,7 @@ describe("turning Codex off", () => { const serverConfig = baseConfig(); await put(serverConfig, { enabled: false }); await put(serverConfig, { enabled: true }); + expect(catalogReads).toBe(1); expect(persistedCodexIntent()).not.toBe(false); const response = await dispatch(serverConfig, "/api/native-integrations"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 58e03d7dd46..04b709c524b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,9 @@ -{ "responses-websocket-workflow-ceiling.test.ts": "responses", "responses-workflow-send-ceiling.test.ts": "responses", "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "lmstudio-system-policy.test.ts": "config", "openai-chat-single-system.test.ts": "adapters/openai", "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", +{"route-intent-file-originals.test.ts":"clients","route-to-child-defaults.test.ts":"routing","route-to-home-live-flags.test.ts":"routing","route-to-home-native-truth.test.ts":"routing","route-to-home-decisions.test.ts":"routing","route-to-ephemeral.test.ts":"server","link-pinned-sync-overwrite.test.ts":"clients","cli-sync-fanout-ephemeral.test.ts":"cli","route-intent-home-consent.test.ts":"clients","management-route-intent-native-errors.test.ts":"server","route-intent-offline-mode-switch.test.ts":"clients","route-intent-parity-withdrawal.test.ts":"clients","service-scheduler-restart-cleanup.test.ts":"service","route-to-retirement-review.test.ts":"server","route-to-policy-review.test.ts":"server","management-route-intent-native-review.test.ts":"server","route-to-home-off-review.test.ts":"routing","codex-journal-provider-reserialization.test.ts":"codex-integration","route-intent-parity-fallback.test.ts":"clients","route-intent-native-originals.test.ts":"clients","route-intent-native-review.test.ts":"clients","route-intent-connected-review.test.ts":"clients","route-intent-connect-recovery.test.ts":"clients","client-disconnect-field-snapshot.test.ts":"clients","cli-route-to-confirmation.test.ts":"cli","route-intent-runtime-stop.test.ts":"clients","route-intent-codex-connect-disabled.test.ts":"clients","route-intent-codex-transitions.test.ts":"clients", "route-to-live-settings.test.ts": "server", + "route-intent-claude-surface.test.ts": "clients", "route-intent-local-receipt.test.ts": "clients", "route-intent-scheduler.test.ts": "clients", "route-intent-watch-client.test.ts": "clients", "route-intent-watch-boundary.test.ts": "server", "route-intent-watch-waiters.test.ts": "server", + "home-route-intent-upgrade.test.ts": "clients", "route-intent-overwrite.test.ts": "clients", "codex-disconnect-owned-recovery.test.ts": "codex-integration", "service-restart-registration-guard.test.ts": "service", + "client-disconnect-config-recovery.test.ts": "clients", "route-intent-aside.test.ts": "clients", "route-intent-connected.test.ts": "clients", + "cli-link-route-intent.test.ts": "cli", "cli-route-to-aliases.test.ts": "cli", "cli-route-to.test.ts": "cli", "client-disconnect-edited-codex.test.ts": "clients", "codex-disconnect-fields.test.ts": "codex-integration", "link-home-route-intent.test.ts": "clients", "link-route-intent.test.ts": "server", "route-intent-desktop.test.ts": "clients", "route-intent-materializer.test.ts": "clients", "route-intent-parity.test.ts": "clients", "route-intent-reporter.test.ts": "clients", "route-intent-state.test.ts": "clients", "route-intent.test.ts": "clients", "route-to-routes.test.ts": "server", "route-to-sync-child.test.ts": "routing", "service-disconnected-client-restart.test.ts": "service", + "responses-websocket-workflow-ceiling.test.ts": "responses", "responses-workflow-send-ceiling.test.ts": "responses", "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "lmstudio-system-policy.test.ts": "config", "openai-chat-single-system.test.ts": "adapters/openai", "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "codex-installation-live-content.test.ts": "codex-integration", "codex-installation-invalid-account.test.ts": "codex-integration", "codex-installation-sidecars.test.ts": "codex-integration", diff --git a/tests/helpers/cli-capability-data.ts b/tests/helpers/cli-capability-data.ts index 5275556206b..7b68124186b 100644 --- a/tests/helpers/cli-capability-data.ts +++ b/tests/helpers/cli-capability-data.ts @@ -8,6 +8,7 @@ export const CAPABILITY_DATA_FILES = [ "capabilities-provider-models.ts", "capabilities-accounts.ts", "capabilities-agents-routing.ts", + "capabilities-route-to.ts", "capabilities-integrations.ts", "capabilities-observe-system.ts", "capabilities-access-remote.ts", @@ -23,6 +24,7 @@ const ROLES: Readonly> = { "import:value:capabilities-provider-models.ts", "import:value:capabilities-accounts.ts", "import:value:capabilities-agents-routing.ts", + "import:value:capabilities-route-to.ts", "import:value:capabilities-integrations.ts", "import:value:capabilities-observe-system.ts", "import:value:capabilities-access-remote.ts", @@ -34,6 +36,7 @@ const ROLES: Readonly> = { "capabilities-provider-models.ts": ["import:type:capability-types.ts"], "capabilities-accounts.ts": ["import:type:capability-types.ts"], "capabilities-agents-routing.ts": ["import:type:capability-types.ts"], + "capabilities-route-to.ts": ["import:type:capability-types.ts"], "capabilities-integrations.ts": ["import:type:capability-types.ts"], "capabilities-observe-system.ts": ["import:type:capability-types.ts"], "capabilities-access-remote.ts": ["import:type:capability-types.ts"], diff --git a/tests/providers/zcode-client.test.ts b/tests/providers/zcode-client.test.ts index 86e3e3a27fc..ec18895efa6 100644 --- a/tests/providers/zcode-client.test.ts +++ b/tests/providers/zcode-client.test.ts @@ -250,7 +250,7 @@ describe("ZCode provider store", () => { }); describe("ocx zcode CLI alias", () => { - /** Captures the client-integration requests the alias forwards. */ + /** Captures canonical intent writes and legacy status/history requests. */ function fakeRuntime(): { deps: { baseUrl: string; fetchImpl: typeof fetch }; requests: Array<{ path: string; method: string; body: unknown }> } { const requests: Array<{ path: string; method: string; body: unknown }> = []; const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { @@ -260,7 +260,10 @@ describe("ocx zcode CLI alias", () => { method: init?.method ?? "GET", body: typeof init?.body === "string" ? JSON.parse(init.body) : null, }); - return new Response(JSON.stringify({ ok: true, message: "done", clients: [] }), { + const payload = url.pathname === "/api/route-to" + ? { schema: "ocx.route-to/1", devices: [{ id: "home", clients: [{ client: "zcode", intent: "on", source: "default", applied: "current" }] }] } + : { ok: true, message: "done", clients: [] }; + return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" }, }); @@ -268,18 +271,25 @@ describe("ocx zcode CLI alias", () => { return { deps: { baseUrl: "http://127.0.0.1:10100", fetchImpl }, requests }; } - test("enable forwards to the client-integration route with --client zcode", async () => { + // A plain toggle is the Home's own decision, like the dashboard: it never writes the Children's default. + test("enable is a Home-only decision for zcode", async () => { const { deps, requests } = fakeRuntime(); const code = await handleZcodeCommand(["enable"], deps); expect(code).toBe(0); - expect(requests.some(r => r.path === "/api/client-integrations/zcode" && r.method === "PUT" && (r.body as { enabled?: boolean }).enabled === true)).toBe(true); + expect(requests).toEqual([{ path: "/api/client-integrations/zcode", method: "PUT", body: { enabled: true } }]); }); test("a flag before the verb does not swallow it", async () => { const { deps, requests } = fakeRuntime(); const code = await handleZcodeCommand(["--json", "enable"], deps); expect(code).toBe(0); - expect(requests.some(r => r.path === "/api/client-integrations/zcode" && r.method === "PUT")).toBe(true); + expect(requests).toEqual([{ path: "/api/client-integrations/zcode", method: "PUT", body: { enabled: true } }]); + }); + + test("disable is a Home-only decision for zcode", async () => { + const { deps, requests } = fakeRuntime(); + expect(await handleZcodeCommand(["disable", "--json"], deps)).toBe(0); + expect(requests).toEqual([{ path: "/api/client-integrations/zcode", method: "PUT", body: { enabled: false } }]); }); test("bare invocation reads status, never writes", async () => { diff --git a/tests/routing/route-to-child-defaults.test.ts b/tests/routing/route-to-child-defaults.test.ts new file mode 100644 index 00000000000..60e846d20c4 --- /dev/null +++ b/tests/routing/route-to-child-defaults.test.ts @@ -0,0 +1,250 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +/** + * Children inherit the Home's native switches as they stood when route intent began. Home-only + * writers (ocx restore, Messages off, the Claude toggle, Claude Desktop apply) change the Home's + * switch and never what a linked Child projects, before or after defaults were first saved. + */ +function scenario(body: string) { + const home = mkdtempSync(join(tmpdir(), "ocx-child-defaults-")); + for (const dir of [".codex", ".claude", "desktop"]) mkdirSync(join(home, dir)); + try { + const child = spawnSync(process.execPath, ["--eval", ` + import { expect } from 'bun:test'; + import { existsSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { setIntegrationEnabled } from './src/codex/desired-state'; + import { setHomeIntegrationEnabled } from './src/routing/home-route-capture'; + import { writeLinkStore } from './src/link/store'; + import { linkStorePath } from './src/link/paths'; + import { LINK_ROUTE_INTENT_PATH } from './src/link/management-routes'; + import { handleRouteToRoutes } from './src/server/management/route-to-routes'; + import { handleRouteToCommand } from './src/cli/route-to'; + import { handleProtocolRoutes } from './src/server/management/protocol-routes'; + import { handleAgentSettingsRoutes } from './src/server/management/agent-settings-routes'; + import { readLinkRouteIntent } from './src/server/index/route-intent-watch'; + import { createLinkListenerLifecycle } from './src/server/index/link-listener'; + import { readRouteIntentState, routeIntentStatePath } from './src/routing/route-intent-state'; + import { setPersistedConfigMutationBeforeCommitForTests } from './src/config/persisted-mutation'; + import { dispatchCommand } from './src/cli/dispatch'; + writeFileSync(join(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR,'_meta.json'),JSON.stringify({entries:[]})); + const local = getDefaultConfig(); local.port=18274; local.hostname='127.0.0.1'; + local.defaultProvider='fixture';local.providers={fixture:{adapter:'openai-chat',baseUrl:'http://127.0.0.1:1/v1',allowPrivateNetwork:true,liveModels:false,models:['fixture-model']}}; + saveConfig(local); + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [{ id: 'lnk_0123456789abcdef', alias: 'child', direction: 'client-initiated', + hostKeyFingerprint: null, tunnelPort: 19200, apiKeyId: 'key-test', createdAt: '2026-11-07T00:00:00.000Z' }] }); + const live = loadConfig(); + const management = async (handler, method, path, body) => { + const req = new Request('http://localhost'+path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const response = await handler({ req, url: new URL(req.url), config: live, version: 'test', deps: {}, syncClaudeAgentDefsBestEffort: async () => {} }); + return { status: response?.status, body: await response?.json() }; + }; + const call = (path, body) => management(handleRouteToRoutes, path.endsWith('/sync') ? 'POST' : 'PUT', path, body); + // What the Child reads over its link: the Home's route intent for that device. + const childIntent = async () => { + const response = await readLinkRouteIntent(new Request('http://127.0.0.1:19100'+LINK_ROUTE_INTENT_PATH), live, 'test', 'key-test'); + expect(response.status).toBe(200); + return (await response.json()).clients; + }; + // ocx route to ... as the operator runs it against this Home's management API. + const cli = async argv => { + const errors = [], log = console.log, error = console.error; + console.log = () => {}; console.error = (...args) => errors.push(args.join(' ')); + try { + const code = await handleRouteToCommand(argv, { readChildContext: () => null, + findLiveProxy: async () => ({ hostname: '127.0.0.1', port: 18274, pid: 1, source: 'runtime' }), + fetchImpl: async (input, init) => { const req = new Request(String(input), init); + return await handleRouteToRoutes({ req, url: new URL(req.url), config: live, version: 'test', deps: {} }); } }); + return { code, errors: errors.join(' | ') }; + } finally { console.log = log; console.error = error; } + }; + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + } finally { removeTreeWithRetry(home); } +} + +test("ocx restore on an upgraded linked Home with no saved route intent keeps Children on Codex", () => scenario(` + expect(existsSync(routeIntentStatePath())).toBe(false); + // ocx restore writes the switch through this. + expect(setHomeIntegrationEnabled('codex', false).ok).toBe(true); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect((await childIntent()).codex).toEqual({ enabled: true }); + // A later initialization captures nothing from the Home-only change. + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + expect(readRouteIntentState().defaults.codex).toEqual({ enabled: true }); + expect((await childIntent()).codex).toEqual({ enabled: true }); +`), SPAWN_BUDGET_MS); + +test("Messages off on a linked Home leaves Children on Claude Code", () => scenario(` + const patched = await management(handleProtocolRoutes, 'PATCH', '/api/protocols/settings', { messagesEnabled: false }); + expect(patched.status).toBe(200); + expect(loadConfig().claudeCode?.enabled).toBe(false); + expect((await childIntent()).claude).toEqual({ enabled: true }); + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + expect(readRouteIntentState().defaults.claude).toEqual({ enabled: true }); +`), SPAWN_BUDGET_MS); + +test("the Claude toggle on a linked Home leaves Children on Claude Code", () => scenario(` + const toggled = await management(handleAgentSettingsRoutes, 'PUT', '/api/claude-code', { enabled: false }); + expect(toggled.status).toBe(200); + expect(loadConfig().claudeCode?.enabled).toBe(false); + expect((await childIntent()).claude).toEqual({ enabled: true }); +`), SPAWN_BUDGET_MS); + +test("Claude Desktop on the Home never enables it on Children", () => scenario(` + // Defaults are first saved by an unrelated route write, with Desktop unset on the Home. + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + const { applyDesktop } = await import('./src/cli/claude-desktop'); + expect((await applyDesktop(undefined, { kind: 'gateway', mode: 'static' }, { findLiveProxyImpl: async () => null })).ok).toBe(true); + expect((await childIntent())['claude-desktop']).toEqual({ enabled: false }); + // Any writer that stores an explicit on for the Home's own switch. + const c = loadConfig(); c.clientIntegrations = { ...c.clientIntegrations, 'claude-desktop': true }; saveConfig(c); + expect((await childIntent())['claude-desktop']).toEqual({ enabled: false }); + // The Home decides Desktop for itself; it takes over the profile the CLI apply wrote above. + const homeOn = await call('/api/route-to', { client: 'claude-desktop', action: 'on', device: 'home', overwriteConflict: true }); + expect(homeOn.body.error).toBeUndefined(); + expect(homeOn.status).toBe(200); + expect((await call('/api/route-to', { client: 'opencode', action: 'off' })).status).toBe(200); + expect(readRouteIntentState().defaults['claude-desktop']).toEqual({ enabled: false }); + expect((await childIntent())['claude-desktop']).toEqual({ enabled: false }); +`), SPAWN_BUDGET_MS); + +test("Child reads write nothing; enrollment saves the defaults they project", () => scenario(` + const { seedRouteDeviceClients } = await import('./src/routing/home-route-intent'); + // The link route-intent read and the route-to status rows are pure reads. + expect((await childIntent()).codex).toEqual({ enabled: true }); + const status = await management(handleRouteToRoutes, 'GET', '/api/route-to?device=all'); + expect(status.status).toBe(200); + expect(existsSync(routeIntentStatePath())).toBe(false); + // Enrollment seeds the new Child before its first read, freezing the switches as they stand. + expect(setIntegrationEnabled('codex', false).ok).toBe(true); + seedRouteDeviceClients(loadConfig(), 'test', 'lnk_0123456789abcdef', undefined, ['lnk_0123456789abcdef']); + expect(readRouteIntentState().defaults).toMatchObject({ codex: { enabled: false }, claude: { enabled: true }, + grok: { enabled: false }, 'claude-desktop': { enabled: false } }); + expect(setIntegrationEnabled('codex', true).ok).toBe(true); + expect((await childIntent()).codex).toEqual({ enabled: false }); +`), SPAWN_BUDGET_MS); + +test("the link listener saves the defaults from the Home's switches before it serves", () => scenario(` + // Restored before this version ran: the switch at migration is what Children inherit. + expect(setIntegrationEnabled('codex', false).ok).toBe(true); + writeFileSync(routeIntentStatePath(), JSON.stringify({ version: 1, defaults: {}, devices: {} })); + const seen = []; + const lifecycle = createLinkListenerLifecycle({ warn: () => {}, serve: () => { + seen.push(readRouteIntentState().defaults.codex); + throw new Error('fixture listener stops here'); + } }); + lifecycle.start({ maxRequestBodySize: 1024, dispatch: async () => new Response('') }); + expect(seen).toEqual([{ enabled: false }]); + await lifecycle.stop(); + expect(setIntegrationEnabled('codex', true).ok).toBe(true); + expect((await childIntent()).codex).toEqual({ enabled: false }); +`), SPAWN_BUDGET_MS); + +const firstPartyHome = ` + const c = loadConfig(); c.claudeCode = { ...c.claudeCode, desktopMode: 'first-party' }; saveConfig(c); Object.assign(live, loadConfig()); +`; + +test("turning Desktop off on a first-party Home fails and names the mode switch", () => scenario(` + ${firstPartyHome} + for (const argv of [['claude-desktop', 'off'], ['claude-desktop', 'off', '--device', 'home']]) { + const result = await cli(argv); + expect(result.code).not.toBe(0); + expect(result.errors).toContain('ocx integrations native claude-desktop off'); + const response = await call('/api/route-to', { client: 'claude-desktop', action: 'off', ...(argv.length > 2 ? { device: 'home' } : {}) }); + expect(response.status).toBe(409); + expect(response.body.error).toContain('ocx integrations native claude-desktop off'); + expect(response.body.devices.find(row => row.id === 'home').clients.find(row => row.client === 'claude-desktop').applied).toBe('conflict'); + } + expect(loadConfig().claudeCode?.desktopMode).toBe('first-party'); +`), SPAWN_BUDGET_MS); + +test("a decided Home write whose native switch write throws fails", () => scenario(` + // Every native switch write in this apply fails; the hook disarms itself after each call. + let failing = true; + const fail = () => { if (!failing) return; setPersistedConfigMutationBeforeCommitForTests(fail); throw new Error('config storage unavailable'); }; + setPersistedConfigMutationBeforeCommitForTests(fail); + const result = await cli(['codex', 'off', '--device', 'home']); + failing = false; setPersistedConfigMutationBeforeCommitForTests(null); + expect(result.code).not.toBe(0); + expect(result.errors).toContain('did not apply codex (failed)'); + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); +`), SPAWN_BUDGET_MS); + +test("an unreadable route-intent.json refuses every Home-only switch writer with a typed refusal", () => scenario(` + writeFileSync(routeIntentStatePath(), '{not json'); + const refused = setHomeIntegrationEnabled('codex', false); + expect(refused).toMatchObject({ ok: false, reason: 'unavailable', retryable: false }); + expect(refused.message).toContain(routeIntentStatePath()); + expect(refused.message).toContain('fix or remove'); + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); + // ocx restore --json still prints exactly one complete envelope and exits nonzero. + const out = [], log = console.log, error = console.error; + console.log = (...args) => out.push(args.join(' ')); console.error = () => {}; + let code; + try { code = await dispatchCommand({ kind: 'run', command: 'restore', args: ['restore', '--json'] }, { args: ['restore', '--json'] }); } + finally { console.log = log; console.error = error; } + expect(code).toBe(1); + expect(out.length).toBe(1); + const envelope = JSON.parse(out[0]); + expect(envelope.success).toBe(false); + expect(envelope.message).toContain(routeIntentStatePath()); + expect(envelope.artifacts.config.state).toBe('skipped'); + const { applyDesktop } = await import('./src/cli/claude-desktop'); + const desktop = await applyDesktop(undefined, { kind: 'gateway', mode: 'static' }, { findLiveProxyImpl: async () => null }); + expect(desktop.ok).toBe(false); + expect(desktop.reason).toContain(routeIntentStatePath()); + const patched = await management(handleProtocolRoutes, 'PATCH', '/api/protocols/settings', { messagesEnabled: false }); + expect(patched.status).toBe(500); + expect(patched.body.error.code).toBe('route_intent_unavailable'); + expect(patched.body.error.message).toContain(routeIntentStatePath()); + const toggled = await management(handleAgentSettingsRoutes, 'PUT', '/api/claude-code', { enabled: false }); + expect(toggled.status).toBe(500); + expect(toggled.body.code).toBe('route_intent_unavailable'); + expect(toggled.body.error).toContain(routeIntentStatePath()); + // Nothing moved: what Children inherit after the repair is still the switches as they were. + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); + expect(loadConfig().claudeCode?.enabled).not.toBe(false); + expect(live.claudeCode?.enabled).not.toBe(false); +`), SPAWN_BUDGET_MS); + +test("unlinked Homes in the same end state give a later-enrolled Child the same defaults", () => scenario(` + const { rmSync } = await import('node:fs'); + const { handleNativeIntegrationRoutes } = await import('./src/server/management/native-integration-routes'); + const { seedRouteDeviceClients } = await import('./src/routing/home-route-intent'); + const link = { id: 'lnk_0123456789abcdef', alias: 'child', direction: 'client-initiated', hostKeyFingerprint: null, tunnelPort: 19200, apiKeyId: 'key-test', createdAt: '2026-11-07T00:00:00.000Z' }; + const inherited = []; + // Each Home-only way to turn Codex off on a Home with no link yet. + for (const turnOff of [ + async () => expect((await management(handleNativeIntegrationRoutes, 'PUT', '/api/native-integrations/codex', { enabled: false })).status).toBe(200), + async () => expect(setHomeIntegrationEnabled('codex', false).ok).toBe(true), + async () => expect((await call('/api/route-to', { client: 'codex', action: 'off', device: 'home' })).status).toBe(200), + ]) { + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [] }); + rmSync(routeIntentStatePath(), { force: true }); + const c = loadConfig(); c.clientIntegrations = {}; saveConfig(c); Object.assign(live, loadConfig()); + await turnOff(); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect(readRouteIntentState().defaults.codex).toBeUndefined(); + // Enrollment adds the link, then provisioning seeds the new Child's route intent. + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [link] }); + seedRouteDeviceClients(loadConfig(), 'test', link.id, undefined, [link.id]); + inherited.push((await childIntent()).codex); + } + // The switches as they stood at enrollment, whichever writer moved them. + expect(inherited).toEqual([{ enabled: false }, { enabled: false }, { enabled: false }]); +`), SPAWN_BUDGET_MS); diff --git a/tests/routing/route-to-home-decisions.test.ts b/tests/routing/route-to-home-decisions.test.ts new file mode 100644 index 00000000000..02f753f067f --- /dev/null +++ b/tests/routing/route-to-home-decisions.test.ts @@ -0,0 +1,193 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +/** + * Home-only integration writes under route intent: a dashboard toggle, a restore and an Aside + * profile change are the Home's decision. Children's defaults are saved before the write (also by + * the link listener's start on an upgraded Home), and the result is recorded so ocx sync keeps it. + */ +function scenario(body: string) { + const home = mkdtempSync(join(tmpdir(), "ocx-home-decisions-")); + for (const dir of [".codex", ".claude", "desktop"]) mkdirSync(join(home, dir)); + try { + const child = spawnSync(process.execPath, ["--eval", ` + import { expect } from 'bun:test'; + import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { writeLinkStore } from './src/link/store'; + import { linkStorePath } from './src/link/paths'; + import { LINK_ROUTE_INTENT_PATH } from './src/link/management-routes'; + import { handleRouteToRoutes } from './src/server/management/route-to-routes'; + import { handleIntegrationRoutes } from './src/server/management/integration-routes'; + import { readLinkRouteIntent } from './src/server/index/route-intent-watch'; + import { createLinkListenerLifecycle } from './src/server/index/link-listener'; + import { captureHomeRouteDefaults } from './src/routing/home-route-capture'; + import { readRouteIntentState, routeIntentStatePath } from './src/routing/route-intent-state'; + import { syncHomeRouteTo } from './src/routing/route-to-local'; + import { applyIntegration } from './src/integrations/writer'; + import { createIntegrationStateStore } from './src/integrations/store'; + import { INTEGRATION_CLIENTS } from './src/integrations/registry'; + writeFileSync(join(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR,'_meta.json'),JSON.stringify({entries:[]})); + const local = getDefaultConfig(); local.port=18274; local.hostname='127.0.0.1'; + local.defaultProvider='fixture';local.providers={fixture:{adapter:'openai-chat',baseUrl:'http://127.0.0.1:1/v1',allowPrivateNetwork:true,liveModels:false,models:['fixture-model']}}; + saveConfig(local); + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [{ id: 'lnk_0123456789abcdef', alias: 'child', direction: 'client-initiated', hostKeyFingerprint: null, tunnelPort: 19200, apiKeyId: 'key-test', createdAt: '2026-11-07T00:00:00.000Z' }] }); + const live = loadConfig(); + const management = async (handler, method, path, body) => { + const req = new Request('http://localhost'+path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const response = await handler({ req, url: new URL(req.url), config: live, version: 'test', deps: { readRuntimePort: () => null } }); + return { status: response?.status, body: await response?.json() }; + }; + const call = (path, body) => management(handleRouteToRoutes, path.endsWith('/sync') ? 'POST' : 'PUT', path, body); + const toggle = (client, enabled) => management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/'+client, { enabled }); + const childIntent = async () => { + const response = await readLinkRouteIntent(new Request('http://127.0.0.1:19100'+LINK_ROUTE_INTENT_PATH), live, 'test', 'key-test'); + expect(response.status).toBe(200); + return (await response.json()).clients; + }; + const store = createIntegrationStateStore(); + const piPath = INTEGRATION_CLIENTS.pi.configPath({}, process.env.HOME); + mkdirSync(INTEGRATION_CLIENTS.pi.detectDir({}, process.env.HOME), { recursive: true }); + const piRouted = () => !!store.readRecords().pi && existsSync(piPath) && readFileSync(piPath, 'utf8').includes('opencodex'); + const homeSync = async client => (await syncHomeRouteTo(loadConfig(), 'test', false, undefined, undefined, { loadModels: async () => [] })) + .find(row => row.client === client); + const asideRoot = join(process.env.HOME, '.aside'); + const asideModels = join(asideRoot, 'u', '0', 'models.json'); + const asideProfile = (userId) => { + mkdirSync(join(asideRoot, 'u', '0'), { recursive: true }); + writeFileSync(asideModels, JSON.stringify({ preferences: { userId }, providers: { personal: { apiKey: 'fixture-personal' } } })); + writeFileSync(join(asideRoot, 'accounts.json'), JSON.stringify({ currentAccountId: 0, accounts: [userId ? { id: 0, userId } : { id: 0 }] })); + }; + const asideRouted = () => !!JSON.parse(readFileSync(asideModels, 'utf8')).providers.opencodex; + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + } finally { removeTreeWithRetry(home); } +} + +test("on an upgraded linked Home a dashboard toggle leaves the Children's file defaults where they were", () => scenario(` + // Before this PR the Home saved only the four native defaults. + expect(captureHomeRouteDefaults(undefined, { whenLinked: true })).toBe(true); + expect(Object.keys(readRouteIntentState().defaults).sort()).toEqual(['claude', 'claude-desktop', 'codex', 'grok']); + expect((await childIntent()).pi).toEqual({ enabled: false }); + const on = await toggle('pi', true); + expect(on.status).toBe(200); + expect(piRouted()).toBe(true); + expect((await childIntent()).pi).toEqual({ enabled: false }); + expect(readRouteIntentState().defaults.pi).toEqual({ enabled: false }); + expect(readRouteIntentState().devices.home?.pi).toEqual({ enabled: true }); + expect((await homeSync('pi'))?.outcome).toBe('current'); + expect(piRouted()).toBe(true); + expect(readRouteIntentState().defaults.pi).toEqual({ enabled: false }); + expect((await childIntent()).pi).toEqual({ enabled: false }); +`), SPAWN_BUDGET_MS); + +test("the link listener's start saves every default a Child inherits, file integrations included", () => scenario(` + expect(applyIntegration({ clientId: 'pi', config: loadConfig(), port: 18274, models: [], store, home: process.env.HOME, env: process.env }).ok).toBe(true); + expect(captureHomeRouteDefaults(undefined, { whenLinked: true })).toBe(true); + expect(readRouteIntentState().defaults.pi).toBeUndefined(); + const listener = createLinkListenerLifecycle({ serve: () => ({ port: 19100, stop: async () => {} }), warn: () => {} }); + listener.start({ dispatch: async () => new Response(null), maxRequestBodySize: 1024 }); + expect(listener.status().state).toBe('listening'); + await listener.stop(); + expect(readRouteIntentState().defaults.pi).toEqual({ enabled: true }); + // A later Home-only toggle now cannot move the Child's default. + expect((await toggle('pi', false)).status).toBe(200); + expect((await childIntent()).pi).toEqual({ enabled: true }); + expect(readRouteIntentState().devices.home?.pi).toEqual({ enabled: false }); +`), SPAWN_BUDGET_MS); + +test("a dashboard restore is the Home's decision, so ocx sync does not reverse the undo", () => scenario(` + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + expect((await toggle('pi', true)).status).toBe(200); + expect(readRouteIntentState().devices.home?.pi).toEqual({ enabled: true }); + const opId = store.listOperations('pi', 1)[0].opId; + const restored = await management(handleIntegrationRoutes, 'POST', '/api/client-integrations/restore', { opId }); + expect(restored.status).toBe(200); + expect(piRouted()).toBe(false); + expect(readRouteIntentState().devices.home?.pi).toEqual({ enabled: false }); + expect((await homeSync('pi'))?.outcome).toBe('current'); + expect(piRouted()).toBe(false); +`), SPAWN_BUDGET_MS); + +test("a dashboard Aside profile toggle on a Home that uses route intent survives ocx sync", () => scenario(` + asideProfile('user-a'); + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + expect(readRouteIntentState().defaults.aside?.enabled).toBe(false); + const on = await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/aside/profiles/0', { enabled: true }); + expect(on.status).toBe(200); + expect(asideRouted()).toBe(true); + expect(readRouteIntentState().devices.home?.aside).toEqual({ enabled: true, accounts: ['user-a'] }); + expect((await homeSync('aside'))?.outcome).toBe('current'); + expect(asideRouted()).toBe(true); + // Children keep their default. + expect((await childIntent()).aside.enabled).toBe(false); + const off = await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/aside/profiles/0', { enabled: false }); + expect(off.status).toBe(200); + expect(readRouteIntentState().devices.home?.aside?.enabled).toBe(false); + expect((await homeSync('aside'))?.outcome).toBe('current'); + expect(asideRouted()).toBe(false); +`), SPAWN_BUDGET_MS); + +test("an Aside profile toggle refuses before anything changes when its decision cannot be kept", () => scenario(` + asideProfile(undefined); + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + const before = readFileSync(asideModels, 'utf8'); + const unresolved = await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/aside/profiles/0', { enabled: true }); + expect(unresolved.status).toBe(409); + expect(unresolved.body).toMatchObject({ code: 'route_intent_aside_unresolved', clientId: 'aside' }); + expect(readFileSync(asideModels, 'utf8')).toBe(before); + expect(readRouteIntentState().devices.home?.aside).toBeUndefined(); + asideProfile('user-a'); + writeFileSync(routeIntentStatePath(), '{not json'); + const damaged = await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/aside/profiles/0', { enabled: true }); + expect(damaged.status).toBe(500); + expect(damaged.body).toMatchObject({ code: 'route_intent_unavailable', clientId: 'aside' }); + expect(damaged.body.error).toContain(routeIntentStatePath()); + expect(asideRouted()).toBe(false); +`), SPAWN_BUDGET_MS); + +test("on a Home with no link an unreadable route-intent.json neither blocks a dashboard toggle nor gets written", () => scenario(` + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [] }); + writeFileSync(routeIntentStatePath(), '{not json'); + expect((await toggle('pi', true)).status).toBe(200); + expect(piRouted()).toBe(true); + expect(readFileSync(routeIntentStatePath(), 'utf8')).toBe('{not json'); + expect((await toggle('pi', false)).status).toBe(200); + expect(piRouted()).toBe(false); + expect(readFileSync(routeIntentStatePath(), 'utf8')).toBe('{not json'); +`), SPAWN_BUDGET_MS); + +test("on an upgraded linked Home whose Aside profiles have no stable userId, a full Home sync leaves Aside routed and still applies the rest", () => scenario(` + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [] }); + asideProfile(undefined); + expect((await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/aside/profiles/0', { enabled: true })).status).toBe(200); + expect(asideRouted()).toBe(true); + expect(applyIntegration({ clientId: 'pi', config: loadConfig(), port: 18274, models: [], store, home: process.env.HOME, env: process.env }).ok).toBe(true); + // A link enrolled by an earlier version: no route-intent.json yet. + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [{ id: 'lnk_0123456789abcdef', alias: 'child', direction: 'client-initiated', hostKeyFingerprint: null, tunnelPort: 19200, apiKeyId: 'key-test', createdAt: '2026-11-07T00:00:00.000Z' }] }); + const listener = createLinkListenerLifecycle({ serve: () => ({ port: 19100, stop: async () => {} }), warn: () => {} }); + listener.start({ dispatch: async () => new Response(null), maxRequestBodySize: 1024 }); + await listener.stop(); + expect(readRouteIntentState().defaults.aside).toBeUndefined(); + const unresolved = []; + const outcomes = await syncHomeRouteTo(loadConfig(), 'test', false, undefined, undefined, { loadModels: async () => [], onAsideUnresolved: message => unresolved.push(message) }); + expect(outcomes.find(row => row.client === 'aside')).toBeUndefined(); + expect(outcomes.find(row => row.client === 'pi')?.outcome).toBe('current'); + expect(asideRouted()).toBe(true); + expect(piRouted()).toBe(true); + expect(unresolved).toHaveLength(1); + expect(unresolved[0]).toContain('ocx route to aside'); + expect(readRouteIntentState().defaults.aside).toBeUndefined(); +`), SPAWN_BUDGET_MS); diff --git a/tests/routing/route-to-home-live-flags.test.ts b/tests/routing/route-to-home-live-flags.test.ts new file mode 100644 index 00000000000..4e8917df599 --- /dev/null +++ b/tests/routing/route-to-home-live-flags.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +/** + * Captured defaults drive Children; the Home's own native switches govern the Home. A full Home + * sync never writes them, whatever route intent or earlier native toggles recorded. + */ +function scenario(body: string) { + const home = mkdtempSync(join(tmpdir(), "ocx-home-live-flags-")); + for (const dir of [".codex", ".claude", "desktop"]) mkdirSync(join(home, dir)); + try { + const child = spawnSync(process.execPath, ["--eval", ` + import { expect } from 'bun:test'; + import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { commitClaudeCodeBlock } from './src/claude/claude-code-block'; + import { setIntegrationEnabled } from './src/codex/desired-state'; + import { handleRouteToRoutes } from './src/server/management/route-to-routes'; + import { handleNativeIntegrationRoutes } from './src/server/management/native-integration-routes'; + import { applyProtocolSettingsPatch } from './src/server/management/protocol-settings-patch'; + import { readHomeRouteIntentState } from './src/routing/home-route-intent'; + import { readRouteIntentState, setRouteClientIntent, writeRouteIntentState } from './src/routing/route-intent-state'; + const library = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + writeFileSync(join(library,'_meta.json'),JSON.stringify({entries:[]})); + const local = getDefaultConfig(); local.port=18274; local.hostname='127.0.0.1'; + local.defaultProvider='fixture';local.providers={fixture:{adapter:'openai-chat',baseUrl:'http://127.0.0.1:1/v1',allowPrivateNetwork:true,liveModels:false,models:['fixture-model']}}; + saveConfig(local); + // The server's in-memory snapshot, loaded before another process changes the switches. + const live = loadConfig(); + const call = async (path, body) => { + const req = new Request('http://localhost'+path, { method: path.endsWith('/sync') ? 'POST' : 'PUT', + headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const response = await handleRouteToRoutes({ req, url: new URL(req.url), config: live, version: 'test', + deps: { readLinkStore: () => ({ version: 1, listenerPort: null, links: [] }) } }); + return { status: response?.status, body: await response?.json() }; + }; + // The dashboard and ocx integrations native both send this request. + const native = async (client, enabled) => { + const req = new Request('http://localhost/api/native-integrations/'+client, { method: 'PUT', + headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled }) }); + const response = await handleNativeIntegrationRoutes({ req, url: new URL(req.url), config: live, version: 'test', + deps: { readRuntimePort: () => null } }); + return { status: response?.status, body: await response?.json() }; + }; + const meta = () => JSON.parse(readFileSync(join(library,'_meta.json'),'utf8')); + const codexConfig = () => existsSync(join(process.env.CODEX_HOME,'config.toml')) ? readFileSync(join(process.env.CODEX_HOME,'config.toml'),'utf8') : ''; + const homeRow = (body, client) => body.devices.find(row => row.id === 'home').clients.find(row => row.client === client); + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + } finally { removeTreeWithRetry(home); } +} + +const restoreAndMessagesOff = ` + // The first route operation captures the Home defaults (Codex and Claude on). + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + expect(readHomeRouteIntentState(loadConfig()).defaults.codex).toEqual({ enabled: true }); + expect(readHomeRouteIntentState(loadConfig()).defaults.claude).toEqual({ enabled: true }); + // ocx restore turns Codex off; Messages off turns Claude Code off. Neither records route intent. + expect(setIntegrationEnabled('codex', false).ok).toBe(true); + const c = loadConfig(); commitClaudeCodeBlock(c, { ...c.claudeCode, enabled: false }); saveConfig(c); + const before = codexConfig(); +`; + +test("an unrelated Home default write keeps ocx restore and Messages off", () => scenario(` + ${restoreAndMessagesOff} + const result = await call('/api/route-to', { client: 'opencode', action: 'on' }); + expect(result.status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect(loadConfig().claudeCode?.enabled).toBe(false); + expect(codexConfig()).toBe(before); + expect(codexConfig()).not.toContain('opencodex'); + expect(homeRow(result.body, 'codex').intent).toBe('off'); + // Clients the write did not decide keep their last proven outcome. + expect(homeRow(result.body, 'pi').applied).toBe('current'); + // The captured defaults still drive Children. + expect(readHomeRouteIntentState(loadConfig()).defaults.codex).toEqual({ enabled: true }); +`), SPAWN_BUDGET_MS); + +test("a full Home sync keeps ocx restore and Messages off", () => scenario(` + ${restoreAndMessagesOff} + const result = await call('/api/route-to/sync', { device: 'home' }); + expect(result.status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect(loadConfig().claudeCode?.enabled).toBe(false); + expect(codexConfig()).not.toContain('opencodex'); +`), SPAWN_BUDGET_MS); + +test("a Home default write for Codex itself still applies to the Home", () => scenario(` + ${restoreAndMessagesOff} + expect((await call('/api/route-to', { client: 'codex', action: 'off' })).status).toBe(200); + expect((await call('/api/route-to', { client: 'codex', action: 'on' })).status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(true); + expect(loadConfig().claudeCode?.enabled).toBe(false); +`), SPAWN_BUDGET_MS); + +const captureDefaults = ` + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); +`; + +test("a native Codex toggle does not pin the Home against a later ocx restore", () => scenario(` + ${captureDefaults} + expect((await native('codex', true)).status).toBe(200); + // ocx restore turns the switch off without recording route intent. + expect(setIntegrationEnabled('codex', false).ok).toBe(true); + const result = await call('/api/route-to/sync', { device: 'home' }); + expect(result.status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect(codexConfig()).not.toContain('opencodex'); + expect(homeRow(result.body, 'codex')).toMatchObject({ intent: 'off', source: 'native' }); + expect(readRouteIntentState().devices.home).toBeUndefined(); +`), SPAWN_BUDGET_MS); + +test("ocx restore back after a Codex default off keeps Codex on through a full Home sync", () => scenario(` + expect((await call('/api/route-to', { client: 'codex', action: 'off' })).status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + // ocx restore back stores ON as the absent key. + expect(setIntegrationEnabled('codex', true).ok).toBe(true); + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); + const result = await call('/api/route-to/sync', { device: 'home' }); + expect(result.status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); + expect(homeRow(result.body, 'codex')).toMatchObject({ intent: 'on', source: 'native', applied: 'current' }); + expect(codexConfig()).toContain('opencodex'); + // Children still inherit the default the write chose. + expect(readHomeRouteIntentState(loadConfig()).defaults.codex).toEqual({ enabled: false }); +`), SPAWN_BUDGET_MS); + +test("Messages off after a native Claude toggle survives a full Home sync", () => scenario(` + ${captureDefaults} + expect((await native('claude', true)).status).toBe(200); + const c = loadConfig(); + expect(applyProtocolSettingsPatch(c, { messagesEnabled: false }).ok).toBe(true); + saveConfig(c); + const result = await call('/api/route-to/sync', { device: 'home' }); + expect(result.status).toBe(200); + expect(loadConfig().claudeCode?.enabled).toBe(false); + expect(loadConfig().apiSurfaces?.messages?.enabled).toBe(false); + expect(homeRow(result.body, 'claude')).toMatchObject({ intent: 'off', source: 'native' }); +`), SPAWN_BUDGET_MS); + +test("ocx claude desktop apply after a captured Desktop default off keeps its profile through a full Home sync", () => scenario(` + expect((await call('/api/route-to', { client: 'claude-desktop', action: 'off' })).status).toBe(200); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBe(false); + const { applyDesktop } = await import('./src/cli/claude-desktop'); + const applied = await applyDesktop(undefined, { kind: 'gateway', mode: 'static' }, { findLiveProxyImpl: async () => null }); + expect(applied.ok).toBe(true); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); + const appliedId = meta().appliedId; + expect(typeof appliedId).toBe('string'); + const result = await call('/api/route-to/sync', { device: 'home' }); + expect(result.status).toBe(200); + expect(meta().appliedId).toBe(appliedId); + expect(homeRow(result.body, 'claude-desktop')).toMatchObject({ intent: 'on', source: 'native', applied: 'current' }); + expect(existsSync(join(library, appliedId + '.json'))).toBe(true); + expect(loadConfig().claudeCode?.desktopMode).toBe('gateway'); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); + expect(readHomeRouteIntentState(loadConfig()).defaults['claude-desktop']).toEqual({ enabled: false }); +`), SPAWN_BUDGET_MS); + +test("a first-party Home with a captured Desktop default off syncs without a conflict", () => scenario(` + setRouteClientIntent('claude-desktop', { enabled: false }); + const c = loadConfig(); c.claudeCode = { ...c.claudeCode, desktopMode: 'first-party' }; saveConfig(c); + const { syncHomeRouteTo } = await import('./src/routing/route-to-local'); + const outcomes = await syncHomeRouteTo(loadConfig(), 'test'); + // ocx sync exits 1 on any outcome other than current or not_installed. + expect(outcomes.filter(row => row.outcome !== 'current' && row.outcome !== 'not_installed')).toEqual([]); + expect(loadConfig().claudeCode?.desktopMode).toBe('first-party'); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); +`), SPAWN_BUDGET_MS); + +test("a devices.home native entry from an earlier version no longer pins the Home", () => scenario(` + writeRouteIntentState({ version: 1, defaults: { codex: { enabled: true }, claude: { enabled: true } }, + devices: { home: { codex: { enabled: true }, pi: { enabled: false } } } }); + expect(setIntegrationEnabled('codex', false).ok).toBe(true); + const result = await call('/api/route-to/sync', { device: 'home' }); + expect(result.status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect(codexConfig()).not.toContain('opencodex'); + expect(homeRow(result.body, 'codex')).toMatchObject({ intent: 'off', source: 'native' }); + // The stale native entry is dropped; a non-native Home override is kept. + expect(readRouteIntentState().devices.home).toEqual({ pi: { enabled: false } }); +`), SPAWN_BUDGET_MS); + +test("a Home-only native write applies through the switch and keeps no Home entry", () => scenario(` + const off = await call('/api/route-to', { client: 'codex', action: 'off', device: 'home' }); + expect(off.status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect(homeRow(off.body, 'codex')).toMatchObject({ intent: 'off', source: 'native', applied: 'current' }); + expect(readRouteIntentState().devices.home).toBeUndefined(); + // This Home has no link, so the Home-only write captured nothing: a Child enrolled later + // inherits the switch as it stands at enrollment. + expect(readHomeRouteIntentState(loadConfig()).defaults.codex).toBeUndefined(); + // Home inherit sets the switch to the default Children inherit, the builtin until one is saved. + const inherited = await call('/api/route-to', { client: 'codex', action: 'inherit', device: 'home' }); + expect(inherited.status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).not.toBe(false); + expect(homeRow(inherited.body, 'codex')).toMatchObject({ intent: 'on', source: 'native' }); + expect(readRouteIntentState().devices.home).toBeUndefined(); +`), SPAWN_BUDGET_MS); diff --git a/tests/routing/route-to-home-native-truth.test.ts b/tests/routing/route-to-home-native-truth.test.ts new file mode 100644 index 00000000000..790e373c1b0 --- /dev/null +++ b/tests/routing/route-to-home-native-truth.test.ts @@ -0,0 +1,188 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +/** + * A Home write reports what it saved: a Home native decision is its switch, so a refused apply + * after the switch moved still says the switch was saved. A Home with no link keeps route intent + * untouched by native toggles and --device home native writes, and a dashboard file toggle on a + * Home that uses route intent is recorded as the Home's decision so ocx sync keeps it. + */ +function scenario(body: string, linked = false) { + const home = mkdtempSync(join(tmpdir(), "ocx-home-native-truth-")); + for (const dir of [".codex", ".claude", "desktop"]) mkdirSync(join(home, dir)); + try { + const child = spawnSync(process.execPath, ["--eval", ` + import { expect } from 'bun:test'; + import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { getDefaultConfig, saveConfig, loadConfig } from './src/config'; + import { writeLinkStore } from './src/link/store'; + import { linkStorePath } from './src/link/paths'; + import { LINK_ROUTE_INTENT_PATH } from './src/link/management-routes'; + import { handleRouteToRoutes } from './src/server/management/route-to-routes'; + import { handleNativeIntegrationRoutes } from './src/server/management/native-integration-routes'; + import { handleIntegrationRoutes } from './src/server/management/integration-routes'; + import { readLinkRouteIntent } from './src/server/index/route-intent-watch'; + import { readRouteIntentState, routeIntentStatePath } from './src/routing/route-intent-state'; + import { syncHomeRouteTo } from './src/routing/route-to-local'; + import { setPersistedConfigMutationBeforeCommitForTests } from './src/config/persisted-mutation'; + import { applyIntegration } from './src/integrations/writer'; + import { createIntegrationStateStore } from './src/integrations/store'; + import { INTEGRATION_CLIENTS } from './src/integrations/registry'; + writeFileSync(join(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR,'_meta.json'),JSON.stringify({entries:[]})); + const local = getDefaultConfig(); local.port=18274; local.hostname='127.0.0.1'; + local.defaultProvider='fixture';local.providers={fixture:{adapter:'openai-chat',baseUrl:'http://127.0.0.1:1/v1',allowPrivateNetwork:true,liveModels:false,models:['fixture-model']}}; + saveConfig(local); + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: ${linked + ? "[{ id: 'lnk_0123456789abcdef', alias: 'child', direction: 'client-initiated', hostKeyFingerprint: null, tunnelPort: 19200, apiKeyId: 'key-test', createdAt: '2026-11-07T00:00:00.000Z' }]" + : "[]"} }); + const live = loadConfig(); + const management = async (handler, method, path, body) => { + const req = new Request('http://localhost'+path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const response = await handler({ req, url: new URL(req.url), config: live, version: 'test', deps: { readRuntimePort: () => null } }); + return { status: response?.status, body: await response?.json() }; + }; + const call = (path, body) => management(handleRouteToRoutes, path.endsWith('/sync') ? 'POST' : 'PUT', path, body); + const native = (client, enabled) => management(handleNativeIntegrationRoutes, 'PUT', '/api/native-integrations/'+client, { enabled }); + const childIntent = async () => { + const response = await readLinkRouteIntent(new Request('http://127.0.0.1:19100'+LINK_ROUTE_INTENT_PATH), live, 'test', 'key-test'); + expect(response.status).toBe(200); + return (await response.json()).clients; + }; + const store = createIntegrationStateStore(); + const piPath = INTEGRATION_CLIENTS.pi.configPath({}, process.env.HOME); + mkdirSync(INTEGRATION_CLIENTS.pi.detectDir({}, process.env.HOME), { recursive: true }); + const piRouted = () => !!store.readRecords().pi && existsSync(piPath) && readFileSync(piPath, 'utf8').includes('opencodex'); + const homeSync = async () => (await syncHomeRouteTo(loadConfig(), 'test', false, undefined, undefined, { loadModels: async () => [] })) + .find(row => row.client === 'pi'); + ${body} + `], { cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, ".codex"), + CODEX_SQLITE_HOME: join(home, ".codex"), OPENCODEX_HOME: join(home, ".opencodex"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), GROK_HOME: join(home, ".grok"), XDG_CONFIG_HOME: join(home, ".config"), + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(home, "desktop"), OCX_TEST_HOME_GUARD: "1" } }); + expect({ status: child.status, stderr: child.stderr, error: child.error?.message }).toMatchObject({ status: 0 }); + } finally { removeTreeWithRetry(home); } +} + +const orphanedGrok = ` + mkdirSync(process.env.GROK_HOME, { recursive: true }); + writeFileSync(join(process.env.GROK_HOME, 'config.toml'), '# >>> opencodex managed block — do not edit (removed by \\\`ocx stop\\\`) >>>\\n[model]\\nname = "x"\\n'); +`; + +test.each([false, true])("a refused Home Grok off reports the switch it saved (linked %s)", linked => scenario(` + ${orphanedGrok} + const result = await call('/api/route-to', { client: 'grok', action: 'off', device: 'home' }); + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ code: 'route_apply_unresolved', desiredCommitted: true }); + expect(result.body.error).toStartWith("The Home's grok switch was saved, but applying it ended in conflict"); + expect(result.body.error).toContain('incomplete'); + expect(loadConfig().clientIntegrations?.grok).toBe(false); +`, linked), SPAWN_BUDGET_MS); + +test("a first-party Desktop off saves nothing and says so", () => scenario(` + const c = loadConfig(); c.claudeCode = { ...c.claudeCode, desktopMode: 'first-party' }; saveConfig(c); Object.assign(live, loadConfig()); + const result = await call('/api/route-to', { client: 'claude-desktop', action: 'off', device: 'home' }); + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ code: 'route_apply_unresolved', desiredCommitted: false }); + expect(result.body.error).toStartWith('The Home did not apply claude-desktop (conflict)'); + expect(loadConfig().clientIntegrations?.['claude-desktop']).toBeUndefined(); +`), SPAWN_BUDGET_MS); + +test("a native switch write that fails saves nothing and says so", () => scenario(` + let failing = true; + const fail = () => { if (!failing) return; setPersistedConfigMutationBeforeCommitForTests(fail); throw new Error('config storage unavailable'); }; + setPersistedConfigMutationBeforeCommitForTests(fail); + const result = await call('/api/route-to', { client: 'codex', action: 'off', device: 'home' }); + failing = false; setPersistedConfigMutationBeforeCommitForTests(null); + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ code: 'route_apply_unresolved', desiredCommitted: false }); + expect(result.body.error).toStartWith('The Home did not apply codex (failed)'); + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); +`), SPAWN_BUDGET_MS); + +test("on a Home with no link a native toggle leaves route intent alone and ocx sync keeps a dashboard integration", () => scenario(` + mkdirSync(process.env.GROK_HOME, { recursive: true }); + writeFileSync(join(process.env.GROK_HOME, 'config.toml'), ''); + expect((await native('grok', false)).status).toBe(200); + expect(loadConfig().clientIntegrations?.grok).toBe(false); + expect((await call('/api/route-to', { client: 'codex', action: 'off', device: 'home' })).status).toBe(200); + expect(existsSync(routeIntentStatePath())).toBe(false); + // The dashboard's file toggle writes the integration without route intent. + expect(applyIntegration({ clientId: 'pi', config: loadConfig(), port: 18274, models: [], store, home: process.env.HOME, env: process.env }).ok).toBe(true); + expect(piRouted()).toBe(true); + expect((await homeSync())?.outcome).toBe('current'); + expect(piRouted()).toBe(true); + expect(existsSync(routeIntentStatePath())).toBe(false); + // Without route intent the dashboard toggle records nothing either. + expect((await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/pi', { enabled: false })).status).toBe(200); + expect(piRouted()).toBe(false); + expect(existsSync(routeIntentStatePath())).toBe(false); +`), SPAWN_BUDGET_MS); + +test("an unreadable route-intent.json never blocks a native toggle on a Home with no link", () => scenario(` + writeFileSync(routeIntentStatePath(), '{not json'); + expect((await native('codex', false)).status).toBe(200); + expect(loadConfig().clientIntegrations?.codex).toBe(false); + expect(readFileSync(routeIntentStatePath(), 'utf8')).toBe('{not json'); + // A route write still needs the file and refuses with the typed refusal naming it. + const result = await call('/api/route-to', { client: 'codex', action: 'on', device: 'home' }); + expect(result.status).toBe(500); + expect(result.body).toMatchObject({ code: 'route_intent_unavailable', desiredCommitted: false }); + expect(result.body.error).toContain(routeIntentStatePath()); + expect(result.body.error).toContain('fix or remove'); + expect(loadConfig().clientIntegrations?.codex).toBe(false); +`), SPAWN_BUDGET_MS); + +test("an unreadable route-intent.json refuses native toggles and route writes on a linked Home by name", () => scenario(` + writeFileSync(routeIntentStatePath(), '{not json'); + const toggled = await native('codex', false); + expect(toggled.status).toBe(500); + expect(toggled.body).toMatchObject({ reason: 'metadata_unreadable' }); + expect(toggled.body.message).toContain(routeIntentStatePath()); + expect(toggled.body.message).toContain('fix or remove'); + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); + for (const body of [{ client: 'codex', action: 'off', device: 'home' }, { client: 'pi', action: 'off' }, { client: 'pi', action: 'off', device: 'child' }]) { + const result = await call('/api/route-to', body); + expect(result.status).toBe(500); + expect(result.body).toMatchObject({ code: 'route_intent_unavailable', desiredCommitted: false }); + expect(result.body.error).toContain(routeIntentStatePath()); + } + expect(loadConfig().clientIntegrations?.codex).toBeUndefined(); + expect(readFileSync(routeIntentStatePath(), 'utf8')).toBe('{not json'); +`, true), SPAWN_BUDGET_MS); + +test("a dashboard file toggle on a Home that uses route intent is the Home's decision", () => scenario(` + // A saved default turns Pi off for the Home and its Children. + expect((await call('/api/route-to', { client: 'pi', action: 'off' })).status).toBe(200); + expect(readRouteIntentState().defaults.pi).toEqual({ enabled: false }); + const on = await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/pi', { enabled: true }); + expect(on.status).toBe(200); + expect(piRouted()).toBe(true); + expect(readRouteIntentState().devices.home?.pi).toEqual({ enabled: true }); + // ocx sync applies route intent to every client and keeps the dashboard decision. + expect((await homeSync())?.outcome).toBe('current'); + expect(piRouted()).toBe(true); + // Children keep the default. + expect((await childIntent()).pi).toEqual({ enabled: false }); + const off = await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/pi', { enabled: false }); + expect(off.status).toBe(200); + expect(readRouteIntentState().devices.home?.pi).toEqual({ enabled: false }); + expect((await homeSync())?.outcome).toBe('current'); + expect(piRouted()).toBe(false); + // An unreadable route-intent.json refuses the toggle by name before anything changes. + writeFileSync(routeIntentStatePath(), '{not json'); + const refused = await management(handleIntegrationRoutes, 'PUT', '/api/client-integrations/pi', { enabled: true }); + expect(refused.status).toBe(500); + expect(refused.body.code).toBe('route_intent_unavailable'); + expect(refused.body.error).toContain(routeIntentStatePath()); + expect(refused.body.error).toContain('fix or remove'); + expect(piRouted()).toBe(false); +`, true), SPAWN_BUDGET_MS); + diff --git a/tests/routing/route-to-home-off-review.test.ts b/tests/routing/route-to-home-off-review.test.ts new file mode 100644 index 00000000000..192d34c3757 --- /dev/null +++ b/tests/routing/route-to-home-off-review.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { syncHomeRouteTo } from "../../src/routing/route-to-local"; +import { setRouteClientIntent } from "../../src/routing/route-intent-state"; +import { initializeHomeRouteIntentDefaults } from "../../src/routing/home-route-intent"; +import { materializeRouteIntent, readRouteIntentReceipt } from "../../src/integrations/route-intent"; +import { applyIntegration } from "../../src/integrations/writer"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { mkdirSync, readFileSync } from "node:fs"; +import type { OcxConfig } from "../../src/types"; +let home: TempHome; +beforeEach(() => { home = createTempHome("home-off-review-"); }); +afterEach(() => home.remove()); + +test("enabling an unrelated integration never turns default-on native clients off", async () => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + initializeHomeRouteIntentDefaults(config, "test", "opencode"); + setRouteClientIntent("opencode", { enabled: true }); + const nativeCalls: Array<{ client: string; enabled: boolean }> = []; + await syncHomeRouteTo(config, "test", false, undefined, undefined, { + loadModels: async () => [], + materialize: async (snapshot, document, models, options) => materializeRouteIntent({ + config: snapshot, document, models, ...options, + home: home.path("local"), env: {}, store: createIntegrationStateStore(), + applyNative: async (client, intent) => { + nativeCalls.push({ client, enabled: intent.enabled }); + return { client, outcome: "current" }; + }, + }), + }); + expect(nativeCalls).toContainEqual({ client: "grok", enabled: true }); + expect(nativeCalls).toContainEqual({ client: "claude-desktop", enabled: true }); + expect(nativeCalls.some(call => ["grok", "claude-desktop"].includes(call.client) && !call.enabled)).toBe(false); +}); + +test.each([false, true])("Home off restores before discovery fails or stalls (failure %s)", async fail => { + const config = { port: 18274, hostname: "127.0.0.1", providers: {} } as OcxConfig; + const localHome = home.path("local"); + const store = createIntegrationStateStore(); + const spec = INTEGRATION_CLIENTS.pi; + mkdirSync(spec.detectDir({}, localHome), { recursive: true }); + const path = spec.configPath({}, localHome); + expect(applyIntegration({ clientId: "pi", config, port: config.port, models: [], store, home: localHome, env: {} }).ok).toBe(true); + expect(readFileSync(path, "utf8")).toContain("opencodex"); + setRouteClientIntent("pi", { enabled: false }); + const enabledCalls: string[] = []; + let started!: () => void, release!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + const waiting = new Promise(resolve => { release = resolve; }); + const pending = syncHomeRouteTo(config, "test", false, undefined, undefined, { + materialize: async (config, document, models, options) => materializeRouteIntent({ config, document, models, ...options, + home: localHome, env: {}, store, applyNative: async (client, intent) => { if (intent.enabled) enabledCalls.push(client); return { client, outcome: "current" }; } }), + loadModels: async () => { started(); await waiting; if (fail) throw new Error("fixture discovery failure"); return []; }, + }); + await entered; + // Discovery is still blocked here; only off intents may have touched configuration. + const blocked = { content: readFileSync(path, "utf8"), enabled: [...enabledCalls], + piOutcome: readRouteIntentReceipt()?.outcomes.find(row => row.client === "pi")?.outcome }; + release(); + if (fail) await expect(pending).rejects.toThrow("fixture discovery failure"); + else { await pending; expect(enabledCalls).toContain("codex"); } + expect(blocked.content).not.toContain("opencodex"); + expect(blocked.enabled).toEqual([]); + expect(blocked.piOutcome).toBe("current"); +}); diff --git a/tests/routing/route-to-sync-child.test.ts b/tests/routing/route-to-sync-child.test.ts new file mode 100644 index 00000000000..73677b3ab01 --- /dev/null +++ b/tests/routing/route-to-sync-child.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { syncRouteToChild } from "../../src/routing/route-to-sync-child"; +import { remotePinnedSyncArgv } from "../../src/link/ssh-argv"; +import { parseSyncFanoutFlags } from "../../src/cli/sync-flags"; +import type { LinkRecord } from "../../src/link/store"; +import type { ManagementContext } from "../../src/server/management/context"; + +const link: LinkRecord = { id: "lnk_0123456789abcdef", alias: "test-device", direction: "hub-initiated", + hostKeyFingerprint: "SHA256:abcdefghijklmnop", tunnelPort: 41000, apiKeyId: "test-key", createdAt: new Date().toISOString(), remotePlatform: "posix" }; +describe("Route-to immediate sync", () => { + test("the explicit overwrite flag is per request and retains pinned link identity on both platforms", () => { + const normal = remotePinnedSyncArgv(link.id); + const forced = remotePinnedSyncArgv(link.id, "posix", true); + expect(normal.join(" ")).not.toContain("--overwrite-conflict"); + expect(forced.join(" ")).toContain('--expect-link "$1" --json --overwrite-conflict'); + expect(forced.at(-1)).toBe(link.id); + expect(remotePinnedSyncArgv(link.id, "posix", true, "aside").join(" ")).toContain("--overwrite-conflict --overwrite-client aside"); + const windows = remotePinnedSyncArgv(link.id, "windows", true); + expect(windows).not.toEqual(remotePinnedSyncArgv(link.id, "windows")); + expect(parseSyncFanoutFlags(["--overwrite-conflict", "--overwrite-conflict"]).ok).toBe(false); + expect(parseSyncFanoutFlags(["--overwrite-client", "aside"]).ok).toBe(false); + expect(parseSyncFanoutFlags(["--overwrite-conflict", "--overwrite-client", "aside"])).toMatchObject({ ok: true, overwriteClient: "aside" }); + }); + test("failed or invalid remote output is a failed apply, never success by exit code alone", async () => { + const calls: readonly string[][] = []; + const ctx = { deps: { sshRunner: { run: async (argv: string[]) => { + (calls as string[][]).push(argv); return { code: 0, stdout: "unverified", stderr: "" }; + } }, linkKnownHostsPath: () => "/test/known_hosts" } } as unknown as ManagementContext; + expect(await syncRouteToChild(link, ctx)).toEqual({ state: "failed", reason: "child-output-invalid" }); + expect(calls).toHaveLength(1); + expect(calls[0]?.join(" ")).toContain(link.id); + }); + test("Child-initiated links disclose pending and perform no Home SSH", async () => { + const ctx = { deps: { sshRunner: { run: () => { throw new Error("must not run"); } } } } as unknown as ManagementContext; + expect(await syncRouteToChild({ ...link, direction: "client-initiated" }, ctx)).toMatchObject({ state: "pending" }); + }); +}); diff --git a/tests/server/link-enrollment-ephemeral-child.test.ts b/tests/server/link-enrollment-ephemeral-child.test.ts index 33d28690cf0..048dfe8f089 100644 --- a/tests/server/link-enrollment-ephemeral-child.test.ts +++ b/tests/server/link-enrollment-ephemeral-child.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; @@ -11,6 +11,7 @@ import type { SshRunner, SshRunResult } from "../../src/link/ssh-runner"; import { quoteRemote, remoteOcxArgv } from "../../src/link/ssh-argv"; import { remoteInspectRuntimeArgv, remotePlantChildArgv, remotePurgePlantedChildArgv } from "../../src/link/remote-plant"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { routeIntentStatePath } from "../../src/routing/route-intent-state"; let root: string; let previousHome: string | undefined; @@ -198,6 +199,9 @@ describe("Home Remote Link enrollment, SSH plant, and --purge removal with ephem const body = await res.json() as { linkId: string }; expect(body.linkId).toMatch(/^lnk_[0-9a-f]{16}$/); expect(h.store.links[0]?.childKind).toBe("ephemeral"); + // An ephemeral ocx-child never reads route intent, so its enrollment saves none: no device + // policy, and no Home defaults that would make ocx sync apply route intent on the Home. + expect(existsSync(routeIntentStatePath())).toBe(false); const adminStatus = await (await h.request("GET", "/api/link/status", undefined, "admin-token")).json() as Record; expect(adminStatus).not.toHaveProperty("homeCodexSettingsDigest"); diff --git a/tests/server/link-enrollment.test.ts b/tests/server/link-enrollment.test.ts index acaab8848eb..7d834c7b34d 100644 --- a/tests/server/link-enrollment.test.ts +++ b/tests/server/link-enrollment.test.ts @@ -10,6 +10,8 @@ import { SshRunnerError, SSH_MAX_OUTPUT_BYTES, SSH_OUTPUT_CAP_BYTES, type SshRun import { quoteRemote, remoteOcxArgv, WINDOWS_OS_PROBE } from "../../src/link/ssh-argv"; import { windowsScriptData } from "../../src/link/remote-windows"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { readRouteIntentState, setRouteClientIntent } from "../../src/routing/route-intent-state"; +import { projectHomeRouteIntent } from "../../src/link/route-intent"; let root: string; let previousHome: string | undefined; @@ -206,6 +208,20 @@ function noEnrollmentEffects(h: ReturnType) { } describe("Home enrollment of a standalone Child", () => { + test("enrollment without a selection inherits the Home policy", async () => { + const h = harness(); + setRouteClientIntent("codex", { enabled: false }); + setRouteClientIntent("claude", { enabled: false }); + expect((await h.call("probe", { alias: "child" })).status).toBe(200); + expect((await h.call("confirm-host", { alias: "child", fingerprint })).status).toBe(200); + expect((await h.call("apply", { alias: "child", fingerprint })).status).toBe(202); + const id = h.store.links[0]!.id; + const state = readRouteIntentState(); + expect(state.devices[id]).toBeUndefined(); + const projected = projectHomeRouteIntent(h.config, "test", id, state); + expect(projected.clients.codex.enabled).toBe(false); + expect(projected.clients.claude.enabled).toBe(false); + }); test("2.83 Child connects, restarts exactly once and needs a fresh complete catalog before success", async () => { const h = harness(); const probed = await h.call("probe", { alias: "child" }); diff --git a/tests/server/link-listener-lifecycle.test.ts b/tests/server/link-listener-lifecycle.test.ts index 953870a0e79..03615f2b390 100644 --- a/tests/server/link-listener-lifecycle.test.ts +++ b/tests/server/link-listener-lifecycle.test.ts @@ -344,4 +344,59 @@ describe("hub-link listener lifecycle", () => { expect((options as { idleTimeout?: number } | undefined)?.idleTimeout).toBe(255); await lifecycle.stop(); }); + + test("a failed startup capture of the Children's route defaults is retried until it succeeds", async () => { + tempHome = mkdtempSync(join(tmpdir(), "ocx-link-capture-retry-")); + const current = { value: store() }; + const warnings: string[] = []; + let attempts = 0; + const lifecycle = makeLifecycle(current, { + warn: message => warnings.push(message), + writeStore: (_path, next) => { current.value = next; }, + serve: served => { const actual = Bun.serve(served); servers.push(actual); return actual; }, + captureRetryDelayMs: () => 1, + captureRouteDefaults: () => { attempts += 1; if (attempts < 3) throw new Error("config lock busy"); }, + }); + lifecycle.start(context()); + // The listener still serves while the capture is owed. + expect(lifecycle.status().state).toBe("listening"); + expect(attempts).toBe(1); + for (let i = 0; i < 100 && attempts < 3; i += 1) await Bun.sleep(5); + expect(attempts).toBe(3); + expect(warnings.filter(message => message.includes("route defaults were not captured"))).toHaveLength(1); + await Bun.sleep(20); + expect(attempts).toBe(3); + await lifecycle.stop(); + }); + + test("a stopped listener stops retrying an owed capture", async () => { + tempHome = mkdtempSync(join(tmpdir(), "ocx-link-capture-stop-")); + let attempts = 0; + const lifecycle = makeLifecycle({ value: store() }, { + warn: () => {}, + serve: () => { throw new Error("fixture listener stops here"); }, + captureRetryDelayMs: () => 5, + captureRouteDefaults: () => { attempts += 1; throw new Error("config lock busy"); }, + }); + lifecycle.start(context()); + await lifecycle.stop(); + await Bun.sleep(30); + expect(attempts).toBe(1); + }); + + test("a closed listener cancels the owed capture's retry timer like stop", async () => { + tempHome = mkdtempSync(join(tmpdir(), "ocx-link-capture-close-")); + let attempts = 0; + const lifecycle = makeLifecycle({ value: store() }, { + warn: () => {}, + serve: () => { throw new Error("fixture listener stops here"); }, + captureRetryDelayMs: () => 5, + captureRouteDefaults: () => { attempts += 1; throw new Error("config lock busy"); }, + }); + lifecycle.start(context()); + await lifecycle.close(); + await Bun.sleep(30); + expect(attempts).toBe(1); + await lifecycle.stop(); + }); }); diff --git a/tests/server/link-route-intent.test.ts b/tests/server/link-route-intent.test.ts new file mode 100644 index 00000000000..0977ea8e479 --- /dev/null +++ b/tests/server/link-route-intent.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleLinkManagementRequest } from "../../src/server/index/link-management"; +import { LINK_ROUTE_INTENT_PATH, linkManagementRouteAllowed } from "../../src/link/management-routes"; +import { linkRouteAllowed } from "../../src/link/routes"; +import { writeLinkStore } from "../../src/link/store"; +import { linkStorePath } from "../../src/link/paths"; +import { setRouteClientIntent, routeIntentStatePath } from "../../src/routing/route-intent-state"; +import { captureHomeRouteDefaults } from "../../src/routing/home-route-capture"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +let root: string; +let previous: string | undefined; +const id = "lnk_0123456789abcdef"; +beforeEach(() => { + previous = process.env.OPENCODEX_HOME; root = mkdtempSync(join(tmpdir(), "ocx-route-boundary-")); process.env.OPENCODEX_HOME = root; + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [{ id, alias: "child", direction: "client-initiated", hostKeyFingerprint: null, tunnelPort: 19200, apiKeyId: "own-key", createdAt: "2026-11-07T00:00:00.000Z" }] }); + // A linked Home saved its Children's native defaults at enrollment or listener start. + captureHomeRouteDefaults({} as OcxConfig); + setRouteClientIntent("aside", { enabled: true, accounts: "all" }); + setRouteClientIntent("aside", { enabled: false }, id); + setRouteClientIntent("pi", { enabled: true }, "another-child"); +}); +afterEach(() => { if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; removeTreeWithRetry(root); }); +function request(path = LINK_ROUTE_INTENT_PATH, key: string | undefined = "own-key", method = "GET") { + const url = new URL(path, "http://127.0.0.1:19100"); + return handleLinkManagementRequest(new Request(url, { method }), url, {} as never, {} as never, undefined, key); +} +test("authenticated read selects only the admitted link device and performs no durable write", async () => { + const before = readFileSync(routeIntentStatePath(), "utf8"); + const response = await request(); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const document = await response.json(); + expect(document.clients.aside).toEqual({ enabled: false, accounts: "all" }); + expect(document.sources.aside).toBe("device"); + expect(document.clients.pi).toEqual({ enabled: false }); + expect(JSON.stringify(document)).not.toContain("another-child"); + expect(JSON.stringify(document)).not.toContain("own-key"); + expect(readFileSync(routeIntentStatePath(), "utf8")).toBe(before); + expect((await request(LINK_ROUTE_INTENT_PATH, "wrong-key")).status).toBe(403); + expect((await request(LINK_ROUTE_INTENT_PATH, "")).status).toBe(403); +}); +test("route stays exact GET/no-query/no-upgrade and outside Child data-plane ingress", async () => { + for (const method of ["POST", "PUT", "PATCH", "DELETE", "HEAD"]) { + const url = new URL(LINK_ROUTE_INTENT_PATH, "http://local"); + expect(linkManagementRouteAllowed(url, new Request(url, { method }))).toBe(false); + expect((await request(LINK_ROUTE_INTENT_PATH, "own-key", method)).status).toBe(404); + } + for (const query of ["?device=another-child", "?refresh=1", "?force=0", "?reload=1", "?quota=1"]) { + expect((await request(LINK_ROUTE_INTENT_PATH + query)).status).toBe(404); + } + const url = new URL(LINK_ROUTE_INTENT_PATH, "http://local"); + expect(linkManagementRouteAllowed(url, new Request(url, { headers: { upgrade: "websocket" } }))).toBe(false); + expect(linkRouteAllowed(url, new Request(url))).toBe(false); +}); diff --git a/tests/server/management-route-intent-native-errors.test.ts b/tests/server/management-route-intent-native-errors.test.ts new file mode 100644 index 00000000000..01f93364fc7 --- /dev/null +++ b/tests/server/management-route-intent-native-errors.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import * as os from "node:os"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { getDefaultConfig, saveConfig, saveConfigPreservingClaudeCode } from "../../src/config"; +import * as desiredState from "../../src/codex/desired-state"; +import * as configPaths from "../../src/config/paths"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { ConfigWritePublishedError } from "../../src/config/persist-unlocked"; +import { withConfigMutationLockSync } from "../../src/config/mutation-lock"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { asideHomeDir } from "../../src/clients/config-export"; +import { initializeHomeRouteIntentDefaults } from "../../src/routing/home-route-intent"; +import { readRouteIntentState, routeIntentStatePath, setRouteClientIntent } from "../../src/routing/route-intent-state"; +import { handleNativeIntegrationRoutes } from "../../src/server/management/native-integration-routes"; +import { writeLinkStore } from "../../src/link/store"; +import { linkStorePath } from "../../src/link/paths"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +let config: OcxConfig; +let previousEnv: Record; +let restores: Array<() => void>; +beforeEach(() => { + home = createTempHome("native-intent-errors-"); + const homedir = spyOn(os, "homedir").mockReturnValue(home.root); + restores = [() => homedir.mockRestore()]; + previousEnv = {}; + for (const [key, path] of Object.entries({ HOME: "", USERPROFILE: "", GROK_HOME: "grok", CLAUDE_CONFIG_DIR: "claude", + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: "desktop", CLAUDE_USER_DATA_DIR: "desktop-user" })) { + previousEnv[key] = process.env[key]; process.env[key] = home.path(path); + } + config = getDefaultConfig(); + config.claudeCode = { enabled: true }; + saveConfig(config); + mkdirSync(home.path("grok")); writeFileSync(home.path("grok", "config.toml"), "# fixture\n"); + // Route intent bookkeeping belongs to a Home with a Child; an unlinked Home's toggle never reads it. + writeLinkStore(linkStorePath(), { version: 1, listenerPort: null, links: [{ id: "lnk_0123456789abcdef", alias: "child", + direction: "client-initiated", hostKeyFingerprint: null, tunnelPort: 19200, apiKeyId: "key-test", createdAt: "2026-11-07T00:00:00.000Z" }] }); +}); +afterEach(() => { + for (const restore of restores.reverse()) restore(); + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + home.remove(); +}); + +async function toggle(client: string, deps: ManagementContext["deps"] = {}, enabled = false, context: Partial = {}) { + const req = new Request(`http://localhost/api/native-integrations/${client}`, { method: "PUT", body: JSON.stringify({ enabled }) }); + const response = await handleNativeIntegrationRoutes({ req, url: new URL(req.url), config, version: "test", deps, ...context } as ManagementContext); + return { status: response!.status, body: await response!.json() }; +} +function ownAside() { + expect(asideHomeDir()).toBe(home.path(".aside")); + createIntegrationStateStore().putRecord({ clientId: "aside", configPath: home.path(".aside", "u", "0", "models.json"), + fileFingerprint: "a".repeat(16), blockFingerprint: "b".repeat(16), fragmentPaths: [["models"]], + opId: "fixture", appliedAt: "2026-01-01T00:00:00.000Z" }); + mkdirSync(home.path(".aside")); +} + +for (const client of ["codex", "grok", "claude", "claude-desktop"]) { + for (const manifest of ["missing", "missing-user-id", "malformed", "unreadable"] as const) { + test(`${client}: ${manifest} owned Aside manifest refuses with recovery and preserves policy`, async () => { + ownAside(); + const path = home.path(".aside", "accounts.json"); + if (manifest === "missing-user-id") writeFileSync(path, JSON.stringify({ accounts: [{ id: 0 }] })); + if (manifest === "malformed") writeFileSync(path, "{private-fixture-details"); + if (manifest === "unreadable") mkdirSync(path); // deterministic regular-file read refusal, even as root + const before = readFileSync(home.path("config.json"), "utf8"); + const result = await toggle(client); + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ code: "native_integration_refused", clientId: client, reason: "metadata_unreadable" }); + expect(result.body.message).toContain("manifest"); + expect(result.body.message).toContain("ocx route to aside on"); + expect(result.body.message).toContain("ocx route to aside off"); + expect(JSON.stringify(result.body)).not.toContain(home.root); + expect(JSON.stringify(result.body)).not.toContain("private-fixture-details"); + expect(result.body.desiredEnabled).toBeUndefined(); + expect(existsSync(routeIntentStatePath())).toBe(false); + expect(readFileSync(home.path("config.json"), "utf8")).toBe(before); + }); + } + + test(`${client}: an unlinked Home toggles without reading an owned Aside manifest or route intent`, async () => { + writeLinkStore(linkStorePath(), { version: 1, listenerPort: null, links: [] }); + ownAside(); + writeFileSync(home.path(".aside", "accounts.json"), "{private-fixture-details"); + const result = await toggle(client); + expect(result.status).toBe(200); + expect(existsSync(routeIntentStatePath())).toBe(false); + }); +} + +for (const enabled of [false, true]) { + test(`explicit Home Aside ${enabled} recovers without replacing that policy`, async () => { + ownAside(); setRouteClientIntent("aside", { enabled, ...(enabled ? { accounts: "all" as const } : {}) }); + expect((await toggle("claude")).status).toBe(200); + expect(readRouteIntentState().defaults.aside).toEqual({ enabled, ...(enabled ? { accounts: "all" } : {}) }); + }); +} + +test("a corrupt config coordinator is a sanitized persistence failure, not retryable contention", async () => { + writeFileSync(home.path("config-mutation.sqlite"), "not a database: private-fixture-details"); + const result = await toggle("claude"); + expect(result.status).toBe(500); + expect(result.body).toMatchObject({ reason: "write_failed", code: "native_integration_failed", diagnostic: { stage: "config", cause: "SQLITE_NOTADB" } }); + expect(result.body.desiredEnabled).toBeUndefined(); + expect(JSON.stringify(result.body)).not.toContain(home.root); + expect(JSON.stringify(result.body)).not.toContain("private-fixture-details"); +}); + +test("the config lock remains held after legacy publication until Home intent is recorded", async () => { + initializeHomeRouteIntentDefaults(config, "test"); + const holder = new Database(home.path("config-mutation.sqlite")); + let acquired = false; + try { + const result = await toggle("claude", { saveConfigPreservingClaudeCode: current => { + saveConfigPreservingClaudeCode(current); + expect(JSON.parse(readFileSync(home.path("config.json"), "utf8")).claudeCode.enabled).toBe(false); + try { holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); acquired = true; } + catch (error) { expect((error as { code: string }).code).toBe("SQLITE_BUSY"); } + } }); + expect(result.status).toBe(200); + expect(acquired).toBe(false); + expect(readRouteIntentState().devices).toEqual({}); + } finally { if (acquired) holder.exec("ROLLBACK"); holder.close(); } +}); + +for (const client of ["codex", "grok", "claude", "claude-desktop"] as const) { + test(`${client}: failed Home intent write after publication returns an honest recoverable refusal`, async () => { + initializeHomeRouteIntentDefaults(config, "test"); + const corruptIntent = () => writeFileSync(routeIntentStatePath(), "{private-fixture-details"); + const deps: ManagementContext["deps"] = {}; + if (client === "claude") deps.saveConfigPreservingClaudeCode = current => { saveConfigPreservingClaudeCode(current); corruptIntent(); }; + else { + const original = desiredState.setIntegrationEnabled; + const persist = () => { const result = original(client, false); expect(result.ok).toBe(true); corruptIntent(); return result; }; + const hook = client === "codex" ? spyOn(desiredState, "setCodexIntegrationEnabled").mockImplementation(persist) + : client === "grok" ? spyOn(desiredState, "setGrokIntegrationEnabled").mockImplementation(persist) + : spyOn(desiredState, "setIntegrationEnabled").mockImplementation(persist); + restores.push(() => hook.mockRestore()); + } + const result = await toggle(client, deps); + const disk = JSON.parse(readFileSync(home.path("config.json"), "utf8")); + expect(client === "claude" ? disk.claudeCode.enabled : disk.clientIntegrations[client]).toBe(false); + expect(result.status).toBe(500); + expect(result.body).toMatchObject({ reason: "write_failed", clientId: client, desiredEnabled: false }); + expect(result.body.message).not.toContain("Nothing was written"); + expect(result.body.message).toMatch(/repair|recover|retry/i); + expect(JSON.stringify(result.body)).not.toContain(home.root); + expect(JSON.stringify(result.body)).not.toContain("private-fixture-details"); + }); +} + +for (const client of ["codex", "grok", "claude", "claude-desktop"] as const) { + test(`${client}: published-then-error retains the saved decision and reports sanitized recovery`, async () => { + const failPublished = (): never => { throw new ConfigWritePublishedError(new Error(`private-fixture-details ${home.root}`)); }; + const deps: ManagementContext["deps"] = {}; + if (client === "claude") deps.saveConfigPreservingClaudeCode = current => { saveConfigPreservingClaudeCode(current); failPublished(); }; + else { + const original = desiredState.setIntegrationEnabled; + const persist = () => { expect(original(client, false).ok).toBe(true); return failPublished(); }; + const hook = client === "codex" ? spyOn(desiredState, "setCodexIntegrationEnabled").mockImplementation(persist) + : client === "grok" ? spyOn(desiredState, "setGrokIntegrationEnabled").mockImplementation(persist) + : spyOn(desiredState, "setIntegrationEnabled").mockImplementation(persist); + restores.push(() => hook.mockRestore()); + } + const result = await toggle(client, deps); + expect(result.status).toBe(500); + const disk = JSON.parse(readFileSync(home.path("config.json"), "utf8")); + expect(client === "claude" ? disk.claudeCode.enabled : disk.clientIntegrations[client]).toBe(false); + if (client === "claude") expect(config.claudeCode?.enabled).toBe(false); + if (client === "claude-desktop") expect(config.clientIntegrations?.[client]).toBe(false); + expect(result.body).toMatchObject({ reason: "write_failed", desiredEnabled: false }); + expect(result.body.message).toMatch(/repair|recover|retry/i); + expect(JSON.stringify(result.body)).not.toContain(home.root); + expect(JSON.stringify(result.body)).not.toContain("private-fixture-details"); + }); +} + +test("real contention during post-decision Claude reconcile never claims nothing was written", async () => { + initializeHomeRouteIntentDefaults(config, "test"); + const holder = new Database(home.path("config-mutation.sqlite")); + let acquired = false; + try { + const result = await toggle("claude", {}, false, { syncClaudeAgentDefsBestEffort: async () => { + expect(readRouteIntentState().devices).toEqual({}); + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); acquired = true; + withConfigMutationLockSync(() => {}); + } }); + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ reason: "config_busy", desiredEnabled: false }); + expect(result.body.message).not.toContain("Nothing was written"); + expect(result.body.message).toMatch(/retry|recover/i); + expect(JSON.parse(readFileSync(home.path("config.json"), "utf8")).claudeCode.enabled).toBe(false); + } finally { if (acquired) holder.exec("ROLLBACK"); holder.close(); } +}); + +test("Claude native OFF recomputes its decision after a competing ON during ACL admission", async () => { + config.claudeCode = { enabled: false }; + saveConfig(config); + const settle = configPaths.withConfigDirHardeningSettled; + let calls = 0, competing = false; + const hook = spyOn(configPaths, "withConfigDirHardeningSettled").mockImplementation(async (root, mutate) => { + if (++calls === 2) { + const url = new URL("http://localhost/api/claude-code"); + const response = await handleManagementAPI(new Request(url, { method: "PUT", headers: { "content-type": "application/json", Host: url.host }, + body: JSON.stringify({ enabled: true }) }), url, config, {}, "admin-token", undefined, { trustedLoopback: true }); + expect(response?.status).toBe(200); + expect(config.claudeCode?.enabled).toBe(true); + competing = true; + } + return settle(root, mutate); + }); + restores.push(() => hook.mockRestore()); + const result = await toggle("claude"); + expect(competing).toBe(true); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ changed: true, state: "absent", desiredEnabled: false }); + expect(config.claudeCode?.enabled).toBe(false); + expect(JSON.parse(readFileSync(home.path("config.json"), "utf8")).claudeCode.enabled).toBe(false); + expect(readRouteIntentState().devices).toEqual({}); +}); diff --git a/tests/server/management-route-intent-native-review.test.ts b/tests/server/management-route-intent-native-review.test.ts new file mode 100644 index 00000000000..32e1894aa43 --- /dev/null +++ b/tests/server/management-route-intent-native-review.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { createTempHome } from "../helpers/temp-home"; +import { repoPath } from "../helpers/repo-root"; + +test("legacy dashboard toggles durably decide Home intent without changing Child defaults", async () => { + const home = createTempHome("native-route-review-"); + try { + const script = ` + const { getDefaultConfig, loadConfig, saveConfig } = await import(${JSON.stringify(repoPath("src/config.ts"))}); + const { handleNativeIntegrationRoutes } = await import(${JSON.stringify(repoPath("src/server/management/native-integration-routes.ts"))}); + const { readHomeRouteIntentState, initializeHomeRouteIntentDefaults } = await import(${JSON.stringify(repoPath("src/routing/home-route-intent.ts"))}); + const { projectHomeRouteIntent } = await import(${JSON.stringify(repoPath("src/link/route-intent.ts"))}); + const { mkdirSync, writeFileSync } = await import("node:fs"); + const config = getDefaultConfig(); + config.port = 18274; + config.claudeCode = { enabled: true }; + config.clientIntegrations = { codex: true, grok: true, "claude-desktop": true }; + saveConfig(config); + initializeHomeRouteIntentDefaults(config, "test"); + mkdirSync(process.env.GROK_HOME, { recursive: true }); + writeFileSync(process.env.GROK_HOME + "/config.toml", "# fixture\\n"); + const result = []; + for (const client of ["claude", "codex", "grok", "claude-desktop"]) { + const req = new Request("http://localhost/api/native-integrations/" + client, { method: "PUT", body: JSON.stringify({ enabled: false }) }); + const response = await handleNativeIntegrationRoutes({ req, url: new URL(req.url), config, version: "test", deps: {} }); + const body = await response.json(); + const state = readHomeRouteIntentState(config); + // The Home reads its durable switch (route-to status does the same); Children read defaults. + result.push({ client, status: response.status, desired: body.desiredEnabled, + home: projectHomeRouteIntent(loadConfig(), "test", "home", state).clients[client].enabled, + child: projectHomeRouteIntent(config, "test", "lnk_0123456789abcdef", state).clients[client].enabled, + homeEntry: state.devices.home?.[client] ?? null }); + } + const { withLinkApply } = await import(${JSON.stringify(repoPath("src/client/link-home.ts"))}); + const before = JSON.stringify(readHomeRouteIntentState(config)); + const request = () => new Request("http://localhost/api/native-integrations/claude", { method: "PUT", body: JSON.stringify({ enabled: true }) }); + const req = request(); + const denied = await handleNativeIntegrationRoutes({ req, url: new URL(req.url), config: { ...config, runtimeRole: "client" }, version: "test", deps: {} }); + const busy = await withLinkApply(async () => { + const req = request(); + return handleNativeIntegrationRoutes({ req, url: new URL(req.url), config, version: "test", deps: {} }); + }); + console.log("RECEIPT=" + JSON.stringify({ rows: result, denied: denied.status, busy: busy.status, unchanged: before === JSON.stringify(readHomeRouteIntentState(config)) })); + `; + const proc = Bun.spawn([process.execPath, "--eval", script], { env: { ...process.env, HOME: home.root, + USERPROFILE: home.root, APPDATA: home.path("appdata"), LOCALAPPDATA: home.path("localappdata"), XDG_CONFIG_HOME: home.path("xdg"), + CLAUDE_USER_DATA_DIR: home.path("claude-user-data"), OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: home.path("desktop-config"), + OPENCODEX_HOME: home.root, CODEX_HOME: home.codexHome, CLAUDE_CONFIG_DIR: home.path(".claude"), GROK_HOME: home.path(".grok"), ASIDE_HOME: home.path(".aside") }, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); + expect({ code, stderr }).toEqual({ code: 0, stderr: "" }); + const result = JSON.parse(stdout.split("RECEIPT=")[1]!.trim()); + expect(result).toMatchObject({ denied: 403, busy: 409, unchanged: true }); + expect(result.rows).toHaveLength(4); + for (const row of result.rows) { + expect(row.desired).toBe(false); + expect(row.home).toBe(false); + // Captured once from the switches: Codex and Claude Code on; Grok and Desktop are never captured on. + expect(row.child).toBe(row.client === "codex" || row.client === "claude"); + expect(row.homeEntry).toBeNull(); + } + } finally { home.remove(); } +}); diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index 1fdf813cb9e..717192de9d3 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -66,6 +66,11 @@ const moduleOf = (file: string): string => file.replace(/^src\//, "").replace(/\ const key = (method: string, path: string): string => `${method} ${path}`; describe("management route registry reconciliation", () => { + test("route-to exposes exactly its versioned read, intent write and explicit sync methods", () => { + const scanned = distinctRoutes(scanRoutes(join(repoRoot, "src/server/management/route-to-routes.ts"))); + expect(scanned.unresolved).toEqual([]); + expect([...scanned.pairs].sort()).toEqual(["GET /api/route-to", "POST /api/route-to/sync", "PUT /api/route-to"]); + }); test("dashboard plan uses the shared proof-bound production inspection path", async () => { const proof = "P".repeat(43); const root = process.platform === "win32" ? "C:\\fixture-only" : "/tmp/fixture-only"; diff --git a/tests/server/route-intent-watch-boundary.test.ts b/tests/server/route-intent-watch-boundary.test.ts new file mode 100644 index 00000000000..a25bacdd39c --- /dev/null +++ b/tests/server/route-intent-watch-boundary.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleLinkManagementRequest } from "../../src/server/index/link-management"; +import { syncRouteToChild } from "../../src/routing/route-to-sync-child"; +import { routeIntentDigest } from "../../src/link/route-intent"; +import { LINK_ROUTE_INTENT_PATH } from "../../src/link/management-routes"; +import { linkStorePath } from "../../src/link/paths"; +import { writeLinkStore, type LinkRecord } from "../../src/link/store"; +import { setRouteClientIntent, routeIntentStatePath } from "../../src/routing/route-intent-state"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { homeRouteIntentWatch } from "../../src/link/route-intent-watch"; +import { handleRouteToRoutes } from "../../src/server/management/route-to-routes"; + +test("watch replies distinguish unchanged timeout from explicit notification", async () => { + const after = await digest(); + const wait = spyOn(homeRouteIntentWatch, "wait"); + try { + wait.mockResolvedValueOnce("timeout").mockResolvedValueOnce("notified"); + const headers = { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-after": after }; + expect((await read(headers).response).headers.get("x-ocx-route-intent-wake")).toBe("timeout"); + expect((await read(headers).response).headers.get("x-ocx-route-intent-wake")).toBe("notified"); + } finally { wait.mockRestore(); } +}); + +const link: LinkRecord = { id: "lnk_0123456789abcdef", alias: "child", direction: "client-initiated", hostKeyFingerprint: null, + tunnelPort: 19200, apiKeyId: "own-key", createdAt: "2026-11-07T00:00:00.000Z" }; +let root: string; +let previous: string | undefined; +const aborts: AbortController[] = []; +beforeEach(() => { + previous = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-watch-boundary-")); process.env.OPENCODEX_HOME = root; + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [link] }); + setRouteClientIntent("aside", { enabled: false }); +}); +afterEach(() => { + for (const abort of aborts.splice(0)) abort.abort(); + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(root); +}); +function read(headers: HeadersInit = {}, key = link.apiKeyId, suffix = "", method = "GET", readmit?: () => string | undefined) { + const abort = new AbortController(); aborts.push(abort); + const url = new URL(LINK_ROUTE_INTENT_PATH + suffix, "http://127.0.0.1"); + return { abort, response: handleLinkManagementRequest(new Request(url, { headers, method, signal: abort.signal }), url, {} as never, {} as never, undefined, key, readmit) }; +} +async function digest() { return routeIntentDigest(await (await read().response).json()); } +const turn = () => new Promise(resolve => setTimeout(resolve, 0)); + +test("same digest waits, Home sync wakes without SSH and returns pending rather than an apply claim", async () => { + const after = await digest(); + const before = readFileSync(routeIntentStatePath(), "utf8"); + let settled = false; + const pending = read({ "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-after": after }).response.then(r => { settled = true; return r; }); + await turn(); + expect(settled).toBe(false); + const result = await syncRouteToChild(link, { deps: { sshRunner: { run() { throw new Error("must not SSH"); } } } } as never); + expect(result.state).toBe("pending"); + expect(result.reason).toContain("requested"); + const response = await pending; + expect(response.headers.get("x-ocx-route-intent-watch")).toBe("1"); + expect(routeIntentDigest(await response.json())).toBe(after); + expect(readFileSync(routeIntentStatePath(), "utf8")).toBe(before); +}); + +test("watch remains exact GET, query-free and admitted-key scoped", async () => { + const headers = { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-after": "0".repeat(64) }; + for (const method of ["POST", "PUT", "DELETE", "HEAD"]) expect((await read(headers, link.apiKeyId, "", method).response).status).toBe(404); + for (const query of ["?device=other", "?refresh=1"]) expect((await read(headers, link.apiKeyId, query).response).status).toBe(404); + expect((await read(headers, "other-key").response).status).toBe(403); + expect((await read({ ...headers, upgrade: "websocket" }).response).status).toBe(404); + for (const bad of [{ "x-ocx-route-intent-watch": "yes" }, { "x-ocx-route-intent-after": "0".repeat(64) }, + { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-after": "secret-invalid" }]) { + const response = await read(bad).response; + expect(response.status).toBe(400); + expect(await response.text()).not.toContain("secret-invalid"); + } +}); + +test("cancelled/duplicate reads release ownership and fresh reads recheck revoked trust", async () => { + const headers = { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-after": await digest() }; + const waiting = read(headers); + expect((await read(headers).response).status).toBe(429); + waiting.abort.abort(new Error("private abort")); + expect((await waiting.response).status).toBe(499); expect(homeRouteIntentWatch.size).toBe(0); + const revoked = read(headers); + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [] }); + homeRouteIntentWatch.notify(link.id); + expect((await revoked.response).status).toBe(403); expect(homeRouteIntentWatch.size).toBe(0); +}); + +test("device mutations notify only their link; default mutations wake all and return fresh policy", async () => { + const other: LinkRecord = { ...link, id: "lnk_fedcba9876543210", alias: "second", apiKeyId: "second-key" }; + writeLinkStore(linkStorePath(), { version: 1, listenerPort: 19100, links: [link, other] }); + const headers = { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-after": await digest() }; + const first = read(headers), second = read(headers, other.apiKeyId); + let otherSettled = false; void second.response.then(() => { otherSettled = true; }); + const mutate = async (body: unknown) => { + const req = new Request("http://localhost/api/route-to", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + return handleRouteToRoutes({ req, url: new URL(req.url), config: { port: 10100, providers: {} }, version: "test", deps: {} } as never, + { applyHome: async () => [] }); + }; + expect((await mutate({ client: "aside", action: "on", device: link.id }))?.status).toBe(200); + await turn(); expect(otherSettled).toBe(false); + expect(homeRouteIntentWatch.size).toBe(1); + const doc = await (await first.response).json(); expect(doc.clients.aside.enabled).toBe(true); + expect((await mutate({ client: "pi", action: "on" }))?.status).toBe(200); + expect(homeRouteIntentWatch.size).toBe(0); + expect((await (await second.response).json()).clients.pi.enabled).toBe(true); +}); + +test("a key revoked during the wait is refused even while its link record remains", async () => { + const headers = { "x-ocx-route-intent-watch": "1", "x-ocx-route-intent-after": await digest() }; + let admitted: string | undefined = link.apiKeyId; + const waiting = read(headers, link.apiKeyId, "", "GET", () => admitted); + await turn(); + admitted = undefined; + homeRouteIntentWatch.notify(link.id); + const response = await waiting.response; + expect(response.status).toBe(403); + expect(await response.text()).not.toContain("aside"); +}); diff --git a/tests/server/route-intent-watch-waiters.test.ts b/tests/server/route-intent-watch-waiters.test.ts new file mode 100644 index 00000000000..57374a66f54 --- /dev/null +++ b/tests/server/route-intent-watch-waiters.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { RouteIntentWatch } from "../../src/link/route-intent-watch"; + +test("notify wakes only the intended admitted link, and every terminal path releases its slot", async () => { + const watch = new RouteIntentWatch(20, 2); + const a = new AbortController(), b = new AbortController(); + const first = watch.wait("link-a", a.signal), second = watch.wait("link-b", b.signal); + expect(watch.size).toBe(2); + expect(watch.notify("unrelated")).toBe(false); + expect(watch.notify("link-a")).toBe(true); + expect(await first).toBe("notified"); expect(watch.size).toBe(1); + expect(watch.notify("link-a")).toBe(false); + b.abort("private abort reason"); expect(await second).toBe("cancelled"); expect(watch.size).toBe(0); + expect(await watch.wait("link-a", new AbortController().signal)).toBe("timeout"); expect(watch.size).toBe(0); + expect(await watch.wait("link-b", b.signal)).toBe("cancelled"); expect(watch.size).toBe(0); +}); + +test("one waiter per link and global cap refuse without evicting another caller", async () => { + const watch = new RouteIntentWatch(30, 1); + const abort = new AbortController(); + const waiting = watch.wait("a", abort.signal); + expect(await watch.wait("a", new AbortController().signal)).toBe("busy"); + expect(await watch.wait("b", new AbortController().signal)).toBe("busy"); + expect(watch.size).toBe(1); + watch.notify("a"); expect(await waiting).toBe("notified"); expect(watch.size).toBe(0); + const reused = watch.wait("b", abort.signal); + abort.abort(); expect(await reused).toBe("cancelled"); expect(watch.size).toBe(0); + for (const ms of [0, -1, NaN, Infinity, 30001]) expect(() => new RouteIntentWatch(ms)).toThrow(); + expect(() => new RouteIntentWatch(30000, 257)).toThrow(); +}); diff --git a/tests/server/route-to-ephemeral.test.ts b/tests/server/route-to-ephemeral.test.ts new file mode 100644 index 00000000000..d0973ea5b24 --- /dev/null +++ b/tests/server/route-to-ephemeral.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { handleRouteToRoutes } from "../../src/server/management/route-to-routes"; +import { handleRouteToCommand } from "../../src/cli/route-to"; +import { homeLinkChildParity } from "../../src/link/child-parity"; +import { readRouteIntentState, routeIntentStatePath } from "../../src/routing/route-intent-state"; +import { syncRouteToChild } from "../../src/routing/route-to-sync-child"; +import { homeHasLinks } from "../../src/routing/home-route-capture"; +import { writeLinkStore, type LinkRecord } from "../../src/link/store"; +import { linkStorePath } from "../../src/link/paths"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; + +/** An ephemeral ocx-child never reads route intent: no route write, sync or status claims it does. */ +const full = "lnk_0123456789abcdef", eph = "lnk_fedcba9876543210"; +const links: LinkRecord[] = [ + { id: full, alias: "full-child", direction: "hub-initiated", hostKeyFingerprint: "SHA256:abcdefghijklmnop", tunnelPort: 41000, apiKeyId: "key-full", createdAt: "2026-11-07T00:00:00.000Z", remotePlatform: "posix", childKind: "full" }, + { id: eph, alias: "devbox", direction: "hub-initiated", hostKeyFingerprint: "SHA256:ponmlkjihgfedcba", tunnelPort: 41001, apiKeyId: "key-eph", createdAt: "2026-11-07T00:00:00.000Z", remotePlatform: "posix", childKind: "ephemeral" }, +]; +let home: TempHome; +let applied: number; +let remote: string[]; +const config = { port: 12345, providers: {} } as OcxConfig; +function ctx(path: string, body?: unknown, method?: string): ManagementContext { + const req = new Request(`http://localhost${path}`, body === undefined ? {} : { + method: method ?? (path.endsWith("/sync") ? "POST" : "PUT"), headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + return { req, url: new URL(req.url), config, version: "test", deps: { readLinkStore: () => ({ version: 1, listenerPort: null, links }) } } as ManagementContext; +} +const deps = { + applyHome: async () => { applied++; return []; }, + syncChild: async (link: { id: string }) => { remote.push(link.id); return { state: "synced" as const }; }, +}; +beforeEach(() => { home = createTempHome("route-to-ephemeral-"); applied = 0; remote = []; homeLinkChildParity.clear(); }); +afterEach(() => { homeLinkChildParity.clear(); home.remove(); }); + +describe("route intent on an ephemeral ocx-child link", () => { + test("a route write that targets it is refused before any state change", async () => { + for (const device of [eph, "devbox"]) { + for (const body of [{ client: "codex", action: "off", device }, { client: "pi", action: "on", device, overwriteConflict: true }, { client: "pi", action: "inherit", device }]) { + const response = await handleRouteToRoutes(ctx("/api/route-to", body), deps); + expect(response?.status).toBe(409); + expect(await response!.json()).toMatchObject({ code: "route_intent_unsupported", reason: "ephemeral-child-no-route-intent", device: eph }); + } + } + expect(existsSync(routeIntentStatePath())).toBe(false); + expect({ applied, remote }).toEqual({ applied: 0, remote: [] }); + }); + + test("route-to sync never runs its remote sync: one device is refused, all reports it unsupported", async () => { + const one = await handleRouteToRoutes(ctx("/api/route-to/sync", { device: "devbox", overwriteConflict: true }), deps); + expect(one?.status).toBe(409); + expect(await one!.json()).toMatchObject({ code: "route_intent_unsupported", reason: "ephemeral-child-no-route-intent" }); + expect({ applied, remote }).toEqual({ applied: 0, remote: [] }); + const all = await handleRouteToRoutes(ctx("/api/route-to/sync", { device: "all" }), deps); + expect(all?.status).toBe(200); + const body = await all!.json(); + expect(body.syncs).toEqual([{ id: full, state: "synced" }, { id: eph, state: "unsupported", reason: "ephemeral-child-no-route-intent" }]); + expect(remote).toEqual([full]); + // The shared Child sync helper refuses on its own as well, before any SSH. + expect(await syncRouteToChild(links[1]!, ctx("/api/route-to/sync", {}), true, "pi")) + .toEqual({ state: "unsupported", reason: "ephemeral-child-no-route-intent" }); + }); + + test("status rows show route intent unsupported, also after a default write", async () => { + expect((await handleRouteToRoutes(ctx("/api/route-to", { client: "pi", action: "off" }), deps))?.status).toBe(200); + const response = await handleRouteToRoutes(ctx("/api/route-to?device=all"), deps); + const body = await response!.json(); + expect(body.devices.find((row: { id: string }) => row.id === eph)) + .toEqual({ id: eph, alias: "devbox", desiredDigest: "", appliedRouteIntentDigest: null, clients: [], routeIntent: "unsupported", reason: "ephemeral-child-no-route-intent" }); + expect(body.devices.find((row: { id: string }) => row.id === full).clients.length).toBeGreaterThan(0); + // The CLI accepts and prints the row. + const out: string[] = []; + const log = spyOn(console, "log").mockImplementation((...parts) => { out.push(parts.join(" ")); }); + try { + expect(await handleRouteToCommand(["status", "--device", "all"], { baseUrl: "http://route-to.invalid", fetchImpl: (async () => Response.json(body)) as unknown as typeof fetch })).toBe(0); + } finally { log.mockRestore(); } + expect(out.join("\n")).toContain("devbox route intent unsupported (ephemeral-child-no-route-intent)"); + }); + + test("a Home whose only links are ephemeral has no Child to protect", () => { + writeLinkStore(linkStorePath(), { version: 1, listenerPort: null, links: [links[1]!] }); + expect(homeHasLinks()).toBe(false); + writeLinkStore(linkStorePath(), { version: 1, listenerPort: null, links }); + expect(homeHasLinks()).toBe(true); + expect(readRouteIntentState().defaults).toEqual({}); + }); +}); diff --git a/tests/server/route-to-live-settings.test.ts b/tests/server/route-to-live-settings.test.ts new file mode 100644 index 00000000000..77a5980e91e --- /dev/null +++ b/tests/server/route-to-live-settings.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test"; +import { createTempHome } from "../helpers/temp-home"; +import { getDefaultConfig, loadConfig, saveConfig } from "../../src/config"; +import { handleRouteToRoutes } from "../../src/server/management/route-to-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { unlinkSync } from "node:fs"; +import { getConfigPath } from "../../src/config"; +import { resolveApiSurfaceSettings } from "../../src/protocols/settings"; + +test("materialization cannot revert concurrent Claude settings or an explicit Messages denial", async () => { + const home = createTempHome("route-live-concurrency-"); + try { + const live = getDefaultConfig(); live.port = 18274; + live.claudeCode = { enabled: true, authMode: "auto", blockedSkills: ["before"] }; + live.apiSurfaces = { messages: { enabled: true } }; + saveConfig(live); + const req = new Request("http://localhost/api/route-to", { method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ client: "claude", action: "off" }) }); + const ctx = { req, url: new URL(req.url), config: live, version: "1", deps: { readLinkStore: () => ({ version: 1, listenerPort: null, links: [] }) } } as ManagementContext; + const response = await handleRouteToRoutes(ctx, { materializeHome: async snapshot => { + // The controller captured this subtree before its asynchronous model work. + snapshot.claudeCode = { ...snapshot.claudeCode, enabled: false }; + snapshot.apiSurfaces = { messages: { enabled: true } }; + await Promise.resolve(); + // Another authenticated settings operation commits while it is awaiting I/O. + live.claudeCode = { ...live.claudeCode, enabled: false, authMode: "subscription", blockedSkills: ["after"] }; + live.apiSurfaces = { messages: { enabled: false } }; + saveConfig(live); + return [{ client: "claude", outcome: "current" }]; + } }); + expect(response?.status).toBe(200); + expect(live.claudeCode).toMatchObject({ enabled: false, authMode: "subscription", blockedSkills: ["after"] }); + expect(live.apiSurfaces?.messages?.enabled).toBe(false); + expect(loadConfig().apiSurfaces?.messages?.enabled).toBe(false); + } finally { home.remove(); } +}); + +test("a missing durable config never removes a live explicit Messages denial", async () => { + const home = createTempHome("route-live-missing-"); + try { + const live = getDefaultConfig(); live.apiSurfaces = { messages: { enabled: false } }; + saveConfig(live); + const req = new Request("http://localhost/api/route-to", { method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ client: "claude", action: "off" }) }); + const ctx = { req, url: new URL(req.url), config: live, version: "1", deps: { readLinkStore: () => ({ version: 1, listenerPort: null, links: [] }) } } as ManagementContext; + const response = await handleRouteToRoutes(ctx, { materializeHome: async () => { + unlinkSync(getConfigPath()); return [{ client: "claude", outcome: "current" }]; + } }); + expect(response?.status).toBe(500); + expect(resolveApiSurfaceSettings(live).messages.enabled).toBe(false); + } finally { home.remove(); } +}); diff --git a/tests/server/route-to-policy-review.test.ts b/tests/server/route-to-policy-review.test.ts new file mode 100644 index 00000000000..8e9c88556e7 --- /dev/null +++ b/tests/server/route-to-policy-review.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { handleRouteToRoutes } from "../../src/server/management/route-to-routes"; +import { readRouteIntentState, setRouteClientIntent } from "../../src/routing/route-intent-state"; +import { withLinkApply, LinkApplyBusyError } from "../../src/client/link-home"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { LinkRecord } from "../../src/link/store"; +let home: TempHome; +beforeEach(() => { home = createTempHome("route-policy-review-"); }); +afterEach(() => home.remove()); +const id = "lnk_0123456789abcdef", other = "lnk_fedcba9876543210"; +function context(body: unknown, sync = false, aliases = ["child", "other"]): ManagementContext { + const req = new Request(`http://localhost/api/route-to${sync ? "/sync" : ""}`, { method: sync ? "POST" : "PUT", body: JSON.stringify(body) }); + return { req, url: new URL(req.url), config: { port: 18274, providers: {} }, version: "test", deps: { + readLinkStore: () => ({ version: 1, listenerPort: null, links: [id, other].map((id, i) => ({ id, alias: aliases[i] } as LinkRecord)) }), + } } as ManagementContext; +} +const deps = { applyHome: async () => [] }; +test("on/off preserve omitted policy knobs and explicit values replace them", () => { + for (const device of [undefined, id]) { + setRouteClientIntent("aside", { enabled: true, accounts: ["user-a"] }, device); + setRouteClientIntent("aside", { enabled: false }, device); + setRouteClientIntent("aside", { enabled: true }, device); + const map = () => device ? readRouteIntentState().devices[device]! : readRouteIntentState().defaults; + expect(map().aside).toEqual({ enabled: true, accounts: ["user-a"] }); + setRouteClientIntent("aside", { enabled: true, accounts: "none" }, device); + expect(map().aside?.accounts).toBe("none"); + setRouteClientIntent("hermes", { enabled: true, modelNames: "alias" }, device); + setRouteClientIntent("hermes", { enabled: false }, device); + expect(map().hermes).toEqual({ enabled: false, modelNames: "alias" }); + } +}); +test("ambiguous aliases refuse before writes; exact ID wins even against another alias", async () => { + const denied = await handleRouteToRoutes(context({ client: "aside", action: "on", device: "same" }, false, ["same", "same"]), deps); + expect(denied?.status).toBe(409); + expect(readRouteIntentState().defaults).toEqual({}); + expect(readRouteIntentState().devices).toEqual({}); + const accepted = await handleRouteToRoutes(context({ client: "aside", action: "on", device: id }, false, ["child", id]), deps); + expect(accepted?.status).toBe(200); + expect(readRouteIntentState().devices[id]?.aside?.enabled).toBe(true); + expect(readRouteIntentState().devices[other]).toBeUndefined(); +}); +test("Home apply busy refuses before committing policy", async () => { + await withLinkApply(async () => { + const response = await handleRouteToRoutes(context({ client: "aside", action: "on" }), deps); + expect(response?.status).toBe(409); + expect(await response!.json()).toMatchObject({ desiredCommitted: false }); + expect(readRouteIntentState().defaults).toEqual({}); + }); +}); +test("a second Home write cannot change the policy an admitted apply is reconciling", async () => { + let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const waiting = new Promise(resolve => { release = resolve; }); + const first = handleRouteToRoutes(context({ client: "aside", action: "on" }), { + applyHome: async () => { entered(); await waiting; expect(readRouteIntentState().defaults.aside?.enabled).toBe(true); return []; }, + }); + await started; + const second = await handleRouteToRoutes(context({ client: "aside", action: "off" }), deps); + release(); + expect((await first)?.status).toBe(200); + expect(second?.status).toBe(409); + expect(readRouteIntentState().defaults.aside?.enabled).toBe(true); +}); +test("a busy error after desired commit reports that the decision remains saved", async () => { + const response = await handleRouteToRoutes(context({ client: "aside", action: "on" }), { + applyHome: async () => { throw new LinkApplyBusyError(); }, + }); + expect(response?.status).toBe(409); + expect(await response!.json()).toMatchObject({ desiredCommitted: true }); + expect(readRouteIntentState().defaults.aside?.enabled).toBe(true); +}); +test("reconcile failure deliberately retains committed desired state", async () => { + const response = await handleRouteToRoutes(context({ client: "aside", action: "on" }), { + applyHome: async () => { expect(readRouteIntentState().defaults.aside?.enabled).toBe(true); throw new Error("fixture apply failed"); }, + }); + expect(response?.status).toBe(500); + expect(readRouteIntentState().defaults.aside?.enabled).toBe(true); +}); +test("failed child sync returns its matrix alongside the failure status", async () => { + const response = await handleRouteToRoutes(context({ device: "child" }, true), { + syncChild: async () => ({ state: "failed", reason: "fixture_failed" }), + }); + expect(response?.status).toBe(502); + const body = await response!.json(); + expect(body.devices.map((row: { id: string }) => row.id)).toEqual([id]); + expect(body.syncs).toEqual([{ id, state: "failed", reason: "fixture_failed" }]); +}); diff --git a/tests/server/route-to-retirement-review.test.ts b/tests/server/route-to-retirement-review.test.ts new file mode 100644 index 00000000000..6a434cdfacb --- /dev/null +++ b/tests/server/route-to-retirement-review.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, afterEach, expect, spyOn, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import * as policyState from "../../src/routing/route-intent-state"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { handleLinkRoutes } from "../../src/server/management/link-routes"; +import { compensateNewLink, type LinkRouteState } from "../../src/server/management/link-route-state"; +import { readRouteIntentState, setRouteClientIntent, writeRouteIntentState } from "../../src/routing/route-intent-state"; +import { seedRouteDeviceClients } from "../../src/routing/home-route-intent"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { LinkRecord, LinkStore } from "../../src/link/store"; +let home: TempHome; +beforeEach(() => { home = createTempHome("route-retire-review-"); }); +afterEach(() => home.remove()); +const retired = "lnk_0123456789abcdef", active = "lnk_fedcba9876543210", orphan = "lnk_0000000000000000"; +function fixture() { + setRouteClientIntent("aside", { enabled: true, accounts: ["user-a"] }); + for (const id of [retired, active, orphan, "home"]) setRouteClientIntent("aside", { enabled: false }, id); + const record: LinkRecord = { id: retired, alias: "retiring", direction: "client-initiated", hostKeyFingerprint: null, tunnelPort: 41000, apiKeyId: "fixture-key", createdAt: "2026-01-01T00:00:00Z" }; + let store: LinkStore = { version: 1, listenerPort: null, links: [record, { ...record, id: active, alias: "remaining" }] }; + const req = new Request(`http://localhost/api/link/${retired}`, { method: "DELETE" }); + const ctx = { req, url: new URL(req.url), principal: "admin-token", trustedLoopbackIngress: true, + config: { port: 18274, providers: {}, apiKeys: [] }, version: "test", deps: { + readLinkStore: () => store, writeLinkStore: (next: LinkStore) => { store = next; }, revokeApiKey: () => true, + } } as unknown as ManagementContext; + const state = { pendingHosts: new Map(), supervisor: { stopLink: async () => {}, reload: async () => {} }, listener: { close: async () => {} } } as unknown as LinkRouteState; + return { ctx, state, record, store: () => store }; +} +function preserved() { + const state = readRouteIntentState(); + expect(state.devices[retired]).toBeUndefined(); expect(state.revisions?.[retired]).toBeUndefined(); + expect(state.devices[orphan]).toBeUndefined(); expect(state.revisions?.[orphan]).toBeUndefined(); + expect(state.devices[active]?.aside).toEqual({ enabled: false }); + expect(state.revisions?.[active]?.aside).toBe(1); + expect(state.devices.home?.aside).toEqual({ enabled: false }); + expect(state.defaults.aside).toEqual({ enabled: true, accounts: ["user-a"] }); +} +test("link removal retires override/revision rows and prior orphans", async () => { + const f = fixture(); expect((await handleLinkRoutes(f.ctx, f.state))?.status).toBe(200); + expect(f.store().links.map(row => row.id)).toEqual([active]); preserved(); +}); +test("failed enrollment compensation retires override/revision rows and prior orphans", async () => { + const f = fixture(); expect(await compensateNewLink(f.ctx, f.state, f.record)).toBeNull(); preserved(); +}); +test("enrollment prunes valid accumulated orphan state before adding a new device", () => { + const f = fixture(); + const state = readRouteIntentState(); + for (let i = 0; i < 2500; i++) { + state.devices[`retired-${i}`] = { aside: { enabled: true, accounts: ["user-old"] } }; + (state.revisions ??= {})[`retired-${i}`] = { aside: 1 }; + } + writeRouteIntentState(state); + seedRouteDeviceClients(f.ctx.config, "test", retired, ["codex"], [active, retired]); + const next = readRouteIntentState(); + expect(Object.keys(next.devices).sort()).toEqual(["home", retired, active].sort()); + expect(Object.keys(next.revisions ?? {}).sort()).toEqual(["home", retired, active].sort()); + expect(next.devices[retired]?.codex?.enabled).toBe(true); + expect(next.defaults.aside?.accounts).toEqual(["user-a"]); +}); + +test("standalone seed callers hold one config lease across retirement, defaults and selection", () => { + const f = fixture(); + const update = policyState.updateRouteIntentState; + let observations = 0; + const check = spyOn(policyState, "updateRouteIntentState").mockImplementation(mutate => { + const competing = new Database(home.path("config-mutation.sqlite")); + try { + expect(() => competing.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE")).toThrow(/locked|busy/i); + observations++; + } finally { + try { if (competing.inTransaction) competing.exec("ROLLBACK"); } + finally { competing.close(); } + } + return update(mutate); + }); + try { seedRouteDeviceClients(f.ctx.config, "test", retired, ["codex"], [active, retired]); } + finally { check.mockRestore(); } + expect(observations).toBeGreaterThanOrEqual(3); + expect(readRouteIntentState().devices[retired]?.codex?.enabled).toBe(true); +}); diff --git a/tests/server/route-to-routes.test.ts b/tests/server/route-to-routes.test.ts new file mode 100644 index 00000000000..15d295e47ea --- /dev/null +++ b/tests/server/route-to-routes.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { handleRouteToRoutes } from "../../src/server/management/route-to-routes"; +import { handleRouteToCommand } from "../../src/cli/route-to"; +import { homeLinkChildParity } from "../../src/link/child-parity"; +import { projectHomeRouteIntent, routeClientIntentDigest } from "../../src/link/route-intent"; +import { readRouteIntentState } from "../../src/routing/route-intent-state"; +import { routeToDeviceStatus } from "../../src/routing/route-to-status"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import { getDefaultConfig, loadConfig, saveConfig } from "../../src/config"; + +const id = "lnk_0123456789abcdef"; +let home: TempHome; +let applied: number; +let remote: string[]; +const config = { port: 12345, providers: {} } as OcxConfig; +function ctx(path: string, body?: unknown): ManagementContext { + const req = new Request(`http://localhost${path}`, body === undefined ? {} : { + method: path.endsWith("/sync") ? "POST" : "PUT", + headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + return { req, url: new URL(req.url), config, version: "test", deps: { + readLinkStore: () => ({ version: 1, listenerPort: null, links: [ + { id, alias: "child-test", direction: "hub-initiated", hostKeyFingerprint: "SHA256:abcdefghijklmnop", tunnelPort: 41000, apiKeyId: "key-test", createdAt: new Date().toISOString(), remotePlatform: "posix" }, + ] }), + } } as ManagementContext; +} +const deps = { + applyHome: async () => { applied++; return []; }, + syncChild: async (link: { id: string }) => { remote.push(link.id); return { state: "synced" as const }; }, +}; +beforeEach(() => { home = createTempHome("route-to-api-"); applied = 0; remote = []; homeLinkChildParity.clear(); }); +afterEach(() => { homeLinkChildParity.clear(); home.remove(); }); + +describe("Route-to device API", () => { + test("unsupported verbs never mutate intent or trigger apply", async () => { + for (const [path, methods] of [["/api/route-to", ["POST", "PATCH", "DELETE"]], ["/api/route-to/sync", ["GET", "PUT", "PATCH", "DELETE"]]] as const) { + for (const method of methods) { + const context = ctx(path); + context.req = new Request(context.req.url, { method }); + expect((await handleRouteToRoutes(context, deps))?.status).toBe(405); + } + } + expect(applied).toBe(0); + expect(remote).toEqual([]); + expect(readRouteIntentState().defaults).toEqual({}); + }); + test("status is versioned and passive, and includes Home and every Child", async () => { + const response = await handleRouteToRoutes(ctx("/api/route-to?device=all"), deps); + expect(response?.status).toBe(200); + const body = await response!.json(); + expect(body.schema).toBe("ocx.route-to/1"); + expect(body.devices.map((row: { id: string }) => row.id)).toEqual(["home", id]); + expect(body.devices[1].clients.find((row: { client: string }) => row.client === "aside")).toMatchObject({ intent: "off", source: "builtin", applied: null }); + expect(applied).toBe(0); + }); + test("device alias writes are isolated and inherit removes the override", async () => { + expect((await handleRouteToRoutes(ctx("/api/route-to", { client: "aside", action: "on", device: "child-test", accounts: ["aside-user-test"] }), deps))?.status).toBe(200); + expect(projectHomeRouteIntent(config, "test", id, readRouteIntentState()).clients.aside).toMatchObject({ enabled: true, accounts: ["aside-user-test"] }); + expect(projectHomeRouteIntent(config, "test", undefined, readRouteIntentState()).clients.aside.enabled).toBe(false); + expect(applied).toBe(0); + expect((await handleRouteToRoutes(ctx("/api/route-to", { client: "aside", action: "inherit", device: id }), deps))?.status).toBe(200); + expect(projectHomeRouteIntent(config, "test", id, readRouteIntentState()).sources.aside).toBe("default"); + }); + test("Home defaults use the same local applier; explicit sync selects one Child", async () => { + expect((await handleRouteToRoutes(ctx("/api/route-to", { client: "aside", action: "on" }), deps))?.status).toBe(200); + expect(applied).toBe(1); + expect((await handleRouteToRoutes(ctx("/api/route-to/sync", { device: "child-test" }), deps))?.status).toBe(200); + expect(remote).toEqual([id]); + expect(applied).toBe(1); + }); + test("unknown devices and machine facts reject before persistence or materialization", async () => { + for (const body of [ + { client: "aside", action: "on", device: "unknown" }, + { client: "aside", action: "on", port: 10100 }, + { client: "aside", action: "on", accounts: ["u/0"] }, + { client: "codex", action: "on", accounts: "all" }, + { client: "aside", action: "inherit" }, + { client: "aside", action: ["on"] }, + ]) expect((await handleRouteToRoutes(ctx("/api/route-to", body), deps))!.status).toBeGreaterThanOrEqual(400); + expect(applied).toBe(0); + expect(readRouteIntentState().defaults).toEqual({}); + }); + test("old successful receipts become stale when the desired intent changes", () => { + const doc = projectHomeRouteIntent(config, "test", id, readRouteIntentState()); + const row = routeToDeviceStatus(id, "child-test", doc, [{ client: "codex", outcome: "current" }], "0".repeat(64)); + expect(row.clients.find(value => value.client === "codex")?.applied).toBe("stale"); + }); + test("Home-only native overrides preserve the inherited Child default", async () => { + expect((await handleRouteToRoutes(ctx("/api/route-to", { client: "codex", action: "off", device: "home" }), deps))?.status).toBe(200); + const mutated = { ...config, clientIntegrations: { codex: false } }; + expect(projectHomeRouteIntent(mutated, "test", id, readRouteIntentState()).clients.codex.enabled).toBe(true); + expect(projectHomeRouteIntent(mutated, "test", "home", readRouteIntentState()).clients.codex.enabled).toBe(false); + }); + test("partial apply keeps unrelated per-client success current", () => { + const doc = projectHomeRouteIntent(config, "test", id); + const row = routeToDeviceStatus(id, "child-test", doc, [ + { client: "codex", outcome: "current", intentDigest: routeClientIntentDigest(doc, "codex") }, + { client: "aside", outcome: "failed" }, + ], null); + expect(row.clients.find(value => value.client === "codex")?.applied).toBe("current"); + expect(row.clients.find(value => value.client === "aside")?.applied).toBe("failed"); + }); + test("a Child's earlier conflict is stale once its intent changes, so the off write succeeds", async () => { + expect((await handleRouteToRoutes(ctx("/api/route-to", { client: "codex", action: "on", device: id }), deps))?.status).toBe(200); + const enabled = projectHomeRouteIntent(config, "test", id, readRouteIntentState()); + homeLinkChildParity.record({ linkId: id, lastApplyAt: null, runtimeStartedAt: 1_761_008_405_000, appliedCodexSettingsDigest: null, sequence: 1, + appliedRouteIntentDigest: null, routeClientOutcomes: [ + { client: "codex", outcome: "conflict", intentDigest: routeClientIntentDigest(enabled, "codex") }, + { client: "claude", outcome: "failed", intentDigest: routeClientIntentDigest(enabled, "claude") }, + ] }, new Set([id])); + const response = await handleRouteToRoutes(ctx("/api/route-to", { client: "codex", action: "off", device: id }), deps); + expect(response?.status).toBe(200); + const body = await response!.json(); + const rows = body.devices.find((row: { id: string }) => row.id === id).clients; + expect(rows.find((row: { client: string }) => row.client === "codex")).toMatchObject({ intent: "off", applied: "stale" }); + // A failure recorded for an unchanged intent still stands. + expect(rows.find((row: { client: string }) => row.client === "claude")?.applied).toBe("failed"); + // ocx route to codex off --device reads the same response and must not report a 409. + const out = spyOn(console, "log").mockImplementation(() => {}), err = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleRouteToCommand(["codex", "off", "--device", id], { readChildContext: () => null, + findLiveProxy: async () => ({ hostname: "127.0.0.1", port: 10100, pid: 1, source: "runtime" }), + fetchImpl: (async () => Response.json(body)) as unknown as typeof fetch })).toBe(0); + } finally { out.mockRestore(); err.mockRestore(); } + }); + test("Home inherit invalidates only the corresponding initial selection", async () => { + const doc = projectHomeRouteIntent(config, "test", id); + const initial = { client: "codex" as const, outcome: "current" as const, initialIntent: { enabled: false }, initialRevision: 0 }; + expect(routeToDeviceStatus(id, "child-test", doc, [initial], null).clients.find(value => value.client === "codex")?.intent).toBe("off"); + await handleRouteToRoutes(ctx("/api/route-to", { client: "codex", action: "inherit", device: id }), deps); + const next = projectHomeRouteIntent(config, "test", id, readRouteIntentState()); + expect(next.clientRevisions?.codex).toBe(1); + expect(routeToDeviceStatus(id, "child-test", next, [initial], null).clients.find(value => value.client === "codex")?.intent).toBe("on"); + }); + test("a client toggle binds explicit takeover to that client", async () => { + const scopes: unknown[] = []; + const scoped = { ...deps, applyHome: async (_ctx: ManagementContext, overwrite: boolean, client?: string) => { scopes.push([overwrite, client]); return []; } }; + const response = await handleRouteToRoutes(ctx("/api/route-to", { client: "aside", action: "on", overwriteConflict: true }), scoped); + expect(response?.status).toBe(200); + expect(scopes).toEqual([[true, "aside"]]); + }); + test("a refused --device home write reports whether a Home override was saved", async () => { + const refusing = { ...deps, applyHome: async (_ctx: ManagementContext, _overwrite: boolean, client?: string) => + [{ client: client as "opencode", outcome: "conflict" as const, message: "owned block changed" }] }; + // A non-native client's Home decision is a devices.home override, saved before the apply ran. + const opencode = await handleRouteToRoutes(ctx("/api/route-to", { client: "opencode", action: "on", device: "home" }), refusing); + expect(opencode?.status).toBe(409); + const saved = await opencode!.json(); + expect(saved).toMatchObject({ code: "route_apply_unresolved", desiredCommitted: true }); + expect(saved.error).toStartWith("Route policy was saved, but the Home did not apply opencode (conflict)"); + expect(readRouteIntentState().devices.home?.opencode).toEqual({ enabled: true }); + // A native client's Home decision is its switch, which a refused apply leaves unchanged. + const codex = await handleRouteToRoutes(ctx("/api/route-to", { client: "codex", action: "off", device: "home" }), refusing); + expect(codex?.status).toBe(409); + const unsaved = await codex!.json(); + expect(unsaved).toMatchObject({ code: "route_apply_unresolved", desiredCommitted: false }); + expect(unsaved.error).toStartWith("The Home did not apply codex (conflict)"); + }); + test("a failure after the write says what it saved", async () => { + const failing = (save?: () => void) => ({ ...deps, applyHome: async () => { save?.(); throw new Error("model discovery failed"); } }); + // A default write saves route policy before the apply, here before its model discovery fails. + const policy = await handleRouteToRoutes(ctx("/api/route-to", { client: "pi", action: "off" }), failing()); + expect(policy?.status).toBe(500); + expect(await policy!.json()).toEqual({ code: "route_to_failed", desiredCommitted: true, + error: "Route policy was saved, but applying it failed; inspect the local integration status and retry sync" }); + expect(readRouteIntentState().defaults.pi).toEqual({ enabled: false }); + // A Home native decision is its switch: the durable switch says whether it was saved. + saveConfig({ ...getDefaultConfig(), port: 12345 }); + const moved = await handleRouteToRoutes(ctx("/api/route-to", { client: "codex", action: "off", device: "home" }), + failing(() => saveConfig({ ...loadConfig(), clientIntegrations: { codex: false } }))); + expect(moved?.status).toBe(500); + expect(await moved!.json()).toEqual({ code: "route_to_failed", desiredCommitted: true, + error: "The Home's codex switch was saved, but applying it failed; inspect the local integration status and retry sync" }); + const unmoved = await handleRouteToRoutes(ctx("/api/route-to", { client: "codex", action: "on", device: "home" }), failing()); + expect(await unmoved!.json()).toEqual({ code: "route_to_failed", desiredCommitted: false, + error: "Route-to state could not be read or applied; inspect the local integration status and retry" }); + }); +}); diff --git a/tests/service/service-disconnected-client-restart.test.ts b/tests/service/service-disconnected-client-restart.test.ts new file mode 100644 index 00000000000..468333ab554 --- /dev/null +++ b/tests/service/service-disconnected-client-restart.test.ts @@ -0,0 +1,137 @@ +import { expect, spyOn, test } from "bun:test"; +import { repairService } from "../../src/service/repair"; +import type { ServiceDiagnostic } from "../../src/service/diagnostics"; +import { prepareDisconnectedClientServiceRestart } from "../../src/service/disconnected-client-restart"; +import * as processState from "../../src/config/process-state"; + +const installed: ServiceDiagnostic = { + supported: true, installed: true, enabled: true, running: true, viable: true, + startable: true, stale: false, conflict: false, backend: "launchd", summary: "fixture", +}; + +test("explicit service restart settles a disconnected client before starting its replacement", async () => { + const calls: string[] = []; + const deps = { + platform: "darwin" as const, verb: "restart" as const, + inspectSupervision: () => ({ kind: "none" as const }), + diagnose: () => installed, readOwnership: () => ({ kind: "none" as const, revision: 0 }), + assertEnv: () => {}, assertAuth: () => {}, + selectRuntime: () => ({ bunPath: process.execPath, source: "fixture" } as never), + assertRuntimeWritable: () => {}, launcherPathDiagnostic: () => null, + prepareDisconnectedClientRestart: async () => { calls.push("settle-client"); }, + repairLaunchd: () => { calls.push("start-standalone"); return { reloaded: true }; }, + }; + await repairService(deps); + expect(calls).toEqual(["settle-client", "start-standalone"]); +}); + +test("a Desktop ownership refusal precedes disconnected-client cleanup", async () => { + let touched = false; + const deps = { + platform: "darwin" as const, verb: "restart" as const, + inspectSupervision: () => ({ kind: "none" as const }), diagnose: () => installed, + readOwnership: () => ({ kind: "owned" as const, revision: 1, + ownership: { owner: "desktop" as const, installId: "fixture", consentGeneration: 1 } }), + prepareDisconnectedClientRestart: async () => { touched = true; }, + }; + await expect(repairService(deps)).rejects.toThrow("desktop app owns the runtime"); + expect(touched).toBe(false); +}); + +test("a surviving detached client exits before standalone can start", async () => { + const calls: string[] = []; + let running = true; + await prepareDisconnectedClientServiceRestart({ + disconnected: () => true, findLive: async () => ({ pid: 42, role: "client" }), + verify: pid => pid, alive: () => running, + beforeStop: () => { calls.push("verify-owner"); }, + stopManager: () => { calls.push("stop-manager"); }, + stop: async pid => { expect(pid).toBe(42); calls.push("stop-client"); running = false; }, + }); + expect(calls).toEqual(["verify-owner", "stop-manager", "verify-owner", "stop-client"]); + expect(running).toBe(false); +}); + +test.each(["connected", "standalone", "foreign", "pending"])("%s runtime is never killed and the restart goes ahead as before", async mode => { + const calls: string[] = []; + const invoke = () => prepareDisconnectedClientServiceRestart({ + disconnected: () => { + if (mode === "pending") throw new Error("client_disconnect_pending"); + return mode !== "connected"; + }, + findLive: async () => ({ pid: 42, role: mode === "standalone" ? "standalone" : "client" }), + verify: () => null, alive: () => true, + stopManager: () => { calls.push("manager"); }, stop: async () => { calls.push("kill"); }, + }); + // A proxy this home cannot prove is its own, and an interrupted disconnect, are not this + // cleanup's case: it touches nothing and lets the restart proceed as it did before. + await invoke(); + expect(calls).toEqual([]); +}); + +test("a reconnected client after the manager stop is preserved", async () => { + let disconnected = true, killed = false; + await expect(prepareDisconnectedClientServiceRestart({ + disconnected: () => disconnected, findLive: async () => ({ pid: 42, role: "client" }), + verify: pid => pid, alive: () => true, + stopManager: () => { disconnected = false; }, stop: async () => { killed = true; }, + })).rejects.toThrow("Client connection changed"); + expect(killed).toBe(false); +}); + +test("a stop that leaves the client alive refuses a replacement", async () => { + await expect(prepareDisconnectedClientServiceRestart({ + disconnected: () => true, findLive: async () => ({ pid: 42, role: "client" }), + verify: pid => pid, alive: () => true, stopManager: () => {}, stop: async () => {}, + })).rejects.toThrow("did not exit"); +}); + +test("default identity verification leaves a PID absent from this home's runtime record alone", async () => { + let touched = false; + const read = spyOn(processState, "readRuntimePort").mockReturnValue(null); + try { + await prepareDisconnectedClientServiceRestart({ + disconnected: () => true, findLive: async () => ({ pid: 42, role: "client" }), + alive: () => true, stopManager: () => { touched = true; }, stop: async () => { touched = true; }, + }); + expect(read).toHaveBeenCalledWith(); + expect(touched).toBe(false); + } finally { read.mockRestore(); } +}); + +test.each([true, false])("runtime record deletion independently verifies the surviving PID (verified: %s)", async verified => { + let record = true, running = true; + const calls: string[] = []; + const read = spyOn(processState, "readRuntimePort").mockImplementation(() => record ? { pid: 42, port: 10100 } : null); + const identity = spyOn(processState, "verifyPidIdentity").mockImplementation(pid => { + calls.push(`verify-${record ? "before" : "after"}`); + return record || verified ? pid : null; + }); + try { + const invoke = () => prepareDisconnectedClientServiceRestart({ + disconnected: () => true, findLive: async () => ({ pid: 42, role: "client" }), + alive: () => running, stopManager: () => { record = false; calls.push("manager"); }, + stop: async () => { calls.push("stop-pid"); running = false; }, + }); + if (verified) { + await invoke(); + expect(calls).toEqual(["verify-before", "manager", "verify-after", "stop-pid"]); + } else { + await expect(invoke()).rejects.toThrow("ownership is unverified"); + expect(calls).toEqual(["verify-before", "manager", "verify-after"]); + } + } finally { read.mockRestore(); identity.mockRestore(); } +}); + +test("a changed runtime record preserves the surviving PID", async () => { + let record = 42, stopped = false; + const read = spyOn(processState, "readRuntimePort").mockImplementation(() => ({ pid: record, port: 10100 })); + const identity = spyOn(processState, "verifyPidIdentity").mockImplementation(pid => pid); + try { + await expect(prepareDisconnectedClientServiceRestart({ + disconnected: () => true, findLive: async () => ({ pid: 42, role: "client" }), alive: () => true, + stopManager: () => { record = 99; }, stop: async () => { stopped = true; }, + })).rejects.toThrow("ownership is unverified"); + expect(stopped).toBe(false); + } finally { read.mockRestore(); identity.mockRestore(); } +}); diff --git a/tests/service/service-restart-registration-guard.test.ts b/tests/service/service-restart-registration-guard.test.ts new file mode 100644 index 00000000000..6eaef8b788d --- /dev/null +++ b/tests/service/service-restart-registration-guard.test.ts @@ -0,0 +1,100 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repairService, type RepairServiceDeps } from "../../src/service/repair"; +import { buildWindowsTaskXml } from "../../src/service/windows-taskxml"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import * as disconnectedCleanup from "../../src/service/disconnected-client-restart"; +import * as orchestration from "../../src/service/orchestration"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); +const sid = "S-1-5-21-111-222-333-1001", launcher = "C:\\fixture\\launcher.vbs"; +const healthy = buildWindowsTaskXml("fixture.cmd", launcher, undefined, sid); +const foreign = 'foreign.exe'; + +function fixture(read: () => string) { + const root = mkdtempSync(join(tmpdir(), "ocx-restart-registration-")); roots.push(root); + const effects: string[] = [], observations: string[] = []; + const deps: RepairServiceDeps = { + platform: "win32", verb: "restart", configDir: () => root, + inspectSupervision: () => ({kind: "none"}), + diagnose: () => ({supported: true, installed: true, enabled: true, running: true, viable: true, + startable: true, stale: false, conflict: false, backend: "scheduler", summary: "fixture"}), + readOwnership: () => ({kind: "none", revision: 0}), assertEnv: () => {}, assertAuth: () => {}, + selectRuntime: () => ({path: process.execPath, source: "override", overrideEnv: "OPENCODEX_BUN_PATH"}), + assertRuntimeWritable: () => {}, launcherPathDiagnostic: () => null, + readSchedulerXml: () => {observations.push("read"); return read();}, resolveExpectedUserId: () => sid, schedulerLauncher: launcher, + prepareDisconnectedClientRestart: async () => { observations.push("settle-client"); effects.push("settle-client"); }, + stopScheduler: () => {effects.push("stop");}, writeSchedulerAssets: () => {effects.push("assets");}, + killServiceWrappers: () => {}, schedulerProxyStillLive: async () => null, + startScheduler: () => {effects.push("start");}, writeSchedulerState: () => {effects.push("state");}, + reregisterScheduler: async () => {throw new Error("fixture must not reregister");}, settleSchedulerRead: () => {}, + }; + return {deps, effects, observations}; +} + +test.each(["empty", "throws", "foreign", "partial", "wrong-account"])("restart preserves %s registration before disconnected-client cleanup", async mode => { + const f = fixture(() => { + if (mode === "throws") throw new Error("unreadable registration"); + if (mode === "empty") return ""; + if (mode === "partial") return healthy.replace(/[\s\S]*?<\/Principals>/, ""); + if (mode === "wrong-account") return healthy.replaceAll(sid, "S-1-5-21-999-999-999-1002"); + return foreign; + }); + await expect(repairService(f.deps)).rejects.toThrow(); + expect(f.effects).toEqual([]); +}); + +test("full scheduler verification and fresh snapshot precede disconnected-client cleanup", async () => { + const f = fixture(() => healthy); + await repairService(f.deps); + expect(f.observations.slice(0, 3)).toEqual(["read", "read", "settle-client"]); + expect(f.effects).toEqual(["settle-client", "stop", "assets", "start", "state"]); +}); + +test.each(["changed", "unreadable"])("registration becomes %s before cleanup: no stop occurs", async mode => { + let reads = 0; + const f = fixture(() => ++reads === 1 ? healthy : mode === "changed" ? foreign : ""); + await expect(repairService(f.deps)).rejects.toThrow(); + expect(f.effects).toEqual([]); +}); + +test("ownership change during registration validation refuses before cleanup", async () => { + let revision = 0; + const f = fixture(() => healthy); + f.deps.readOwnership = () => ({kind: "none", revision}); + f.deps.resolveExpectedUserId = () => {revision++; return sid;}; + await expect(repairService(f.deps)).rejects.toThrow("ownership changed"); + expect(f.effects).toEqual([]); +}); + +test("replacement registration after client cleanup is preserved before normal scheduler stop", async () => { + let xml = healthy; + const f = fixture(() => xml); + f.deps.prepareDisconnectedClientRestart = async () => {xml = foreign;}; + await expect(repairService(f.deps)).rejects.toThrow(); + expect(f.effects).toEqual([]); +}); + +test.each(["registration", "ownership"])("default cleanup rechecks %s after resolving manager operations, before manager stop", async mode => { + let xml = healthy, revision = 0; + const f = fixture(() => xml); + f.deps.prepareDisconnectedClientRestart = undefined; + f.deps.readOwnership = () => ({kind: "none", revision}); + const cleanup = spyOn(disconnectedCleanup, "prepareDisconnectedClientServiceRestart").mockImplementation(async deps => { + deps.beforeStop?.(); + await deps.stopManager(); + }); + const operations = spyOn(orchestration, "platformOps").mockImplementation(() => { + if (mode === "registration") xml = foreign; + else revision++; + return {stop: () => {f.effects.push("manager-stop");}} as ReturnType; + }); + try { + await expect(repairService(f.deps)).rejects.toThrow(); + expect(operations).toHaveBeenCalledTimes(1); + expect(f.effects).toEqual([]); + } finally {operations.mockRestore(); cleanup.mockRestore();} +}); diff --git a/tests/service/service-scheduler-restart-cleanup.test.ts b/tests/service/service-scheduler-restart-cleanup.test.ts new file mode 100644 index 00000000000..24e4ce706ba --- /dev/null +++ b/tests/service/service-scheduler-restart-cleanup.test.ts @@ -0,0 +1,110 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repairService, type RepairServiceDeps } from "../../src/service/repair"; +import { buildWindowsTaskXml } from "../../src/service/windows-taskxml"; +import * as disconnectedCleanup from "../../src/service/disconnected-client-restart"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const sid = "S-1-5-21-111-222-333-1001", launcher = "C:\\fixture\\launcher.vbs"; +const healthy = buildWindowsTaskXml("fixture.cmd", launcher, undefined, sid); + +async function scenario(run: (deps: RepairServiceDeps, effects: string[]) => Promise) { + const root = mkdtempSync(join(tmpdir(), "ocx-scheduler-cleanup-")); + const effects: string[] = []; + const deps: RepairServiceDeps = { + platform: "win32", verb: "restart", configDir: () => root, + inspectSupervision: () => ({ kind: "none" }), + diagnose: () => ({ supported: true, installed: true, enabled: true, running: true, viable: true, + startable: true, stale: false, conflict: false, backend: "scheduler", summary: "fixture" }), + readOwnership: () => ({ kind: "none", revision: 0 }), assertEnv: () => {}, assertAuth: () => {}, + selectRuntime: () => ({ path: process.execPath, source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }), + assertRuntimeWritable: () => {}, launcherPathDiagnostic: () => null, + readSchedulerXml: () => healthy, resolveExpectedUserId: () => sid, schedulerLauncher: launcher, + prepareDisconnectedClientRestart: async () => {}, + stopScheduler: () => { effects.push("stop"); }, killServiceWrappers: () => { effects.push("wrappers"); }, + stopTrackedProxy: async () => { effects.push("tracked"); }, + schedulerProxyStillLive: async () => { effects.push("verify-stopped"); return null; }, + writeSchedulerAssets: () => { effects.push("assets"); }, startScheduler: () => { effects.push("start"); }, + writeSchedulerState: () => { effects.push("state"); }, + reregisterScheduler: async () => { throw new Error("unexpected reregister"); }, settleSchedulerRead: () => {}, + }; + try { await run(deps, effects); } finally { removeTreeWithRetry(root); } +} + +test("scheduler restart without a disconnected client cleans and verifies wrappers before replacement", async () => { + await scenario(async (deps, effects) => { + await repairService(deps); + expect(effects).toEqual(["stop", "wrappers", "tracked", "verify-stopped", "assets", "start", "state"]); + }); +}); + +test("a Home or connected Child proxy that survives schtasks /end is stopped by its tracked pid, and restart completes", async () => { + await scenario(async (deps, effects) => { + let trackedStopped = false; + deps.stopTrackedProxy = async () => { effects.push("tracked"); trackedStopped = true; }; + deps.schedulerProxyStillLive = async () => { effects.push("verify-stopped"); return trackedStopped ? null : { port: 10100 }; }; + await repairService(deps); + expect(effects).toEqual(["stop", "wrappers", "tracked", "verify-stopped", "assets", "start", "state"]); + }); +}); + +test("disconnected scheduler callback shares one guarded wrapper cleanup with ordinary restart", async () => { + await scenario(async (deps, effects) => { + deps.prepareDisconnectedClientRestart = undefined; + const cleanup = spyOn(disconnectedCleanup, "prepareDisconnectedClientServiceRestart").mockImplementation(async callbacks => { + await callbacks.stopManager(); + effects.push("client-exited"); + }); + try { + await repairService(deps); + expect(effects).toEqual(["stop", "wrappers", "client-exited", "tracked", "verify-stopped", "assets", "start", "state"]); + } finally { cleanup.mockRestore(); } + }); +}); + +test("surviving scheduler proxy refuses replacement after owned wrapper cleanup", async () => { + await scenario(async (deps, effects) => { + deps.schedulerProxyStillLive = async () => ({ port: 10100 }); + await expect(repairService(deps)).rejects.toThrow("still running"); + expect(effects).toEqual(["stop", "wrappers", "tracked"]); + }); +}); + +test.each(["ownership", "registration"])("scheduler %s change during stop preserves wrappers and replacement", async mode => { + await scenario(async (deps, effects) => { + let changed = false; + deps.readOwnership = () => ({ kind: "none", revision: changed && mode === "ownership" ? 1 : 0 }); + deps.readSchedulerXml = () => changed && mode === "registration" ? "" : healthy; + deps.stopScheduler = () => { effects.push("stop"); changed = true; }; + await expect(repairService(deps)).rejects.toThrow(); + expect(effects).toEqual(["stop"]); + }); +}); + +test("wrapper cleanup failure refuses scheduler replacement", async () => { + await scenario(async (deps, effects) => { + deps.killServiceWrappers = () => { throw new Error("wrapper cleanup refused"); }; + await expect(repairService(deps)).rejects.toThrow("wrapper cleanup refused"); + expect(effects).toEqual(["stop"]); + }); +}); + +test("unrecognized scheduler registration refuses before wrapper cleanup", async () => { + await scenario(async (deps, effects) => { + deps.readSchedulerXml = () => 'foreign.exe'; + await expect(repairService(deps)).rejects.toThrow("not a recognized legacy"); + expect(effects).toEqual([]); + }); +}); + +test("ownership changing during awaited stop verification refuses replacement", async () => { + await scenario(async (deps, effects) => { + let revision = 0; + deps.readOwnership = () => ({ kind: "none", revision }); + deps.schedulerProxyStillLive = async () => { revision++; return null; }; + await expect(repairService(deps)).rejects.toThrow("ownership changed"); + expect(effects).toEqual(["stop", "wrappers", "tracked"]); + }); +});