diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 0bab328853d..c4a80069fd7 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -92,7 +92,7 @@ A host that has both a Task Scheduler entry and a WinSW service is in a conflict ### The CLI -`npm install -g --allow-scripts=bun @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the +`npm install -g @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the Bun runtime it needs is bundled inside that package. There is no separate runtime to install and no WSL layer on Windows. diff --git a/README.md b/README.md index 263079fb129..db98afcaf0a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ and connect OpenCode, Pi, Qoder, WorkBuddy, Kilo, Cline, T3 Code and more throug

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -94,7 +94,7 @@ providers with Codex, Claude Code, Claude Desktop, Grok Build and the tools list ### Personal install (CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically +npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically ocx start # proxy + dashboard on localhost:10100 ``` @@ -253,7 +253,7 @@ they reach the npm package. For agents ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # or `ocx service` ocx init # interactive setup: writes ~/.opencodex/config.json and wires Codex ``` diff --git a/bin/ocx.mjs b/bin/ocx.mjs index ef5b11ad33d..72eff87dfcf 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -331,7 +331,7 @@ async function runPackageManagerSelfUpdate(manager, child) { const latestInvocation = managerInvocation(["view", `${PKG}@${selector}`, "version"]); const installArgs = manager === "pnpm" ? ["add", "-g", "--allow-build=bun", `${PKG}@${selector}`] - : ["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]; + : ["install", "-g", `${PKG}@${selector}`]; const installInvocation = managerInvocation(installArgs); if (!latestInvocation || !installInvocation) { console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`); diff --git a/docs-site/src/components/Landing.astro b/docs-site/src/components/Landing.astro index c7f68222199..99a3f92abb8 100644 --- a/docs-site/src/components/Landing.astro +++ b/docs-site/src/components/Landing.astro @@ -165,14 +165,14 @@ const providers: { label: string; path?: string }[] = [ ]; const quickstartMini = [ - { cmd: 'npm install -g --allow-scripts=bun @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') }, + { cmd: 'npm install -g @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') }, { cmd: 'ocx init', note: t('Add a provider and connect Codex', '프로바이더 추가 후 Codex 연결', '添加提供商并连接 Codex', 'Добавьте провайдера и подключите Codex', 'プロバイダーを追加して Codex を接続', '新增供應商並連接 Codex', 'Sağlayıcı ekleyip Codex’i bağlayın') }, { cmd: 'ocx start', note: t('Start the proxy', '프록시 시작', '启动代理', 'Запуск прокси', 'プロキシを起動', '啟動代理', "Proxy'yi başlatın") }, { cmd: 'ocx claude', note: t('In another terminal: launch Claude Code through ocx', '다른 터미널에서: ocx를 거쳐 Claude Code 실행', '在另一个终端中:通过 ocx 启动 Claude Code', 'В другом терминале: запуск Claude Code через ocx', '別のターミナルで: ocx 経由で Claude Code を起動', '在另一個終端機中:透過 ocx 啟動 Claude Code', 'Başka bir terminalde: Claude Code’u ocx üzerinden başlatın') }, ]; const quickstart = [ - { cmd: 'npm install -g --allow-scripts=bun @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') }, + { cmd: 'npm install -g @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') }, { cmd: 'ocx init', note: t('Add a provider and connect Codex', '프로바이더 추가 후 Codex 연결', '添加提供商并连接 Codex', 'Добавьте провайдера и подключите Codex', 'プロバイダーを追加して Codex を接続', '新增供應商並連接 Codex', 'Sağlayıcı ekleyip Codex’i bağlayın') }, { cmd: 'ocx start', note: t('Start the proxy', '프록시 시작', '启动代理', 'Запуск прокси', 'プロキシを起動', '啟動代理', "Proxy'yi başlatın") }, { cmd: 'codex "Write a hello world in Rust"', note: t('Codex now routes through ocx', 'Codex가 ocx를 경유', 'Codex 现在经由 ocx 路由', 'Codex теперь работает через ocx', 'Codex は ocx 経由に', 'Codex 現在經由 ocx 路由', 'Codex artık ocx üzerinden yönlendirilir') }, @@ -344,7 +344,7 @@ const docsMap = [

{t('Prefer the terminal?', '터미널이 더 편하다면', '更喜欢终端?', 'Предпочитаете терминал?', 'ターミナル派ですか?', '偏好終端機?', 'Terminali mi tercih edersiniz?')} - npm install -g --allow-scripts=bun @bitkyc08/opencodex + npm install -g @bitkyc08/opencodex {t('Installation guide', '설치 가이드', '安装指南', 'Руководство по установке', 'インストールガイド', '安裝指南', 'Kurulum kılavuzu')} {t('All releases and checksums', '전체 릴리스와 체크섬', '所有版本与校验和', 'Все релизы и контрольные суммы', 'すべてのリリースとチェックサム', '所有版本與校驗碼', 'Tüm sürümler ve sağlama toplamları')} {t('Desktop app guide', '데스크톱 앱 가이드', '桌面应用指南', 'Руководство по приложению', 'デスクトップアプリガイド', '桌面應用程式指南', 'Masaüstü uygulaması kılavuzu')} diff --git a/docs-site/src/content/docs/fr/getting-started/for-agents.md b/docs-site/src/content/docs/fr/getting-started/for-agents.md index 671d1d01e02..3e962903062 100644 --- a/docs-site/src/content/docs/fr/getting-started/for-agents.md +++ b/docs-site/src/content/docs/fr/getting-started/for-agents.md @@ -13,7 +13,7 @@ disponible pour une configuration interactive ; consultez le [tableau de bord w Installez le package publié et confirmez que `ocx` est sur `PATH` : ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/fr/getting-started/installation.md b/docs-site/src/content/docs/fr/getting-started/installation.md index 456907e4e9e..65fb3d91133 100644 --- a/docs-site/src/content/docs/fr/getting-started/installation.md +++ b/docs-site/src/content/docs/fr/getting-started/installation.md @@ -19,7 +19,7 @@ ChatGPT lorsqu’un modèle routé en a besoin. ## Installer ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` :::note[npm a-t-il bloqué le script postinstall de bun ?] @@ -52,7 +52,7 @@ les entrées du catalogue n’accordent aucun accès par elles-mêmes. N’utili tester des versions opencodex non encore publiées : ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index cf0dedd48f1..f909a13a450 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -150,14 +150,11 @@ synchronisation, même si le document géré ne change pas. Si le fichier change après votre examen, l'écriture est refusée car le plan est devenu obsolète. La boîte de dialogue remplace l'ancien plan par le nouveau et vous demande de confirmer à nouveau ; elle ne relance jamais -l'écriture automatiquement. Si l'aperçu indique qu'aucun catalogue de modèles n'est encore chargé, la boîte de -dialogue reste ouverte et propose **Charger le catalogue et réessayer** : elle relit la liste des intégrations, -puis redemande le plan. +l'écriture automatiquement. Si l'aperçu est temporairement indisponible, rechargez normalement la page et +recommencez l'action. Aside utilise le même flux d'aperçu et de confirmation pour un seul profil sélectionné à la fois. **Synchroniser -tous les profils** reste une action groupée distincte et n'est pas liée à un aperçu combiné unique. Un plan décrit le fichier d'un seul profil : la carte -de la Vue d'ensemble d'une intégration Aside en conflit propose donc **Choisir un profil**, et le bloc se -remplace depuis la page de ce profil. +tous les profils** reste une action groupée distincte et n'est pas liée à un aperçu combiné unique. ## À quoi s'attendre, en toute transparence diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index f760aafe574..fb00d4b6c07 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -39,7 +39,7 @@ remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du gestionnaire de mots de passe. -**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie d’appareil HttpOnly, SameSite=Strict et une preuve distincte stockée dans le localStorage de ce site. Le cookie est Secure en HTTPS et limité à l’hôte. Ni le cookie ni la preuve ne sont le jeton administrateur, et aucun ne fonctionne seul. Les cookies sont partagés entre les ports d’un même nom d’hôte ; la preuve, limitée à l’origine, lie l’appareil mémorisé à l’origine de ce tableau de bord. Préférez tout de même un nom d’hôte dédié. Le jeton administrateur n’est jamais écrit dans localStorage ou sessionStorage. Si le stockage du navigateur est indisponible ou ne peut enregistrer la preuve, la mémorisation est signalée comme indisponible. Pour un tableau de bord autonome derrière un proxy inverse qui termine TLS, OpenCodex voit une requête HTTP simple et refuse donc la mémorisation pour cette adresse ; la fenêtre de connexion l’explique et désactive l’option, et la connexion sans mémorisation fonctionne toujours. Un écouteur de gestion Hub de confiance fait exception et peut toujours mémoriser les appareils. +**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie d’appareil HttpOnly, SameSite=Strict et une preuve distincte stockée dans le localStorage de ce site. Le cookie est Secure en HTTPS et limité à l’hôte. Ni le cookie ni la preuve ne sont le jeton administrateur, et aucun ne fonctionne seul. Les cookies sont partagés entre les ports d’un même nom d’hôte ; la preuve, limitée à l’origine, lie l’appareil mémorisé à l’origine de ce tableau de bord. Préférez tout de même un nom d’hôte dédié. Le jeton administrateur n’est jamais écrit dans localStorage ou sessionStorage. Si le stockage du navigateur est indisponible ou ne peut enregistrer la preuve, la mémorisation est signalée comme indisponible. Derrière un proxy inverse qui termine TLS, OpenCodex voit une requête HTTP simple et refuse donc la mémorisation pour cette adresse ; la fenêtre de connexion l’explique et désactive l’option, et la connexion sans mémorisation fonctionne toujours. Le fichier privé dashboard-sessions.json conserve les empreintes du cookie, de la preuve, du jeton administrateur et de l’identité du navigateur, l’origine du serveur et une expiration fixe de 30 jours. La relance de l’application Safari reprend une session courte tant que cet appareil reste autorisé. Les sessions mémorisées ne peuvent pas approuver les actions de consentement : étoile GitHub, consommation de crédits de réinitialisation, modification des couches de prompt, connexion Meta Muse, changements de compatibilité du bureau ou validation des quotas. Utilisez une nouvelle session locale ou appairée pour ces actions. Les tableaux de bord relayés, inter-origines ou HTTP distant ne prennent pas en charge la mémorisation. L’expiration, un changement d’identité du navigateur ou la rotation du jeton suivie d’un redémarrage imposent une nouvelle connexion. diff --git a/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md b/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md index b0d3f4476db..cae4dce2521 100644 --- a/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/fr/troubleshooting/windows-memory.md @@ -44,4 +44,4 @@ Si vous essayez l’une de ces solutions avec une charge Windows réelle, veuill L’installation et la réparation du service Windows ainsi que l’installation et l’actualisation du shim Codex vérifient que l’exécutable sélectionné peut créer puis supprimer un répertoire dans la racine de configuration OpenCodex. Ce contrôle de politique d’écriture ne corrige pas la mémoire. Un échec refuse l’opération avant tout arrêt, téléchargement, préparation ou écriture de jeton ou de lanceur. La réparation automatique du shim est différée avec des instructions et le démarrage continue. Les shims sains ou désactivés ne sont pas testés. Aucun autre runtime n’est recherché. Une première installation du planificateur enregistre sa tâche et revendique la racine de configuration avant ce contrôle ; en cas de refus, cet enregistrement est annulé et la racine revendiquée est conservée pour une nouvelle tentative. -> Le runtime Bun sélectionné ne peut pas créer puis supprimer un répertoire de configuration. La politique d’application Windows peut interdire les écritures depuis cet emplacement. Avant de lancer `ocx`, définissez `OPENCODEX_BUN_PATH` vers un exécutable Bun de confiance autorisé par votre politique, ou réinstallez avec `npm install -g --allow-scripts=bun @bitkyc08/opencodex`, puis réessayez. Aucun autre runtime n’a été sélectionné. +> Le runtime Bun sélectionné ne peut pas créer puis supprimer un répertoire de configuration. La politique d’application Windows peut interdire les écritures depuis cet emplacement. Avant de lancer `ocx`, définissez `OPENCODEX_BUN_PATH` vers un exécutable Bun de confiance autorisé par votre politique, ou réinstallez avec `npm install -g @bitkyc08/opencodex`, puis réessayez. Aucun autre runtime n’a été sélectionné. diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md index 9bba3f1bfc1..b02c39812a5 100644 --- a/docs-site/src/content/docs/getting-started/for-agents.md +++ b/docs-site/src/content/docs/getting-started/for-agents.md @@ -13,7 +13,7 @@ available for interactive configuration; see [Web Dashboard](/guides/web-dashboa Install the published package and confirm that `ocx` is on `PATH`: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index a2660d4c128..7eab3cd9117 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -30,7 +30,7 @@ for one package invocation. That bypass does not repair the Desktop record, shim ## Install ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` With pnpm 10.4 or later: @@ -70,12 +70,6 @@ npm install -g --allow-scripts=bun @bitkyc08/opencodex # if the original install used sudo, keep using sudo: sudo npm install -g --allow-scripts=bun @bitkyc08/opencodex ``` - -On Windows a running proxy keeps `node_modules\bun\bin` open, so a manual -`npm install -g` fails with `EBUSY`. `ocx update` stops the proxy and restarts the service itself; -for a manual reinstall, stop it first (`ocx stop`), install, then restart through the owning service -(`ocx service restart`) or `ocx start` when unmanaged. `ocx doctor` prints this sequence when it finds -a package install whose bundled runtime never unpacked. ::: Verify both command aliases are on your `PATH`: @@ -122,7 +116,7 @@ the catalog entries do not grant access by themselves. Use the preview channel o unreleased opencodex builds: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 848e3da9c6c..0b75d49d6a4 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -445,7 +445,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -On macOS, opencodex checks the Codex app’s bundled CLI in `/Applications/ChatGPT.app` and then `~/Applications/ChatGPT.app` after explicit selections, shims, PATH, established installs, and the runnable bare `codex` fallback, requires the bundle’s XML `Info.plist` identity to be `com.openai.codex`, and never automatically replaces a runnable persisted selection with an app candidate. When no Codex CLI is found at all, the catalog probe behind `ocx connect status` readiness also tries those bundled locations, so a Child whose only Codex is the app’s bundled CLI reports its reasoning levels instead of `unverified`; every execution still passes the checks below. +On macOS, opencodex checks the Codex app’s bundled CLI in `/Applications/ChatGPT.app` and then `~/Applications/ChatGPT.app` after explicit selections, shims, PATH, established installs, and the runnable bare `codex` fallback, requires the bundle’s XML `Info.plist` identity to be `com.openai.codex`, and never automatically replaces a runnable persisted selection with an app candidate. Execution checks also recognize `Codex.app` and `ChatGPT.app` at any location, including relocated bundles, explicit `CODEX_CLI_PATH` or configured runtime commands, and symlink aliases. A recognized diff --git a/docs-site/src/content/docs/guides/custom-gateway.md b/docs-site/src/content/docs/guides/custom-gateway.md deleted file mode 100644 index 1b298fb0928..00000000000 --- a/docs-site/src/content/docs/guides/custom-gateway.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Verify a custom remote gateway -description: Check a custom Chat Completions gateway from discovery through a bounded completion. ---- - -Before registering a gateway, obtain its authorized Chat Completions base URL, API key, and -supported model IDs from its operator. For example, `https://api.example.com/v1` is a placeholder, -not a working endpoint. Start the proxy if it is not already running, then choose **Custom** in the -dashboard, name it `my-gateway`, select `openai-chat`, and enter the authorized URL and key in the -form. Save it without putting the key in commands or shared examples. A saved provider is not yet -proof that discovery or inference works. - -Check the boundaries separately, using the configured local port if it differs from the default: - -```sh -curl -fsS http://127.0.0.1:10100/healthz -ocx ready -ocx provider test my-gateway -ocx models live --provider my-gateway --json -``` - -Health and readiness describe the proxy; they do not prove the upstream can generate text. The -provider test without `--model` checks discovery, not inference. `applicable: false` can mean there -is no live discovery endpoint for a static catalog; it is not a successful inference test. A public -model list also need not validate the key. If discovery fails or no model appears, check the URL, -credentials, upstream access, and provider configuration before selecting a model. - -A gateway can serve completions without a model-list endpoint. If its operator supplies a supported -raw ID, configure `liveModels: false` and `models: ["vendor/model"]` on the existing provider, -preserving its other fields and replacing the example ID with the supplied value. See -[static model catalogs](/reference/configuration/providers/#static-model-allowlists). - -Before editing `config.json` directly, save your work and wait for active requests to finish; -stopping the proxy can interrupt them. Stop the proxy through its owner: quit OpenCodex Desktop -from its tray, run `ocx service stop` for a service install, or run `ocx stop` for an unsupervised -proxy. Confirm it is stopped, then edit and save the file without running other configuration -writers. Start it again through the same owner: reopen Desktop, run `ocx service start`, or run the -original `ocx start` command with its launch options, including `--port` if supplied. For a -foreground start, leave it running in its terminal and run `ocx sync` and the remaining checks from -another terminal. Then inspect the live catalog again. Alternatively, test the supplied raw ID directly -with the cost-confirmed completion command below; that test does not require a catalog row. - -In the live catalog inspect the actual `id`, `namespaced`, `disabled`, and -`initialSelectionPending` fields. Initial selection can leave models off or pending; see -[initial model selection](/reference/configuration/providers/#initial-model-selection). -Resolve pending initial selection before enabling a row. For visibility commands, combine the -provider name with the row's **raw `id`**, preserving any slashes inside that ID: - -```sh -ocx models enable 'my-gateway/' -``` - -For example, a raw `id` of `vendor/model` needs the selector `my-gateway/vendor/model`. -The client-facing `namespaced` slug may instead be `my-gateway/vendor-model`; substituting that -encoded slug can leave a raw disabled entry in place. Enabling one model does not disable others. -If only that model should be visible from this gateway, disable each other enabled gateway row -using its raw `id` in the same selector format, then inspect the list again: - -```sh -ocx models disable 'my-gateway/' -ocx models live --provider my-gateway --json -``` - -Check that the intended row has `disabled: false` and the other intended exclusions are disabled. -A bare native model ID can select a different route. Use exact values rather than display names or -guessed reasoning suffixes: a suffix may be a distinct upstream model ID, while reasoning effort -is a separate setting. See the [CLI reference](/reference/cli/providers-accounts/) for details. - -When needed, run `ocx sync` to update the client catalog, then confirm the model appears in the -Codex model picker. A running client can retain an older in-memory list. Preserve active work and, -if the picker remains stale, fully quit and reopen Codex. Alternatively, after explicit approval, -use [`ocx system codex-restart --yes`](/reference/cli/agents/): it can -interrupt the session and discard unsaved drafts, selections, or pending approval prompts. -Outside the direct config-file edit above, restarting a client or proxy is not the default recovery step. - -To verify one actual completion on a supported key-auth remote configuration, pass the **raw -upstream `id`** from the row or gateway operator unchanged, without adding the provider prefix: - -```sh -ocx provider test my-gateway --model '' -``` - -This sends one bounded request, consumes quota, and may incur charges. Confirm the default-No prompt -only after approving that cost; non-interactive or JSON use requires `--yes`. An -`applicable: false` completion result sends no completion and is not a pass, even if the command -exits zero. Before counting a successful test as a pass, check that the reported wire model -matches the intended upstream ID; an alias can resolve to a different model. In JSON output this -is `wireModel`. A successful text completion does not establish tool calling, structured output, -continuation behavior, or the model's actual identity; upstream-reported identity is metadata. For -later compatibility issues, reproduce the affected protocol separately with a sanitized fixture. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index d8759794fac..2463c8263ac 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -33,8 +33,6 @@ 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: @@ -481,15 +479,11 @@ though the managed document does not change. If the file changes after you review it, the write is refused as stale. The dialog replaces the old plan with the updated one and asks you to confirm again; it never retries the write -automatically. When a preview reports that no model catalog is loaded yet, the dialog stays open and -offers **Load catalog and retry**: it reads the integration list again — the ordinary load that -retains the catalog, never a preview that gathers models — and then asks for the plan once more. - -Aside uses the same preview and confirmation flow for one selected profile at a time, and a plan -describes exactly that profile's file. Because one plan cannot describe several independently -changing files, the Overview card for a conflicted Aside integration opens **Connect → Aside** with -**Choose profile** rather than a Replace action; replace the block on the profile's own page. **Sync -all profiles** remains a separate bulk action and is not bound to one combined preview. +automatically. If a preview is temporarily unavailable, reload the page normally and start the +action again. + +Aside uses the same preview and confirmation flow for one selected profile at a time. **Sync all +profiles** remains a separate bulk action and is not bound to one combined preview. ## What to expect, honestly diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 635150ee7e3..18dd72b84e5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1835,8 +1835,6 @@ references are preserved. Flattening is lossy: branch constraints disappear and later property definitions win, so tools must validate arguments. Existing Zen and Azure schema compatibility rules remain in effect. -See [Verify a custom remote gateway](/guides/custom-gateway/) for the setup and validation checklist. - ## Approval reviewer per provider Codex asks a second model to review approval requests, and takes that reviewer from @@ -1902,9 +1900,7 @@ Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, Command Code, and any a6api- or Abliteration custom provider. **Kimi Coding Plan quota.** Account login and API-key providers on the canonical -`https://api.kimi.com/coding/v1` endpoint read `/usages`. API-key providers on the regional -`https://api.kimi.ai/coding/v1` endpoint read `/usages` from that same origin; account login -stays on `api.kimi.com`. Plans that report +`https://api.kimi.com/coding/v1` endpoint read `/usages`. Plans that report `usages.limit_month_total` (or `monthTotal`) show a monthly utilization bar, with `limit_month_code` (or `monthCode`) as the fallback when total usage is unavailable. The `used_ratio` / `usedRatio` fraction must be in `[0, 1]` before conversion to a consumed diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index ebc6f881b5b..383f73c8fa9 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -107,45 +107,6 @@ 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`. - -```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. @@ -234,7 +195,7 @@ The check covers the intent folder and record. It does not protect against repla On the Home, run `ocx link probe child-a --json` to inspect the offered SSH fingerprint, key type and OpenCodex version. Verify the fingerprint through a trusted source. Then run `ocx link add child-a --expect-fingerprint SHA256:… --json`. The command probes again and refuses a mismatch before storing trust or issuing a key. Without the fingerprint flag, it requires an interactive terminal and an explicit `yes` after showing the fingerprint. It never prints the link key. -Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port and expose bound restart support. POSIX Children need curl; Windows uses a bounded loopback HTTP request through .NET. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows x64 Children use encoded PowerShell commands without consuming key stdin. The preflight reads `ocx config show --json` under the runner's 4 MiB capture limit, so a Child with a large saved configuration still accepts a link; output past that limit answers `child_preflight_output_limit` and names the command to trim. When a phase fails, `ocx link add` prints the phase together with the Child's own reason, for example an invalid saved configuration or the failing command's last error line. That reason is shortened and has OpenCodex keys and URL query strings removed. +Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port and expose bound restart support. POSIX Children need curl; Windows uses a bounded loopback HTTP request through .NET. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows x64 Children use encoded PowerShell commands without consuming key stdin. The preflight reads `ocx config show --json` under the runner's 4 MiB capture limit, so a Child with a large saved configuration still accepts a link; output past that limit answers `child_preflight_output_limit` and names the command to trim. ### Ephemeral Child machines (`ocx-child`) @@ -251,8 +212,6 @@ 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/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 7d3eb09c232..07c74760856 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -37,7 +37,7 @@ password manager can offer to save and autofill it. The dashboard itself still k in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. -**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie plus a separate device proof stored in this site's localStorage. HTTPS cookies are Secure and host-only. Neither the cookie nor the proof is the admin token, and neither works alone. Cookies are shared across ports of the same hostname; the origin-scoped proof binds the remembered device to this dashboard origin. Still prefer a dedicated hostname. The admin token is never written to localStorage or sessionStorage. If browser storage is unavailable or cannot save the proof, Remember reports unavailable. On a standalone dashboard behind a TLS-terminating reverse proxy, OpenCodex sees a plain HTTP request, so it refuses Remember for that address; the sign-in dialog says so and turns the option off, and signing in without Remember still works. A trusted Hub management listener is the exception and can still remember devices. +**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie plus a separate device proof stored in this site's localStorage. HTTPS cookies are Secure and host-only. Neither the cookie nor the proof is the admin token, and neither works alone. Cookies are shared across ports of the same hostname; the origin-scoped proof binds the remembered device to this dashboard origin. Still prefer a dedicated hostname. The admin token is never written to localStorage or sessionStorage. If browser storage is unavailable or cannot save the proof, Remember reports unavailable. Behind a TLS-terminating reverse proxy OpenCodex sees a plain HTTP request, so it refuses Remember for that address; the sign-in dialog says so and turns the option off, and signing in without Remember still works. The private dashboard-sessions.json stores hashes of the cookie, proof, admin credential and browser identity, the server origin and a fixed 30-day expiry. Relaunching the Safari home-screen app resumes a short-lived GUI session while that device record is valid. Remembered sessions cannot approve consent actions: GitHub stars, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes or quota validation. Use a fresh local or paired session for those actions. Cross-origin/relayed dashboards and remote plaintext HTTP do not support Remember. Expiry, a browser identity change, or rotating the admin token and restarting the server requires sign-in again. diff --git a/docs-site/src/content/docs/ja/getting-started/for-agents.md b/docs-site/src/content/docs/ja/getting-started/for-agents.md index db3261538f7..9eb2aa29574 100644 --- a/docs-site/src/content/docs/ja/getting-started/for-agents.md +++ b/docs-site/src/content/docs/ja/getting-started/for-agents.md @@ -10,7 +10,7 @@ description: ユーザー同意の境界を越えずに、エージェント主 公開されたパッケージをインストールし、`ocx` が `PATH` 上にあることを確認します。 ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index 3ac4677d744..49b50f7d37d 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -18,7 +18,7 @@ opencodex をインストールすると同じ実行ファイルを指す `ocx` ## インストール ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` :::note[npm が bun の postinstall をブロックした?] @@ -63,7 +63,7 @@ GPT-5.6 Sol/Terra/Luna カタログ情報がすでに含まれています。た ありません。まだ正式配布されていない opencodex ビルドを試す場合のみ preview チャネルを使ってください: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index 22dab73354b..e34ff7db555 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -100,9 +100,9 @@ Apply、Replace、Disable、Restore はプレビューから始まります。 計画に変更がない場合、管理対象のクライアント文書は要求した状態にすでになっています。選択された Aside プロファイルでは、文書に変更がなくても、確定によって同期の設定が保存される場合があります。 -確認後にファイルが変更されると、古い計画として書き込みが拒否されます。ダイアログは更新された計画に置き換えて再確認を求め、自動再試行はしません。プレビューがモデルカタログ未読み込みを報告した場合、ダイアログは開いたままで **カタログを読み込んで再試行** を表示します。統合一覧を読み直してから計画を再度取得します。 +確認後にファイルが変更されると、古い計画として書き込みが拒否されます。ダイアログは更新された計画に置き換えて再確認を求め、自動再試行はしません。プレビューが一時的に使えない場合は、通常どおりページを再読み込みして操作をやり直してください。 -Aside も選択したプロファイル 1 件ずつ同じプレビューと確認の手順を使います。**Sync all profiles** は別の一括操作であり、1 つの統合プレビューには結び付きません。1 つの計画は 1 プロファイルのファイルだけを表すため、競合している Aside の概要カードには **プロファイルを選択** が表示され、ブロックの置き換えはそのプロファイルのページで行います。 +Aside も選択したプロファイル 1 件ずつ同じプレビューと確認の手順を使います。**Sync all profiles** は別の一括操作であり、1 つの統合プレビューには結び付きません。 ## 実際に起こること diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 1f75438e846..416b97a13f8 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -27,7 +27,7 @@ bun run dev:gui リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 -**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict デバイス Cookie と、このサイトの localStorage に保存する別のデバイス証明に交換します。HTTPS の Cookie は Secure でホスト限定です。Cookie も証明も管理者トークンではなく、どちらも単独では機能しません。同じホスト名のポート間では Cookie が共有されるため、オリジン単位の証明が記憶したデバイスをこのダッシュボードのオリジンに結び付けます。それでも専用ホスト名を推奨します。管理者トークンは localStorage や sessionStorage に保存しません。ブラウザーのストレージが使えない、または証明を保存できない場合、記憶機能は利用不可と表示されます。スタンドアロンのダッシュボードを TLS を終端するリバースプロキシ経由で使う場合、 OpenCodex には通常の HTTP リクエストとして見えるため、そのアドレスでは記憶を拒否します。サインイン画面がその旨を表示してオプションをオフにし、記憶なしのサインインは引き続き使えます。信頼された Hub の管理リスナーは例外で、引き続きデバイスを記憶できます。 +**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict デバイス Cookie と、このサイトの localStorage に保存する別のデバイス証明に交換します。HTTPS の Cookie は Secure でホスト限定です。Cookie も証明も管理者トークンではなく、どちらも単独では機能しません。同じホスト名のポート間では Cookie が共有されるため、オリジン単位の証明が記憶したデバイスをこのダッシュボードのオリジンに結び付けます。それでも専用ホスト名を推奨します。管理者トークンは localStorage や sessionStorage に保存しません。ブラウザーのストレージが使えない、または証明を保存できない場合、記憶機能は利用不可と表示されます。TLS を終端するリバースプロキシ経由では OpenCodex には通常の HTTP リクエストとして見えるため、そのアドレスでは記憶を拒否します。サインイン画面がその旨を表示してオプションをオフにし、記憶なしのサインインは引き続き使えます。 非公開の dashboard-sessions.json は Cookie・証明・管理者認証情報・ブラウザー識別のハッシュ、サーバーのオリジンと固定30日間の有効期限を保存します。Safari ホーム画面アプリを再起動すると、有効なデバイス記録から短期セッションを再開します。記憶したセッションでは、GitHub スター、リセットクレジットの消費、プロンプト層の編集、Meta Muse サインイン、デスクトップ互換性の変更、クォータ検証などの同意操作を承認できません。これらには新しいローカルまたはペアリング済みセッションを使ってください。中継・別オリジン・リモートの平文 HTTP は記憶機能に対応しません。期限切れ、ブラウザー識別の変更、管理者トークン変更後のサーバー再起動では再ログインが必要です。 diff --git a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md index 3bdc80e3f68..774933a39f3 100644 --- a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md @@ -50,4 +50,4 @@ Windows では、#32111 クラッシュを回避するために、opencodex は Windows のサービスのインストール・修復と Codex shim のインストール・更新は、選択した実行ファイルが OpenCodex 設定ルート内でディレクトリを作成・削除できるか確認します。これはメモリ修正ではなく書き込みポリシーの確認です。失敗するとサービス停止、ダウンロード、ステージング、トークンやランチャーの書き込み前に拒否します。shim の自動修復は案内付きで延期され、起動は続行します。正常または無効な shim は検査しません。別のランタイムを自動探索しません。 初回のスケジューラーインストールでは、この確認の前にタスクを登録し設定ルートを確保します。拒否された場合は新しい登録を取り消し、確保したルートは再試行用に残します。 -> 選択した Bun ランタイムが設定ディレクトリ内でディレクトリを作成・削除できませんでした。Windows のアプリケーションポリシーがこの実行ファイルの位置からの書き込みを拒否している可能性があります。`ocx` 起動前にポリシーが許可する信頼できる Bun のパスを `OPENCODEX_BUN_PATH` に設定するか、`npm install -g --allow-scripts=bun @bitkyc08/opencodex` で再インストールして再試行してください。別のランタイムは選択していません。 +> 選択した Bun ランタイムが設定ディレクトリ内でディレクトリを作成・削除できませんでした。Windows のアプリケーションポリシーがこの実行ファイルの位置からの書き込みを拒否している可能性があります。`ocx` 起動前にポリシーが許可する信頼できる Bun のパスを `OPENCODEX_BUN_PATH` に設定するか、`npm install -g @bitkyc08/opencodex` で再インストールして再試行してください。別のランタイムは選択していません。 diff --git a/docs-site/src/content/docs/ko/getting-started/for-agents.md b/docs-site/src/content/docs/ko/getting-started/for-agents.md index b79ffdd7f06..4b6a07ded65 100644 --- a/docs-site/src/content/docs/ko/getting-started/for-agents.md +++ b/docs-site/src/content/docs/ko/getting-started/for-agents.md @@ -10,7 +10,7 @@ description: 사용자의 동의 경계를 넘지 않으면서 에이전트가 배포된 패키지를 설치하고 `ocx`가 `PATH`에 들어 있는지 확인합니다: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 909eb049228..4e2ec7dbdbe 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -18,7 +18,7 @@ opencodex를 설치하면 같은 실행 파일을 가리키는 `ocx`와 `opencod ## 설치 ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` :::note[npm이 bun postinstall을 차단했다면?] @@ -86,7 +86,7 @@ GPT-5.6 Sol/Terra/Luna 카탈로그 정보가 이미 들어 있습니다. 다만 아닙니다. 아직 정식 배포되지 않은 opencodex 빌드를 시험할 때만 preview 채널을 사용하세요: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index e5adb56b101..9de4a3d9e7f 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -213,7 +213,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -macOS에서는 명시적 선택, shim, PATH, 기존 설치 경로와 실행 가능한 기본 `codex` 후보 뒤에 `/Applications/ChatGPT.app`, `~/Applications/ChatGPT.app` 순서로 Codex 앱의 번들 CLI를 확인하며, 번들의 XML `Info.plist` 식별자가 `com.openai.codex`여야 하고 실행 가능한 저장된 선택을 앱 후보로 자동 교체하지 않습니다. Codex CLI가 전혀 발견되지 않으면 `ocx connect status` 준비 상태 뒤의 카탈로그 프로브도 해당 번들 위치를 시도하므로, 앱 번들 CLI만 있는 Child는 `unverified` 대신 지원 추론 수준을 보고합니다. 실행 시에는 아래 검사가 그대로 적용됩니다. +macOS에서는 명시적 선택, shim, PATH, 기존 설치 경로와 실행 가능한 기본 `codex` 후보 뒤에 `/Applications/ChatGPT.app`, `~/Applications/ChatGPT.app` 순서로 Codex 앱의 번들 CLI를 확인하며, 번들의 XML `Info.plist` 식별자가 `com.openai.codex`여야 하고 실행 가능한 저장된 선택을 앱 후보로 자동 교체하지 않습니다. 실행 시에는 위치와 관계없이 `Codex.app`과 `ChatGPT.app`을 인식하며, 이동한 번들, 명시적 `CODEX_CLI_PATH`·설정 런타임 명령, 심볼릭 링크 별칭에도 같은 검증을 적용합니다. 앱 이름은 검증 diff --git a/docs-site/src/content/docs/ko/guides/custom-gateway.md b/docs-site/src/content/docs/ko/guides/custom-gateway.md deleted file mode 100644 index 82cfd75dbc2..00000000000 --- a/docs-site/src/content/docs/ko/guides/custom-gateway.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: 사용자 지정 원격 게이트웨이 검증 -description: 모델 목록 조회부터 제한된 응답 생성까지 사용자 지정 게이트웨이를 확인합니다. ---- - -등록 전에 운영자로부터 사용이 허가된 Chat Completions 베이스 URL, API 키, 지원 모델 ID를 확보하세요. -예를 들어 `https://api.example.com/v1`은 실제 엔드포인트가 아닌 자리표시자입니다. 프록시가 꺼져 있다면 -시작한 다음 대시보드에서 **Custom**을 선택하고 이름을 `my-gateway`, 어댑터를 `openai-chat`으로 정한 뒤 -허가된 URL과 키를 입력하고 저장하세요. 키를 명령이나 공유 예시에 넣지 마세요. 저장 성공만으로 모델 목록 조회나 -추론 성공이 증명되지는 않습니다. - -각 단계를 따로 확인하세요. 기본 포트가 아니면 설정한 로컬 포트를 사용합니다. - -```sh -curl -fsS http://127.0.0.1:10100/healthz -ocx ready -ocx provider test my-gateway -ocx models live --provider my-gateway --json -``` - -Health와 readiness는 프록시 상태이며 업스트림의 텍스트 생성 성공을 뜻하지 않습니다. `--model` 없는 -provider test는 추론이 아닌 모델 목록 조회를 검사합니다. 정적 카탈로그에 실시간 모델 조회 엔드포인트가 없으면 -`applicable: false`가 나올 수 있으며 이는 추론 성공이 아닙니다. 공개 모델 목록의 성공도 키 유효성을 -증명하지 않을 수 있습니다. 모델 목록 조회가 실패하거나 모델이 없다면 선택 전에 URL, 자격 증명, 업스트림 접근 권한과 -프로바이더 설정을 확인하세요. - -게이트웨이는 모델 목록 엔드포인트가 없어도 응답을 생성할 수 있습니다. 운영자가 지원하는 원본 ID를 제공했다면 -기존 프로바이더의 다른 필드는 유지하고 `liveModels: false`, `models: ["vendor/model"]`을 설정하세요. -예시 ID는 제공받은 정확한 값으로 바꿉니다. [정적 모델 설정](/ko/reference/configuration/providers/)을 참고하세요. - -`config.json`을 직접 수정하기 전에 작업을 저장하고 진행 중인 요청이 끝날 때까지 기다리세요. 프록시를 중지하면 -진행 중인 요청이 중단될 수 있습니다. 관리 주체에 따라 Desktop 트레이에서 OpenCodex를 종료하거나, 서비스 설치는 -`ocx service stop`, 감독되지 않는 프록시는 `ocx stop`으로 중지하세요. 중지된 것을 확인한 뒤 다른 설정 쓰기 작업을 -실행하지 않는 상태에서 파일을 수정하고 저장합니다. 같은 관리 주체로 다시 시작하세요: Desktop을 다시 열거나, -`ocx service start`를 실행하거나, 기존 `ocx start` 명령과 실행 옵션을 사용합니다. 직접 지정한 `--port`도 -포함합니다. 포그라운드 시작 명령은 해당 터미널에서 계속 실행하고 다른 터미널에서 `ocx sync`와 나머지 검사를 -실행하세요. 그다음 live 카탈로그를 다시 확인합니다. 또는 아래 비용 확인을 거치는 응답 생성 명령에 -제공받은 원본 ID를 직접 사용할 수 있습니다. 이 테스트에는 카탈로그 행이 필요하지 않습니다. - -live 카탈로그에서 실제 `id`, `namespaced`, `disabled`, `initialSelectionPending`을 확인하세요. -초기 선택에 따라 모델이 꺼져 있거나 대기 상태일 수 있습니다. [초기 모델 선택](/ko/reference/configuration/providers/)을 -참고하세요. 행을 켜기 전에 초기 선택의 대기 상태를 해소합니다. 표시 관리 명령의 선택자는 프로바이더 이름과 행의 -**원본 `id`**를 조합하며, ID 내부의 슬래시를 그대로 유지합니다. - -```sh -ocx models enable 'my-gateway/<원본-업스트림-ID>' -``` - -예를 들어 원본 `id`가 `vendor/model`이면 선택자는 `my-gateway/vendor/model`입니다. -클라이언트용 `namespaced` 값은 `my-gateway/vendor-model`처럼 인코딩될 수 있습니다. 이를 대신 사용하면 -원본 ID로 저장된 비활성 항목이 해제되지 않을 수 있습니다. 모델 하나를 켜도 다른 모델은 꺼지지 않습니다. -이 게이트웨이에서 해당 모델만 표시하려면 같은 선택자 형식에 각 행의 원본 `id`를 넣어 다른 활성 모델을 끄고 -목록을 다시 확인하세요. - -```sh -ocx models disable 'my-gateway/<다른-원본-업스트림-ID>' -ocx models live --provider my-gateway --json -``` - -원하는 행은 `disabled: false`이고 제외하려는 다른 행은 비활성 상태인지 확인하세요. 접두사 없는 네이티브 모델 ID는 -다른 경로를 선택할 수 있습니다. 표시 이름이나 추측한 reasoning 접미사 대신 정확한 값을 쓰세요. 접미사는 별도 -업스트림 모델 ID일 수 있으며 reasoning 강도는 별도 설정입니다. 자세한 내용은 -[CLI 레퍼런스](/ko/reference/cli/providers-accounts/)를 참고하세요. - -필요하면 `ocx sync`로 클라이언트 카탈로그에 반영한 뒤 Codex 모델 선택기에 해당 모델이 나타나는지 확인하세요. -실행 중인 클라이언트는 이전 목록을 메모리에 유지할 수 있습니다. 진행 중인 작업을 보존하고 목록이 계속 오래된 상태라면 -Codex를 완전히 종료한 뒤 다시 여세요. 또는 명시적으로 승인한 뒤 -[`ocx system codex-restart --yes`](/ko/reference/cli/agents/)를 사용할 수 있습니다. -이 명령은 세션을 중단하고 저장하지 않은 초안, 선택 또는 대기 중인 승인 요청을 잃게 할 수 있습니다. -위의 설정 파일 직접 수정 외에는 클라이언트나 프록시 재시작이 기본 복구 단계가 아닙니다. - -지원되는 키 인증 원격 설정에서 실제 응답 생성 요청 하나를 검증하려면 행이나 게이트웨이 운영자로부터 얻은 -**업스트림 원본 `id`**를 프로바이더 접두사를 추가하지 않고 그대로 사용합니다. - -```sh -ocx provider test my-gateway --model '<원본-업스트림-ID>' -``` - -이 명령은 제한된 요청 하나를 보내며 쿼터를 사용하고 요금이 발생할 수 있습니다. 비용을 승인한 뒤에만 기본값이 -No인 확인 질문에 동의하세요. 비대화형 또는 JSON 사용에는 `--yes`가 필요합니다. 응답 생성 테스트의 -`applicable: false`는 요청을 보내지 않았다는 뜻이며 종료 코드가 0이어도 통과가 아닙니다. 성공한 테스트를 통과로 -판정하기 전에 보고된 wire 모델이 의도한 업스트림 ID와 일치하는지 확인하세요. 별칭이 다른 모델로 해석될 수 있습니다. -JSON 출력에서는 `wireModel`입니다. 텍스트 생성 성공 -한 번은 도구 호출, 구조화 출력, 연속 호출 또는 모델의 실제 정체를 증명하지 않습니다. 업스트림이 보고한 -모델명은 메타데이터입니다. 이후 호환성 문제는 비식별 fixture로 해당 프로토콜을 따로 재현하세요. diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index 58d8a14e361..db7efc81b07 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -76,8 +76,6 @@ 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 안에서 처리합니다. @@ -108,9 +106,9 @@ Apply, Replace, Disable, Restore는 미리 보기로 시작합니다. 대화 상 계획에 변경이 없다면 관리 대상 클라이언트 문서가 이미 요청한 상태라는 뜻입니다. Aside 프로필을 선택했다면 관리 문서가 바뀌지 않아도 확인 시 그 프로필의 동기화 기본 설정을 저장할 수 있습니다. -검토 후 파일이 바뀌면 오래된 계획으로 판단해 쓰기를 거부합니다. 대화 상자가 갱신된 계획을 보여주고 다시 확인을 요청하며, 자동으로 쓰기를 재시도하지 않습니다. 미리 보기가 모델 카탈로그가 아직 로드되지 않았다고 알리면 대화 상자는 열린 채로 **카탈로그 로드 후 다시 시도**를 제공합니다. 통합 목록을 다시 읽은 뒤 계획을 다시 요청합니다. +검토 후 파일이 바뀌면 오래된 계획으로 판단해 쓰기를 거부합니다. 대화 상자가 갱신된 계획을 보여주고 다시 확인을 요청하며, 자동으로 쓰기를 재시도하지 않습니다. 미리 보기를 일시적으로 사용할 수 없다면 페이지를 일반적으로 새로고침하고 작업을 다시 시작하세요. -Aside는 선택한 프로필 하나에 같은 미리 보기와 확인 흐름을 사용합니다. **Sync all profiles**는 별도 일괄 작업이며 하나의 통합 미리 보기에 묶이지 않습니다. 하나의 계획은 한 프로필의 파일만 설명하므로, 충돌 상태인 Aside 개요 카드는 **프로필 선택**을 제공하고 블록 교체는 해당 프로필 페이지에서 합니다. +Aside는 선택한 프로필 하나에 같은 미리 보기와 확인 흐름을 사용합니다. **Sync all profiles**는 별도 일괄 작업이며 하나의 통합 미리 보기에 묶이지 않습니다. ## 실제로 예상해야 할 동작 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 3c94de912b6..aa3b3f3aff1 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -561,7 +561,6 @@ opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은 (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …)는 [설정 레퍼런스](/ko/reference/configuration/)를 참고하세요. -설정과 검증 단계는 [사용자 지정 원격 게이트웨이 검증](/ko/guides/custom-gateway/)을 참고하세요. ### Antigravity 쿼터 조회 실패 확인 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 9afcb8e5521..21673c7cb45 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -71,45 +71,6 @@ 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를 직접 결정하세요. - -```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을 사용합니다. 이는 독립형 설치가 로컬 프로그램에 주는 것과 같은 신뢰 수준입니다. 다른 사이트의 웹 페이지는 거부됩니다. @@ -128,8 +89,6 @@ 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/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 46eb7ab9120..0c65fc98c23 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -27,7 +27,7 @@ bun run dev:gui 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. -**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 기기 쿠키와 이 사이트의 localStorage에 저장하는 별도의 기기 증명으로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 쿠키와 증명은 모두 관리자 토큰이 아니며, 어느 쪽도 단독으로 작동하지 않습니다. 같은 호스트명의 여러 포트는 쿠키를 공유하므로, 오리진별 저장소의 증명이 기억한 기기를 이 대시보드 오리진에 묶습니다. 그래도 전용 호스트명을 권장합니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 브라우저 저장소를 사용할 수 없거나 증명 저장에 실패하면 기억하기는 사용 불가로 표시됩니다. 독립 실행형 대시보드가 TLS를 종료하는 리버스 프록시 뒤에 있으면 OpenCodex가 일반 HTTP 요청으로 보기 때문에 해당 주소에서는 기억하기를 거부합니다. 로그인 창이 이를 안내하고 옵션을 끄며, 기억하기 없이 로그인하는 것은 계속 가능합니다. 신뢰된 Hub 관리 리스너는 예외이며 계속 기기를 기억할 수 있습니다. +**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 기기 쿠키와 이 사이트의 localStorage에 저장하는 별도의 기기 증명으로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 쿠키와 증명은 모두 관리자 토큰이 아니며, 어느 쪽도 단독으로 작동하지 않습니다. 같은 호스트명의 여러 포트는 쿠키를 공유하므로, 오리진별 저장소의 증명이 기억한 기기를 이 대시보드 오리진에 묶습니다. 그래도 전용 호스트명을 권장합니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 브라우저 저장소를 사용할 수 없거나 증명 저장에 실패하면 기억하기는 사용 불가로 표시됩니다. TLS를 종료하는 리버스 프록시 뒤에서는 OpenCodex가 일반 HTTP 요청으로 보기 때문에 해당 주소에서는 기억하기를 거부합니다. 로그인 창이 이를 안내하고 옵션을 끄며, 기억하기 없이 로그인하는 것은 계속 가능합니다. 비공개 dashboard-sessions.json에는 쿠키·증명·관리자 인증 정보·브라우저 식별 해시, 서버 오리진과 고정 30일 만료를 저장합니다. Safari 홈 화면 앱을 다시 열면 유효한 기기 기록으로 단기 세션을 재개합니다. 기억한 세션으로는 GitHub 스타, 리셋 크레딧 소비, 프롬프트 계층 편집, Meta Muse 로그인, 데스크톱 호환성 변경, 할당량 검증에 동의할 수 없습니다. 이 작업에는 새 로컬 세션이나 페어링한 세션을 사용하세요. 릴레이·다른 오리진·원격 평문 HTTP에서는 기억하기를 지원하지 않습니다. 만료, 브라우저 식별 변경, 관리자 토큰 교체 후 서버 재시작 시 다시 로그인해야 합니다. diff --git a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md index 2e0396dff32..1534ef83f4d 100644 --- a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md @@ -44,4 +44,4 @@ Windows에서는 opencodex가 #32111 충돌을 피하기 위해 스트리밍 응 Windows 서비스 설치·복구와 Codex shim 설치·갱신은 선택한 실행 파일이 OpenCodex 설정 루트 안에 디렉터리를 만들고 제거할 수 있는지 검사합니다. 메모리 수정이 아닌 쓰기 정책 검사입니다. 실패하면 서비스 중지, 다운로드, 스테이징, 토큰·실행 파일 쓰기 전에 거부합니다. shim 자동 복구는 안내와 함께 지연되며 시작은 계속됩니다. 정상 상태 또는 비활성화된 shim은 검사하지 않습니다. 다른 런타임을 자동으로 찾지 않습니다. 처음 하는 스케줄러 설치는 이 검사 전에 작업을 등록하고 설정 루트를 확보합니다. 거부되면 새 등록은 되돌리고, 확보한 루트는 재시도를 위해 남겨 둡니다. -> 선택한 Bun 런타임이 OpenCodex 설정 디렉터리 안에 디렉터리를 만들고 제거하지 못했습니다. Windows 애플리케이션 정책이 이 실행 파일 위치의 쓰기를 차단할 수 있습니다. `ocx` 실행 전에 정책이 허용하는 신뢰할 수 있는 Bun 실행 파일로 `OPENCODEX_BUN_PATH`를 설정하거나, `npm install -g --allow-scripts=bun @bitkyc08/opencodex`로 다시 설치한 뒤 재시도하세요. 다른 런타임은 선택하지 않았습니다. +> 선택한 Bun 런타임이 OpenCodex 설정 디렉터리 안에 디렉터리를 만들고 제거하지 못했습니다. Windows 애플리케이션 정책이 이 실행 파일 위치의 쓰기를 차단할 수 있습니다. `ocx` 실행 전에 정책이 허용하는 신뢰할 수 있는 Bun 실행 파일로 `OPENCODEX_BUN_PATH`를 설정하거나, `npm install -g @bitkyc08/opencodex`로 다시 설치한 뒤 재시도하세요. 다른 런타임은 선택하지 않았습니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index ed227c8abf5..26ecb707983 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -395,11 +395,6 @@ MiMo model Command Code serves. (`reasoning.summary: "none"`) keeps the provider default. Native Opus 5.5 on `api.anthropic.com` defaults to `display: "updates"`; explicit `reasoning.summary: "concise"` or `"detailed"` selects `"summarized"` without the updates beta. See [Opus 5.5 progress updates](/guides/providers/#claude-opus-55-progress-updates). - The provider or exact-model `thinkingDisplay` setting overrides that default: - `summarized` forces `display: "summarized"`, `updates` keeps progress commentary, and - `omitted` drops the display field so a Chat client receives no reasoning deltas during the - think. An explicit request summary still wins. See - [provider configuration](/reference/configuration/providers/). - **Structured output:** Responses `text.format` and Chat Completions `response_format` requests with `type: "json_schema"` become Anthropic `output_config.format`. The format merges into an existing adaptive-thinking output configuration, preserving a compatible `output_config.effort`. @@ -459,11 +454,7 @@ Use `liveModels: false` for a static catalog; Settings can switch between the tw - Tool-call ids are synthesized when Gemini omits them. Vertex and Antigravity preserve and replay opaque `thoughtSignature` values so tool-result continuations retain Gemini reasoning continuity. The signature cache is snapshotted to the config directory, so continuations also survive proxy - restarts. On Vertex and Antigravity, when upstream rejects a request with any HTTP 400, or with - `INVALID_ARGUMENT` or a signature error inside a 200 response, that conversation's in-memory - signature replay entry is cleared so the next turn does not replay it. A rejection whose text - names a signature also evicts the rejected signatures from the durable store; any other - rejection leaves durable entries in place. The rejected request itself is not retried. + restarts. - Antigravity's Gemini 4 Argon requests include the selected thinking tier when thought summaries are enabled, allowing its thought summaries to appear in supported clients. - **Malformed response shapes fail closed.** A claimed candidate, its `content`, or its diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index facaaaa2011..d9cca538c98 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -388,8 +388,6 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelSuppressSyntheticMax?` | `Record` | Catalog-only per-model switch. `true` prevents OpenCodex from adding a missing synthetic `max` rung while retaining a provider-declared `max` and continuing to add `ultra` to reasoning-capable ladders. A preserved row that already contains `max` keeps it until healthy discovery rebuilds that row, because the catalog does not persist whether an older `max` was synthetic. If a configured default names a suppressed missing `max`, the catalog falls back to the highest real rung below it. Codex uses the same ladder membership for the picker and explicit `spawn_agent` effort validation, so an explicit `max` spawn can fail client-side before proxy clamping; `ultra` remains the supported harness path. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | -| `thinkingDisplay?` | `"auto" \| "summarized" \| "updates" \| "omitted"` | Anthropic adaptive `thinking.display` policy for this provider. Unset and `auto` keep the built-in selection: native Claude Opus 5.5 on `api.anthropic.com` sends `updates` (progress commentary), and other adaptive models send `summarized`. `summarized` forces reasoning summaries for both. `updates` is honored only where the adapter already sent it — native Opus 5.5 — and is treated as `auto` for every other model. `omitted` sends adaptive thinking without a display field, matching hidden thinking; the stream then carries signature-only thinking blocks, so a Chat client sees only stream heartbeats during a long think. An explicit request `reasoning.summary` of `concise` or `detailed` still forces `summarized`. The dashboard provider editor and `PATCH /api/providers` accept `null` to clear it. `ocx config set providers..thinkingDisplay summarized` writes it directly. | -| `modelThinkingDisplay?` | `Record` | Per-model override of `thinkingDisplay`, keyed by the exact upstream model id (for example `claude-opus-5-5`). An entry always wins over the provider-wide value, including an explicit `auto` that returns one model to the built-in behavior; a model with no entry uses the provider value. The same value rules apply, including `updates` degrading to `auto` on models that never received that display. `PATCH /api/providers` merges entries and accepts `null` for one entry or the whole map to clear it. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; Alibaba Token Plan (Beijing) pins `qwen3.8-flash`, `qwen3.7-plus`, and `glm-5.3` to its native Responses API for Responses inbound only (live-verified on that gateway, including plaintext reasoning replay; Chat and Anthropic inbound stay on Chat, and the rest of the family can be opted in here); and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | | xAI Chat Completions (dashboard / CLI) | switch | Grok 4.5/4.6 OAuth Responses requests default to Responses. Existing Chat overrides are migrated once on upgrade; later Chat choices are preserved. Turn on to select Chat for both models, off to select Responses. CLI: `ocx provider edit xai --xai-chat on` or `--xai-chat off` (running proxy required). Mixed means only one model currently uses Chat. Other overrides and tier policy stay unchanged. API-key and translated Chat/Anthropic defaults are unchanged. Grok 4.7 defaults to Responses on OAuth through its registry wire default and can use Chat through an explicit `modelAdapters["grok-4.7"] = "openai-chat"` override. | | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index e4413ff306c..8e97df84875 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -61,7 +61,6 @@ route-specific results rather than repeating this table. | --- | --- | --- | | 401 | `opencodex admin token required` | The admin token or GUI session is missing, invalid, expired, origin-mismatched, or missing CSRF evidence | | 403 | `cross-origin request blocked` | The request origin is outside the management allowlist | -| 403 | `cross-origin request blocked` with `code: "remember_requires_direct_origin"` | `POST /api/session/remember` only: this dashboard address cannot hold a remembered device (for example a standalone dashboard behind a TLS-terminating proxy); sign in without Remember | | 404 | `not_found` | No management route matched the method and path | | 413 | `request body too large` | A POST, PUT, or PATCH body exceeds the 2 MiB management limit | | 503 | `management API unavailable` | Admin credential initialization or hardening is unavailable | diff --git a/docs-site/src/content/docs/ru/getting-started/for-agents.md b/docs-site/src/content/docs/ru/getting-started/for-agents.md index c443d881925..37917a311b3 100644 --- a/docs-site/src/content/docs/ru/getting-started/for-agents.md +++ b/docs-site/src/content/docs/ru/getting-started/for-agents.md @@ -14,7 +14,7 @@ description: Установите и используйте opencodex из аг Установите опубликованный пакет и убедитесь, что `ocx` доступен в `PATH`: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index b48185a1c0f..ca0fe20b3e4 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -19,7 +19,7 @@ opencodex устанавливает два эквивалентных имен ## Установка ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` :::note[npm заблокировал postinstall-скрипт bun?] @@ -66,7 +66,7 @@ ChatGPT, OpenAI по API-ключу, OpenRouter и эксперименталь Используйте канал preview только для тестирования ещё не выпущенных сборок opencodex: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index f55330c7478..6afaa08e162 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -272,15 +272,12 @@ Apply, Replace, Disable и Restore теперь начинаются с пред Если файл изменится после просмотра, запись будет отклонена как устаревшая. Диалог заменит старый план новым и попросит подтвердить ещё раз; автоматического -повтора записи не будет. Если предпросмотр сообщает, что каталог моделей ещё -не загружен, диалог остаётся открытым и предлагает **Загрузить каталог и повторить**: -он заново читает список интеграций и снова запрашивает план. +повтора записи не будет. Если предпросмотр временно недоступен, обычным способом +обновите страницу и начните действие заново. Для отдельного профиля Aside применяется тот же сценарий предпросмотра и подтверждения. **Sync all profiles** остаётся отдельным массовым действием -без одного общего предпросмотра. План описывает файл одного профиля, поэтому карточка -обзора для Aside в состоянии конфликта предлагает **Выбрать профиль**, а блок -заменяется на странице этого профиля. +без одного общего предпросмотра. ## Чего ожидать на практике diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 898f3d1e816..a0b34524047 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -30,7 +30,7 @@ bun run dev:gui Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. -**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер обменивает токен администратора на cookie устройства HttpOnly, SameSite=Strict и отдельное доказательство устройства в localStorage этого сайта. В HTTPS cookie имеет Secure и привязку к хосту. Ни cookie, ни доказательство не являются токеном администратора и не работают по отдельности. Cookie общие для портов одного имени хоста; доказательство в хранилище конкретного origin привязывает запомненное устройство к origin этой панели. Всё же предпочтительно отдельное имя хоста. Токен администратора не записывается в localStorage или sessionStorage. Если хранилище браузера недоступно или доказательство нельзя сохранить, запоминание сообщает о недоступности. Для автономной панели за обратным прокси, который завершает TLS, OpenCodex видит обычный HTTP-запрос и поэтому отказывает в запоминании для этого адреса; окно входа сообщает об этом и отключает опцию, а вход без запоминания по-прежнему работает. Исключение — доверенный управляющий слушатель Hub: он по-прежнему может запоминать устройства. +**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер обменивает токен администратора на cookie устройства HttpOnly, SameSite=Strict и отдельное доказательство устройства в localStorage этого сайта. В HTTPS cookie имеет Secure и привязку к хосту. Ни cookie, ни доказательство не являются токеном администратора и не работают по отдельности. Cookie общие для портов одного имени хоста; доказательство в хранилище конкретного origin привязывает запомненное устройство к origin этой панели. Всё же предпочтительно отдельное имя хоста. Токен администратора не записывается в localStorage или sessionStorage. Если хранилище браузера недоступно или доказательство нельзя сохранить, запоминание сообщает о недоступности. За обратным прокси, который завершает TLS, OpenCodex видит обычный HTTP-запрос и поэтому отказывает в запоминании для этого адреса; окно входа сообщает об этом и отключает опцию, а вход без запоминания по-прежнему работает. Приватный dashboard-sessions.json хранит хеши cookie, доказательства, учётных данных администратора и идентичности браузера, origin сервера и фиксированный срок 30 дней. Повторный запуск приложения Safari возобновляет короткую сессию, пока запись действительна. Запомненные сессии не могут подтверждать действия, требующие согласия: звезду GitHub, расход кредитов сброса, изменение слоёв промпта, вход Meta Muse, изменения совместимости настольного приложения или проверку квот. Для них используйте новую локальную или сопряжённую сессию. Релейный доступ, доступ с другого origin и удалённый HTTP без шифрования не поддерживают запоминание. Истечение срока, смена идентичности браузера или смена токена с перезапуском сервера требуют нового входа. diff --git a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md index 401ea3fd65e..67a7ea9c7c7 100644 --- a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md @@ -91,4 +91,4 @@ opencodex поставляет рантайм Bun (сейчас это **1.3.14* Установка и восстановление службы Windows, а также установка и обновление shim Codex проверяют, может ли выбранный исполняемый файл создать и удалить каталог внутри корня конфигурации OpenCodex. Это проверка политики записи, а не исправление памяти. При неудаче операция отклоняется до остановки службы, загрузки, подготовки или записи токена и запускающего файла. Автовосстановление shim откладывается с рекомендацией, запуск продолжается. Исправный или отключённый shim не проверяется. Поиск другой среды не выполняется. При первой установке через планировщик задача регистрируется и корень конфигурации закрепляется до этой проверки; при отказе новая регистрация откатывается, а закреплённый корень остаётся для повторной попытки. -> Выбранная среда Bun не смогла создать и удалить каталог конфигурации. Политика приложений Windows может запрещать запись из этого расположения. До запуска `ocx` задайте `OPENCODEX_BUN_PATH` на доверенный исполняемый файл Bun, разрешённый политикой, либо переустановите через `npm install -g --allow-scripts=bun @bitkyc08/opencodex` и повторите попытку. Другая среда не выбиралась. +> Выбранная среда Bun не смогла создать и удалить каталог конфигурации. Политика приложений Windows может запрещать запись из этого расположения. До запуска `ocx` задайте `OPENCODEX_BUN_PATH` на доверенный исполняемый файл Bun, разрешённый политикой, либо переустановите через `npm install -g @bitkyc08/opencodex` и повторите попытку. Другая среда не выбиралась. diff --git a/docs-site/src/content/docs/tr/getting-started/for-agents.md b/docs-site/src/content/docs/tr/getting-started/for-agents.md index 7e4f295b692..54e7023ccf2 100644 --- a/docs-site/src/content/docs/tr/getting-started/for-agents.md +++ b/docs-site/src/content/docs/tr/getting-started/for-agents.md @@ -15,7 +15,7 @@ kalır; bkz. [Web Kontrol Paneli](/tr/guides/web-dashboard/). Yayınlanan paketi kurun ve `ocx`'in `PATH` üzerinde olduğunu doğrulayın: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/tr/getting-started/installation.md b/docs-site/src/content/docs/tr/getting-started/installation.md index 4d410758294..14f26330d21 100644 --- a/docs-site/src/content/docs/tr/getting-started/installation.md +++ b/docs-site/src/content/docs/tr/getting-started/installation.md @@ -20,7 +20,7 @@ duyduğunda ChatGPT oturumunuzu da kullanabilir. ## Kurulum ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` :::note[npm bun postinstall betiğini engelledi mi?] @@ -54,7 +54,7 @@ erişim sağlamaz. Önizleme kanalını yalnızca yayınlanmamış opencodex derlemelerini test etmek için kullanın: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index ba9d2b916c7..460e359b560 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -181,14 +181,11 @@ bir Aside profilinde onaylama, yönetilen belge değişmese bile profilin eşitl Dosya siz inceledikten sonra değişirse yazma işlemi eski plan nedeniyle reddedilir. İletişim kutusu eski planı güncel planla değiştirir ve yeniden açıkça onaylamanızı ister; yazma işlemini hiçbir zaman -otomatik olarak yeniden denemez. Önizleme henüz model kataloğu yüklenmediğini bildirirse -iletişim kutusu açık kalır ve **Kataloğu yükle ve yeniden dene** seçeneğini sunar: entegrasyon listesini -yeniden okur ve planı tekrar ister. +otomatik olarak yeniden denemez. Önizleme geçici olarak kullanılamıyorsa sayfayı normal şekilde +yenileyin ve işlemi yeniden başlatın. Aside, aynı önizleme ve onay akışını bir defada seçili tek bir profil için kullanır. **Tüm profilleri -eşitle** ayrı bir toplu işlem olarak kalır ve tek bir birleşik önizlemeye bağlı değildir. Bir plan yalnızca bir profilin dosyasını -anlatır; bu yüzden çakışmadaki Aside genel bakış kartı **Profil seç** sunar ve blok o profilin -sayfasında değiştirilir. +eşitle** ayrı bir toplu işlem olarak kalır ve tek bir birleşik önizlemeye bağlı değildir. ## Dürüstçe ne beklenmeli? diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 6d61e4cf94c..a0ba1f0d84f 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -41,7 +41,7 @@ yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz; kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin kararıdır. -**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çerezi ve bu sitenin localStorage alanında saklanan ayrı bir cihaz kanıtıyla değiştirilir. HTTPS çerezi Secure ve ana makineyle sınırlıdır. Çerez de kanıt da yönetici belirteci değildir; hiçbiri tek başına çalışmaz. Aynı ana makine adının portları çerezleri paylaşır; origin kapsamındaki kanıt, hatırlanan cihazı bu panelin origin’ine bağlar. Yine de ayrı bir ana makine adı tercih edin. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Tarayıcı depolaması kullanılamıyorsa veya kanıt kaydedilemiyorsa Hatırla işlemi kullanılamadığını bildirir. TLS'i sonlandıran bir ters proxy arkasındaki bağımsız bir panelde OpenCodex düz bir HTTP isteği görür ve bu adres için Hatırla'yı reddeder; oturum açma penceresi bunu açıklar ve seçeneği kapatır, Hatırla olmadan oturum açmak çalışmaya devam eder. Güvenilen bir Hub yönetim dinleyicisi istisnadır ve cihazları hatırlamaya devam edebilir. +**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çerezi ve bu sitenin localStorage alanında saklanan ayrı bir cihaz kanıtıyla değiştirilir. HTTPS çerezi Secure ve ana makineyle sınırlıdır. Çerez de kanıt da yönetici belirteci değildir; hiçbiri tek başına çalışmaz. Aynı ana makine adının portları çerezleri paylaşır; origin kapsamındaki kanıt, hatırlanan cihazı bu panelin origin’ine bağlar. Yine de ayrı bir ana makine adı tercih edin. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Tarayıcı depolaması kullanılamıyorsa veya kanıt kaydedilemiyorsa Hatırla işlemi kullanılamadığını bildirir. TLS'i sonlandıran bir ters proxy arkasında OpenCodex düz bir HTTP isteği görür ve bu adres için Hatırla'yı reddeder; oturum açma penceresi bunu açıklar ve seçeneği kapatır, Hatırla olmadan oturum açmak çalışmaya devam eder. Özel dashboard-sessions.json çerezin, kanıtın, yönetici kimlik bilgisinin ve tarayıcı kimliğinin özetlerini, sunucu origin’ini ve sabit 30 günlük süreyi saklar. Safari ana ekran uygulaması yeniden açılınca geçerli cihaz kaydı kısa bir oturumu yeniler. Hatırlanan oturumlar onay gerektiren işlemleri yetkilendiremez: GitHub yıldızı, sıfırlama kredisi tüketimi, istem katmanı düzenlemeleri, Meta Muse girişi, masaüstü uyumluluk değişiklikleri veya kota doğrulaması. Bunlar için yeni bir yerel veya eşleştirilmiş oturum kullanın. Relay, başka origin ve uzak düz HTTP Hatırla seçeneğini desteklemez. Süre dolması, tarayıcı kimliği değişmesi veya belirtecin değiştirilip sunucunun yeniden başlatılması yeniden giriş gerektirir. diff --git a/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md b/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md index 5f09debff7c..260b35816b8 100644 --- a/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/tr/troubleshooting/windows-memory.md @@ -111,4 +111,4 @@ doğrulama tam olarak budur. Windows hizmet kurulumu ve onarımı ile Codex shim kurulumu ve yenilemesi, seçilen yürütülebilir dosyanın OpenCodex yapılandırma kökünde bir dizin oluşturup kaldırabildiğini denetler. Bu bir yazma politikası denetimidir; bellek düzeltmesi değildir. Başarısızlık hizmet durdurma, indirme, hazırlama, belirteç veya başlatıcı yazma işlemlerinden önce reddedilir. Otomatik shim onarımı yönlendirmeyle ertelenir ve başlangıç devam eder. Sağlıklı veya devre dışı shim denetlenmez. Başka çalışma zamanı aranmaz. İlk zamanlayıcı kurulumu bu denetimden önce görevini kaydeder ve yapılandırma kökünü sahiplenir; ret durumunda yeni kayıt geri alınır ve sahiplenilen kök yeniden deneme için kalır. -> Seçilen Bun çalışma zamanı yapılandırma dizininde bir dizin oluşturup kaldıramadı. Windows uygulama politikası bu yürütülebilir konumundan yazmayı engelleyebilir. `ocx` başlatılmadan önce `OPENCODEX_BUN_PATH` değerini politikanızın izin verdiği güvenilir Bun yürütülebilir dosyasına ayarlayın veya `npm install -g --allow-scripts=bun @bitkyc08/opencodex` ile yeniden kurup tekrar deneyin. Başka çalışma zamanı seçilmedi. +> Seçilen Bun çalışma zamanı yapılandırma dizininde bir dizin oluşturup kaldıramadı. Windows uygulama politikası bu yürütülebilir konumundan yazmayı engelleyebilir. `ocx` başlatılmadan önce `OPENCODEX_BUN_PATH` değerini politikanızın izin verdiği güvenilir Bun yürütülebilir dosyasına ayarlayın veya `npm install -g @bitkyc08/opencodex` ile yeniden kurup tekrar deneyin. Başka çalışma zamanı seçilmedi. diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 50bb093fd21..65ee95c07ab 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -146,4 +146,4 @@ the verification this mitigation is waiting on. Windows service installation, repair and Codex shim installation/refresh check whether the selected executable can create and remove a directory inside the OpenCodex config root. This is a write-policy check, not a memory fix. Failure refuses before service stops, downloads, staging, token or launcher writes. Automatic shim repair is deferred with guidance and startup continues. Healthy or disabled shims do not probe. No alternate runtime is discovered. A first-time scheduler install registers its task and claims the config root before this check; on refusal that new registration is rolled back and the claimed root stays for a retry. -> The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. Windows application policy may deny writes from this executable location. Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, or reinstall opencodex with npm install -g --allow-scripts=bun @bitkyc08/opencodex, then retry. No alternate runtime was selected. +> The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. Windows application policy may deny writes from this executable location. Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected. diff --git a/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md b/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md index 182b79a38ab..80970e9601c 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md @@ -10,7 +10,7 @@ description: 为受代理驱动或脚本控制的终端安装并操作 opencodex 安装已发布的包,并确认 `ocx` 已在 `PATH` 中: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index e4d017e72b3..335debd845d 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -18,7 +18,7 @@ vision 和网络搜索 sidecar 也可以使用你的 ChatGPT 登录凭据。 ## 安装 ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` :::note[npm 拦截了 bun postinstall?] @@ -61,7 +61,7 @@ GPT-5.6 Sol/Terra/Luna 目录信息,但这些条目本身不会授予上游模 opencodex 构建时,才需要使用 preview 渠道: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index e807acd44e9..05d5718e975 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -149,7 +149,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 macOS 上,opencodex 在显式选择、shim、PATH、既有安装路径和可运行的裸 `codex` 后备命令之后,依次检查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 应用内置 CLI,要求其 XML `Info.plist` 标识为 `com.openai.codex`,且不会用应用候选项自动替换仍可运行的已保存选择。 如果完全没有发现 Codex CLI,则 `ocx connect status` 就绪状态背后的目录探测也会尝试这些内置位置,因此仅装有应用内置 CLI 的 Child 会报告其支持的推理级别,而不是 `unverified`;执行时仍会应用下述检查。 +在 macOS 上,opencodex 在显式选择、shim、PATH、既有安装路径和可运行的裸 `codex` 后备命令之后,依次检查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 应用内置 CLI,要求其 XML `Info.plist` 标识为 `com.openai.codex`,且不会用应用候选项自动替换仍可运行的已保存选择。 执行检查还会识别任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移动后的应用包、显式 `CODEX_CLI_PATH` 或配置的运行时命令,以及符号链接别名。名称只触发验证,不代表可信。 diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index 46d0cfd5cbb..6b8c37a9f81 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -100,9 +100,9 @@ Apply、Replace、Disable 和 Restore 都先显示预览。对话框准确列出 如果计划显示没有变更,说明托管客户端文档已处于请求的状态。对于选中的 Aside 配置文件,即使托管文档不变,确认操作仍可能保存该配置文件的同步偏好。 -如果检查后文件发生变化,写入会因计划过期而被拒绝。对话框会用更新后的计划替换旧计划,并要求再次确认;绝不会自动重试写入。如果预览提示尚未加载模型目录,对话框会保持打开并提供 **加载目录并重试**:它会重新读取集成列表,然后再次请求计划。 +如果检查后文件发生变化,写入会因计划过期而被拒绝。对话框会用更新后的计划替换旧计划,并要求再次确认;绝不会自动重试写入。如果预览暂时不可用,请正常重新加载页面,再发起操作。 -Aside 对单个选中配置文件采用相同的预览和确认流程。**Sync all profiles** 仍是单独的批量操作,不与一份合并预览绑定。一份计划只描述一个配置文件的文件,因此处于冲突状态的 Aside 概览卡片会提供 **选择配置文件**,请在该配置文件页面替换配置块。 +Aside 对单个选中配置文件采用相同的预览和确认流程。**Sync all profiles** 仍是单独的批量操作,不与一份合并预览绑定。 ## 实际效果与限制 diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index f68326e0d74..091073fa25d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui 远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 -**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie,以及单独存入本站 localStorage 的设备证明。HTTPS 下 Cookie 启用 Secure 且仅限本主机。Cookie 和证明都不是管理员令牌,且都不能单独使用。同一主机名的不同端口共享 Cookie;按来源隔离的证明将记住的设备绑定到本仪表板来源。仍建议使用专用主机名。管理员令牌不存入 localStorage 或 sessionStorage。浏览器存储不可用或无法保存证明时,记住设备会报告不可用。对于位于终止 TLS 的反向代理之后的独立仪表板,OpenCodex 看到的是普通 HTTP 请求,因此会拒绝为该地址记住设备;登录对话框会说明原因并关闭该选项,不勾选记住设备仍可正常登录。受信任的 Hub 管理监听器例外,仍可记住设备。 +**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie,以及单独存入本站 localStorage 的设备证明。HTTPS 下 Cookie 启用 Secure 且仅限本主机。Cookie 和证明都不是管理员令牌,且都不能单独使用。同一主机名的不同端口共享 Cookie;按来源隔离的证明将记住的设备绑定到本仪表板来源。仍建议使用专用主机名。管理员令牌不存入 localStorage 或 sessionStorage。浏览器存储不可用或无法保存证明时,记住设备会报告不可用。在终止 TLS 的反向代理之后,OpenCodex 看到的是普通 HTTP 请求,因此会拒绝为该地址记住设备;登录对话框会说明原因并关闭该选项,不勾选记住设备仍可正常登录。 私有 dashboard-sessions.json 保存 Cookie、证明、管理员凭据和浏览器标识的哈希、服务器来源及固定30天有效期。重新打开 Safari 主屏幕应用时,有效设备记录可恢复短期会话。记住的会话不能批准需要同意的操作:GitHub 星标、重置额度消费、提示词层编辑、Meta Muse 登录、桌面兼容性更改或配额验证。这些操作请使用新的本地会话或已配对会话。中继、跨来源和远程明文 HTTP 不支持记住设备。过期、浏览器标识改变,或更换管理员令牌并重启服务器后需重新登录。 diff --git a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md index bde42e03b21..b77e5f2d7c1 100644 --- a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md @@ -44,4 +44,4 @@ opencodex 打包了 Bun 运行时(当前为 **1.3.14**)。这类内存增长 Windows 服务安装、修复及 Codex shim 安装、刷新会检查所选可执行文件能否在 OpenCodex 配置根目录内创建并删除目录。这是写入策略检查,并非内存修复。失败时会在停止服务、下载、暂存以及写入令牌或启动器前拒绝操作。shim 自动修复会延后并显示指引,启动继续。正常或禁用的 shim 不会检查。不自动寻找其他运行时。 首次计划任务安装会在此检查前注册任务并认领配置根目录;被拒绝时会回滚新注册,已认领的根目录保留以便重试。 -> 所选 Bun 运行时无法在配置目录内创建并删除目录。Windows 应用策略可能禁止来自此可执行文件位置的写入。在启动 `ocx` 前,将 `OPENCODEX_BUN_PATH` 设置为策略允许的可信 Bun 可执行文件,或通过 `npm install -g --allow-scripts=bun @bitkyc08/opencodex` 重新安装后重试。未选择其他运行时。 +> 所选 Bun 运行时无法在配置目录内创建并删除目录。Windows 应用策略可能禁止来自此可执行文件位置的写入。在启动 `ocx` 前,将 `OPENCODEX_BUN_PATH` 设置为策略允许的可信 Bun 可执行文件,或通过 `npm install -g @bitkyc08/opencodex` 重新安装后重试。未选择其他运行时。 diff --git a/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md b/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md index 1438fab0353..174f4447fcc 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md @@ -10,7 +10,7 @@ description: 從 agent 驅動或腳本化終端機安裝並操作 opencodex, 安裝已發布的套件,並確認 `ocx` 在 `PATH` 上: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx --version ``` diff --git a/docs-site/src/content/docs/zh-tw/getting-started/installation.md b/docs-site/src/content/docs/zh-tw/getting-started/installation.md index 1e2e1018e4b..f954cb7175a 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/installation.md @@ -18,7 +18,7 @@ vision 和網路搜尋 sidecar 也可以使用你的 ChatGPT 登入憑證。 ## 安裝 ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ``` :::note[npm 攔截了 bun postinstall?] @@ -50,7 +50,7 @@ GPT-5.6 Sol/Terra/Luna 目錄資訊,但這些條目本身不會授予上游模 opencodex 建置時,才需要使用 preview 渠道: ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex@preview +npm install -g @bitkyc08/opencodex@preview ocx update --tag preview ``` diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 2d00f3344aa..e8eded5bcaf 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -146,7 +146,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 macOS 上,opencodex 在明確選擇、shim、PATH、既有安裝路徑和可執行的裸 `codex` 後備命令之後,依序檢查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 應用程式內建 CLI,要求其 XML `Info.plist` 識別碼為 `com.openai.codex`,且不會用應用程式候選項自動取代仍可執行的已儲存選擇。 若完全找不到 Codex CLI,`ocx connect status` 就緒狀態背後的目錄探測也會嘗試這些內建位置,因此僅有應用程式內建 CLI 的 Child 會回報其支援的推理等級,而不是 `unverified`;執行時仍會套用下列檢查。 +在 macOS 上,opencodex 在明確選擇、shim、PATH、既有安裝路徑和可執行的裸 `codex` 後備命令之後,依序檢查 `/Applications/ChatGPT.app` 和 `~/Applications/ChatGPT.app` 中的 Codex 應用程式內建 CLI,要求其 XML `Info.plist` 識別碼為 `com.openai.codex`,且不會用應用程式候選項自動取代仍可執行的已儲存選擇。 執行檢查也會辨識任意位置的 `Codex.app` 和 `ChatGPT.app`,包括移動後的應用程式套件、明確的 `CODEX_CLI_PATH` 或設定的執行階段命令,以及符號連結別名。名稱只觸發驗證,不代表可信。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 30eb97ce21d..e4b070287aa 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -104,12 +104,11 @@ Hermes 的會話標識升級是上述衝突規則的特例:既有受管設定 即使受管理的文件沒有變更,確認後仍可能儲存該設定檔的同步偏好。 如果檔案在你檢視後又有變更,寫入會因計畫過期而被拒絕。對話框會用更新後的計畫取代舊 -計畫,並要求你再次明確確認;它絕不會自動重試寫入。如果預覽表示尚未載入模型目錄,對話框會保持開啟並 -提供 **載入目錄並重試**:它會重新讀取整合清單,再重新要求計畫。 +計畫,並要求你再次明確確認;它絕不會自動重試寫入。如果預覽暫時無法使用,請正常重新 +載入頁面,再重新開始該操作。 Aside 會對一次選取的單一設定檔使用相同的預覽與確認流程。**同步所有設定檔**仍是獨立的 -批次操作,不會綁定到單一合併預覽。一份計畫只描述一個設定檔的檔案, -因此處於衝突狀態的 Aside 概覽卡片會提供 **選擇設定檔**,請在該設定檔頁面取代設定區塊。 +批次操作,不會綁定到單一合併預覽。 ## 誠實的預期 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 175483cac74..64b8349176b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -31,7 +31,7 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全 由瀏覽器或密碼管理員決定。 -**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie,以及另外存入本站 localStorage 的裝置證明。HTTPS 下 Cookie 使用 Secure,且僅限本主機。Cookie 和證明都不是管理員權杖,且都不能單獨使用。同一主機名稱的不同連接埠共用 Cookie;依來源隔離的證明將記住的裝置綁定至本儀表板來源。仍建議使用專用主機名稱。管理員權杖不存入 localStorage 或 sessionStorage。瀏覽器儲存空間無法使用或無法儲存證明時,記住裝置會回報無法使用。對於位於終止 TLS 的反向代理後方的獨立儀表板,OpenCodex 看到的是一般 HTTP 請求,因此會拒絕為該位址記住裝置;登入對話框會說明原因並關閉該選項,不勾選記住裝置仍可正常登入。受信任的 Hub 管理監聽器例外,仍可記住裝置。 +**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie,以及另外存入本站 localStorage 的裝置證明。HTTPS 下 Cookie 使用 Secure,且僅限本主機。Cookie 和證明都不是管理員權杖,且都不能單獨使用。同一主機名稱的不同連接埠共用 Cookie;依來源隔離的證明將記住的裝置綁定至本儀表板來源。仍建議使用專用主機名稱。管理員權杖不存入 localStorage 或 sessionStorage。瀏覽器儲存空間無法使用或無法儲存證明時,記住裝置會回報無法使用。在終止 TLS 的反向代理後方,OpenCodex 看到的是一般 HTTP 請求,因此會拒絕為該位址記住裝置;登入對話框會說明原因並關閉該選項,不勾選記住裝置仍可正常登入。 私有 dashboard-sessions.json 儲存 Cookie、證明、管理員憑證和瀏覽器識別的雜湊、伺服器來源及固定30天有效期。重新開啟 Safari 主畫面應用程式時,有效裝置記錄可恢復短期工作階段。記住的工作階段無法核准需要同意的操作:GitHub 星號、重設額度消耗、提示詞層編輯、Meta Muse 登入、桌面相容性變更或配額驗證。這些操作請使用新的本機工作階段或已配對工作階段。中繼、跨來源和遠端明文 HTTP 不支援記住裝置。過期、瀏覽器識別變更,或更換管理員權杖並重新啟動伺服器後需重新登入。 diff --git a/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md index bc8cdf7e7e3..58f1d32c023 100644 --- a/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/zh-tw/troubleshooting/windows-memory.md @@ -44,4 +44,4 @@ opencodex 內嵌 Bun runtime(目前為 **1.3.14**)。記憶體成長由已 Windows 服務安裝、修復及 Codex shim 安裝、更新會檢查所選執行檔能否在 OpenCodex 設定根目錄內建立並移除目錄。這是寫入政策檢查,而非記憶體修復。失敗時會在停止服務、下載、暫存以及寫入權杖或啟動器前拒絕操作。shim 自動修復會延後並顯示指引,啟動繼續。正常或停用的 shim 不會檢查。不自動尋找其他 runtime。 首次排程安裝會在此檢查前登錄工作並認領設定根目錄;被拒絕時會復原新登錄,已認領的根目錄保留以便重試。 -> 所選 Bun runtime 無法在設定目錄內建立並移除目錄。Windows 應用程式政策可能禁止來自此執行檔位置的寫入。在啟動 `ocx` 前,將 `OPENCODEX_BUN_PATH` 設為政策允許的可信 Bun 執行檔,或以 `npm install -g --allow-scripts=bun @bitkyc08/opencodex` 重新安裝後重試。未選擇其他 runtime。 +> 所選 Bun runtime 無法在設定目錄內建立並移除目錄。Windows 應用程式政策可能禁止來自此執行檔位置的寫入。在啟動 `ocx` 前,將 `OPENCODEX_BUN_PATH` 設為政策允許的可信 Bun 執行檔,或以 `npm install -g @bitkyc08/opencodex` 重新安裝後重試。未選擇其他 runtime。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2c8b21ff32c..78c029bba4f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1986,10 +1986,6 @@ export const de: Record = { "integrations.preview.loading": "Der genaue Änderungsplan wird geladen…", "integrations.preview.failed": "Der Änderungsplan konnte nicht geladen werden. Es wurde nichts geändert.", "integrations.preview.stale": "Die Integration wurde während der Bestätigung geändert. Prüfe den aktualisierten Plan und bestätige erneut.", - "integrations.preview.catalogUnavailable": "Der Modellkatalog ist noch nicht geladen, daher kann diese Änderung nicht geplant werden. Lade ihn, um fortzufahren.", - "integrations.preview.loadCatalogRetry": "Katalog laden und erneut versuchen", - "integrations.preview.recovering": "Katalog wird geladen…", - "integrations.action.chooseProfile": "Profil wählen", "integrations.dialog.apply.title": "Diese Integration anwenden?", "integrations.dialog.apply.changes": "opencodex fügt der Client-Konfiguration seine verwalteten Anbieter-Einstellungen hinzu.", "integrations.dialog.apply.breakage": "Der Client verwendet die Modelle, die diese opencodex-Instanz derzeit bereitstellt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 31adb533d58..6fa11a26f50 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2624,10 +2624,6 @@ export const en = { "integrations.preview.loading": "Loading the exact change plan…", "integrations.preview.failed": "The change plan could not be loaded. Nothing was changed.", "integrations.preview.stale": "The integration changed while you were confirming. Review the updated plan and confirm again.", - "integrations.preview.catalogUnavailable": "The model catalog is not loaded yet, so this change cannot be planned. Load it to continue.", - "integrations.preview.loadCatalogRetry": "Load catalog and retry", - "integrations.preview.recovering": "Loading catalog…", - "integrations.action.chooseProfile": "Choose profile", "integrations.dialog.apply.title": "Apply this integration?", "integrations.dialog.apply.changes": "opencodex will add its managed provider settings to the client configuration.", "integrations.dialog.apply.breakage": "The client will use the models currently exposed by this opencodex instance.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ae9377ed0fa..37191a7b98f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2551,10 +2551,6 @@ export const fr: Record = { "integrations.preview.loading": "Chargement du plan de modification exact…", "integrations.preview.failed": "Le plan de modification n’a pas pu être chargé. Rien n’a été modifié.", "integrations.preview.stale": "L’intégration a changé pendant la confirmation. Vérifiez le plan mis à jour et confirmez à nouveau.", - "integrations.preview.catalogUnavailable": "Le catalogue de modèles n’est pas encore chargé, cette modification ne peut donc pas être planifiée. Chargez-le pour continuer.", - "integrations.preview.loadCatalogRetry": "Charger le catalogue et réessayer", - "integrations.preview.recovering": "Chargement du catalogue…", - "integrations.action.chooseProfile": "Choisir un profil", "integrations.dialog.apply.title": "Appliquer cette intégration ?", "integrations.dialog.apply.changes": "opencodex ajoutera ses paramètres de fournisseur gérés à la configuration du client.", "integrations.dialog.apply.breakage": "Le client utilisera les modèles actuellement exposés par cette instance opencodex.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 18f5759195a..5f1be4e8afd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2446,10 +2446,6 @@ export const ja: Record = { "integrations.preview.loading": "正確な変更プランを読み込んでいます…", "integrations.preview.failed": "変更プランを読み込めませんでした。変更は行われていません。", "integrations.preview.stale": "確認中に統合の状態が変わりました。更新されたプランを確認して、もう一度確定してください。", - "integrations.preview.catalogUnavailable": "モデルカタログがまだ読み込まれていないため、この変更を計画できません。読み込んでから続行してください。", - "integrations.preview.loadCatalogRetry": "カタログを読み込んで再試行", - "integrations.preview.recovering": "カタログを読み込み中…", - "integrations.action.chooseProfile": "プロファイルを選択", "integrations.dialog.apply.title": "この統合を適用しますか?", "integrations.dialog.apply.changes": "opencodex が管理するプロバイダー設定をクライアント設定に追加します。", "integrations.dialog.apply.breakage": "クライアントは、この opencodex インスタンスが現在公開しているモデルを使用します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5e3251753fc..bc118190596 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2030,10 +2030,6 @@ export const ko: Record = { "integrations.preview.loading": "정확한 변경 계획을 불러오는 중…", "integrations.preview.failed": "변경 계획을 불러오지 못했습니다. 아무것도 변경하지 않았습니다.", "integrations.preview.stale": "확인하는 동안 통합 상태가 바뀌었습니다. 갱신된 계획을 검토하고 다시 확인하세요.", - "integrations.preview.catalogUnavailable": "모델 카탈로그가 아직 로드되지 않아 이 변경을 계획할 수 없습니다. 계속하려면 카탈로그를 로드하세요.", - "integrations.preview.loadCatalogRetry": "카탈로그 로드 후 다시 시도", - "integrations.preview.recovering": "카탈로그 로드 중…", - "integrations.action.chooseProfile": "프로필 선택", "integrations.dialog.apply.title": "이 통합을 적용할까요?", "integrations.dialog.apply.changes": "opencodex가 관리하는 공급자 설정을 클라이언트 구성에 추가합니다.", "integrations.dialog.apply.breakage": "클라이언트는 현재 이 opencodex 인스턴스가 제공하는 모델을 사용합니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 9630d5f8638..4657ba62fab 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2594,10 +2594,6 @@ export const pt: Record = { "integrations.preview.loading": "Carregando o plano exato de alterações…", "integrations.preview.failed": "Não foi possível carregar o plano de alterações. Nada foi alterado.", "integrations.preview.stale": "A integração mudou enquanto você confirmava. Revise o plano atualizado e confirme novamente.", - "integrations.preview.catalogUnavailable": "O catálogo de modelos ainda não foi carregado, então esta alteração não pode ser planejada. Carregue-o para continuar.", - "integrations.preview.loadCatalogRetry": "Carregar catálogo e tentar novamente", - "integrations.preview.recovering": "Carregando catálogo…", - "integrations.action.chooseProfile": "Escolher perfil", "integrations.dialog.apply.title": "Aplicar esta integração?", "integrations.dialog.apply.changes": "O opencodex vai adicionar suas configurações de provedor gerenciadas à configuração do cliente.", "integrations.dialog.apply.breakage": "O cliente passará a usar os modelos expostos atualmente por esta instância do opencodex.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 775326e2047..ae0973335a0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2537,10 +2537,6 @@ export const ru: Record = { "integrations.preview.loading": "Загрузка точного плана изменений…", "integrations.preview.failed": "Не удалось загрузить план изменений. Ничего не изменено.", "integrations.preview.stale": "Интеграция изменилась во время подтверждения. Проверьте обновлённый план и подтвердите снова.", - "integrations.preview.catalogUnavailable": "Каталог моделей ещё не загружен, поэтому это изменение нельзя спланировать. Загрузите его, чтобы продолжить.", - "integrations.preview.loadCatalogRetry": "Загрузить каталог и повторить", - "integrations.preview.recovering": "Загрузка каталога…", - "integrations.action.chooseProfile": "Выбрать профиль", "integrations.dialog.apply.title": "Применить эту интеграцию?", "integrations.dialog.apply.changes": "opencodex добавит управляемые настройки провайдера в конфигурацию клиента.", "integrations.dialog.apply.breakage": "Клиент будет использовать модели, доступные через этот экземпляр opencodex.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b6d5382de8a..5d4afa1c925 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2564,10 +2564,6 @@ export const tr: Record = { "integrations.preview.loading": "Kesin değişiklik planı yükleniyor…", "integrations.preview.failed": "Değişiklik planı yüklenemedi. Hiçbir şey değiştirilmedi.", "integrations.preview.stale": "Onay sırasında entegrasyon değişti. Güncellenen planı inceleyip yeniden onaylayın.", - "integrations.preview.catalogUnavailable": "Model kataloğu henüz yüklenmediği için bu değişiklik planlanamıyor. Devam etmek için kataloğu yükleyin.", - "integrations.preview.loadCatalogRetry": "Kataloğu yükle ve yeniden dene", - "integrations.preview.recovering": "Katalog yükleniyor…", - "integrations.action.chooseProfile": "Profil seç", "integrations.dialog.apply.title": "Bu entegrasyon uygulansın mı?", "integrations.dialog.apply.changes": "opencodex, yönettiği sağlayıcı ayarlarını istemci yapılandırmasına ekleyecek.", "integrations.dialog.apply.breakage": "İstemci, bu opencodex örneğinin sunduğu modelleri kullanacak.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index bdc68e37c63..a3d4a07f8be 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2572,10 +2572,6 @@ export const vi: Record = { "integrations.preview.loading": "Đang tải kế hoạch thay đổi chính xác…", "integrations.preview.failed": "Không thể tải kế hoạch thay đổi. Không có gì bị thay đổi.", "integrations.preview.stale": "Tích hợp đã thay đổi trong lúc xác nhận. Hãy xem kế hoạch mới và xác nhận lại.", - "integrations.preview.catalogUnavailable": "Danh mục mô hình chưa được tải nên không thể lập kế hoạch cho thay đổi này. Hãy tải danh mục để tiếp tục.", - "integrations.preview.loadCatalogRetry": "Tải danh mục và thử lại", - "integrations.preview.recovering": "Đang tải danh mục…", - "integrations.action.chooseProfile": "Chọn hồ sơ", "integrations.dialog.apply.title": "Áp dụng tích hợp này?", "integrations.dialog.apply.changes": "opencodex sẽ thêm các cài đặt nhà cung cấp do nó quản lý vào cấu hình máy khách.", "integrations.dialog.apply.breakage": "Máy khách sẽ dùng các mô hình hiện được phiên bản opencodex này cung cấp.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4e6f1d230ce..dad97b42417 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3506,10 +3506,6 @@ export const zhTW: Record = { "integrations.preview.loading": "正在載入精確的變更計畫…", "integrations.preview.failed": "無法載入變更計畫。未進行任何變更。", "integrations.preview.stale": "確認期間整合狀態已變更。請檢視更新後的計畫並再次確認。", - "integrations.preview.catalogUnavailable": "模型目錄尚未載入,無法規劃此變更。請載入後繼續。", - "integrations.preview.loadCatalogRetry": "載入目錄並重試", - "integrations.preview.recovering": "正在載入目錄…", - "integrations.action.chooseProfile": "選擇設定檔", "integrations.dialog.apply.title": "套用此整合?", "integrations.dialog.apply.changes": "opencodex 會將其管理的供應商設定加入用戶端設定。", "integrations.dialog.apply.breakage": "用戶端將使用此 opencodex 執行個體目前提供的模型。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 565b169bba7..b85962505fe 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2003,10 +2003,6 @@ export const zh: Record = { "integrations.preview.loading": "正在加载准确的更改计划…", "integrations.preview.failed": "无法加载更改计划。未进行任何更改。", "integrations.preview.stale": "确认期间集成状态已变化。请查看更新后的计划并再次确认。", - "integrations.preview.catalogUnavailable": "模型目录尚未加载,无法规划此更改。请加载后继续。", - "integrations.preview.loadCatalogRetry": "加载目录并重试", - "integrations.preview.recovering": "正在加载目录…", - "integrations.action.chooseProfile": "选择配置文件", "integrations.dialog.apply.title": "应用此集成?", "integrations.dialog.apply.changes": "opencodex 会将其管理的提供商设置添加到客户端配置。", "integrations.dialog.apply.breakage": "客户端将使用此 opencodex 实例当前提供的模型。", diff --git a/gui/src/pages/integrations/AsideProfilesPage.tsx b/gui/src/pages/integrations/AsideProfilesPage.tsx index 7a19d039d3f..c7981995379 100644 --- a/gui/src/pages/integrations/AsideProfilesPage.tsx +++ b/gui/src/pages/integrations/AsideProfilesPage.tsx @@ -10,12 +10,11 @@ import FileIntegrationPage from "./FileIntegrationPage"; import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import { describeRefusal, describeAsideProfileOutcome } from "./refusal-copy"; import type { AsideProfileOutcome } from "./aside-profile-contract"; -import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery"; import { bindingFor, IntegrationApiError, + isIntegrationPreviewUnavailable, previewIntegrationMutation, - loadIntegrationState, toggleIntegration, type IntegrationMutationPlan, } from "./integration-api"; @@ -45,7 +44,6 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: plan: IntegrationMutationPlan | null; loading: boolean; failure: string | null; - recovery: PreviewRecovery | null; } | null>(null); const previewAbortRef = useRef(null); const previewGenerationRef = useRef(0); @@ -106,50 +104,29 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: setPending(null); } }; - const requestProfileMutation = async ( - profile: AsideProfileStatus, - enabled: boolean, - /** Recovery replaces the failed plan it is retrying; the first request never does. */ - replacing = false, - ) => { - if (pendingRef.current || (planned && !replacing)) return; + const requestProfileMutation = async (profile: AsideProfileStatus, enabled: boolean) => { + if (pendingRef.current || planned) return; const controller = new AbortController(); const generation = previewGenerationRef.current + 1; previewGenerationRef.current = generation; previewAbortRef.current?.abort(); previewAbortRef.current = controller; - setPlanned({ profile, enabled, plan: null, loading: true, failure: null, recovery: null }); + setPlanned({ profile, enabled, plan: null, loading: true, failure: null }); try { const plan = await previewIntegrationMutation(apiBase, "aside", enabled ? "apply" : "disable", controller.signal, profile.profileId); if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - setPlanned({ profile, enabled, plan, loading: false, failure: null, recovery: null }); + setPlanned({ profile, enabled, plan, loading: false, failure: null }); } catch (error) { if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - // A missing retained roster is recoverable: keep the dialog and offer the - // ordinary profile read that fills it, so the retry can produce a plan. - const recovery = previewRecoveryFor(error); - setPlanned({ - profile, enabled, plan: null, loading: false, recovery, - failure: recovery === null ? t("integrations.preview.failed") : null, - }); - } - }; - const recoverPlanned = async () => { - const pendingPlan = planned; - if (!pendingPlan) return; - // Close and any newer preview move the generation; a stale recovery must not revive it. - const generation = previewGenerationRef.current; - try { - await loadIntegrationState(apiBase, "aside", undefined, pendingPlan.profile.profileId); - } catch { - // Fall through: the preview answers again, and a second banner here - // would only hide the refusal the user can act on. + if (isIntegrationPreviewUnavailable(error)) { + setPlanned(null); + resource.refresh(); + return; + } + setPlanned({ profile, enabled, plan: null, loading: false, failure: t("integrations.preview.failed") }); } - if (generation !== previewGenerationRef.current) return; - resource.refresh(); - await requestProfileMutation(pendingPlan.profile, pendingPlan.enabled, true); }; const closePlanned = () => { previewGenerationRef.current += 1; @@ -258,8 +235,6 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: plan={planned.plan} planLoading={planned.loading} planFailure={planned.failure} - previewRecovery={planned.recovery} - onPreviewRecovery={recoverPlanned} onClose={closePlanned} onConfirm={async plan => { if (!plan) return; diff --git a/gui/src/pages/integrations/ConsequenceDialog.tsx b/gui/src/pages/integrations/ConsequenceDialog.tsx index 80acb01b99b..94b964ee2f2 100644 --- a/gui/src/pages/integrations/ConsequenceDialog.tsx +++ b/gui/src/pages/integrations/ConsequenceDialog.tsx @@ -3,7 +3,6 @@ import { useT, type TKey } from "../../i18n/shared"; import { Notice } from "../../ui"; import IntegrationPlanDetails, { type LabeledIntegrationPlan } from "./IntegrationPlanDetails"; import { IntegrationApiError, type IntegrationMutationPlan } from "./integration-api"; -import type { PreviewRecovery } from "./preview-recovery"; export interface ConsequenceCopy { titleKey: TKey; @@ -36,8 +35,6 @@ export default function ConsequenceDialog({ planStale = false, planLoading = false, planFailure = null, - previewRecovery = null, - onPreviewRecovery, missingStorePath, onConfirm, onClose, @@ -49,13 +46,6 @@ export default function ConsequenceDialog({ planStale?: boolean; planLoading?: boolean; planFailure?: string | null; - /** - * A preview that could not be made for a reason the user can clear by reading - * the page's data again (no retained model roster). The dialog then shows the - * reason and one explicit button instead of a failure the user can only stare at. - */ - previewRecovery?: PreviewRecovery | null; - onPreviewRecovery?: () => Promise | void; /** Passed through to the plan details; see IntegrationPlanDetails. */ missingStorePath?: string; onConfirm: (plan?: IntegrationMutationPlan) => Promise | void; @@ -67,7 +57,6 @@ export default function ConsequenceDialog({ const closeRef = useRef(null); const [pending, setPending] = useState(false); const [failure, setFailure] = useState(null); - const [recovering, setRecovering] = useState(false); const [staleOverride, setStaleOverride] = useState<{ sourceFingerprint: string | null; plan: IntegrationMutationPlan; @@ -126,19 +115,6 @@ export default function ConsequenceDialog({ } }, [activePlan, onConfirm, pending, plan?.fingerprint, t]); - // Recovery is its own press, and its own busy state: it must not look like a - // confirmation, and it must not leave the confirm button enabled while the - // plan it would confirm is still missing. - const recoverPreview = useCallback(async () => { - if (!onPreviewRecovery || recovering) return; - setRecovering(true); - try { - await onPreviewRecovery(); - } finally { - setRecovering(false); - } - }, [onPreviewRecovery, recovering]); - const slots: ReactNode[] = [ , , @@ -175,23 +151,12 @@ export default function ConsequenceDialog({ {planLoading &&

{t("integrations.preview.loading")}

} {pending &&

{t("integrations.mutation.pending")}

} {(stale || planStale) && {t("integrations.preview.stale")}} - {previewRecovery === "catalog-unavailable" && {t("integrations.preview.catalogUnavailable")}} {planFailure && {planFailure}} {failure && {failure}}
- {previewRecovery !== null && onPreviewRecovery && ( - - )} - {/* - A refused preview leaves no plan to confirm. Without `previewRecovery` - here the button looked live and did nothing, which reads as a broken - dialog rather than the one retry the user can actually take. - */} -
diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index fa42fa0866f..51f78320e64 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -12,7 +12,6 @@ import RaycastPlanNotice from "./RaycastPlanNotice"; import DroidReasoningDefaultsPanel from "./DroidReasoningDefaultsPanel"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; -import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery"; import { loadIntegrationJournal, loadIntegrationState, @@ -22,6 +21,7 @@ import { bindingFor, IntegrationApiError, deleteJournalEntry, + isIntegrationPreviewUnavailable, isMissingJournalEntry, type FileIntegrationClientId, type IntegrationJournalRow, @@ -165,7 +165,6 @@ function FileIntegrationControls({ plan: IntegrationMutationPlan | null; loading: boolean; failure: string | null; - recovery: PreviewRecovery | null; droidReasoningDefaults?: Record; } | null>(null); const previewAbortRef = useRef(null); @@ -230,67 +229,32 @@ function FileIntegrationControls({ ? droidReasoningDraft.values : undefined) : undefined, - /** Recovery replaces the failed plan it is retrying; the first request never does. */ - replacing = false, ) => { - if (!status || pending || (plannedMutation && !replacing)) return; + if (!status || pending || plannedMutation) return; const defaultsSnapshot = reasoningDefaults === undefined ? undefined : { ...reasoningDefaults }; const controller = new AbortController(); const generation = previewGenerationRef.current + 1; previewGenerationRef.current = generation; previewAbortRef.current?.abort(); previewAbortRef.current = controller; - setPlannedMutation({ operation, plan: null, loading: true, failure: null, recovery: null }); + setPlannedMutation({ operation, plan: null, loading: true, failure: null }); try { const plan = await previewIntegrationMutation(apiBase, client, operation, controller.signal, profileId, defaultsSnapshot, hermesProfile); if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - setPlannedMutation({ operation, plan, loading: false, failure: null, recovery: null, droidReasoningDefaults: defaultsSnapshot }); + setPlannedMutation({ operation, plan, loading: false, failure: null, droidReasoningDefaults: defaultsSnapshot }); } catch (error) { if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - // A missing retained roster is recoverable from this dialog. Closing it, - // as this used to, left the user with a button that appeared to do - // nothing at all; the preview itself never gathers models. - const recovery = previewRecoveryFor(error); - setPlannedMutation({ - operation, plan: null, loading: false, recovery, - failure: recovery === null ? t("integrations.preview.failed") : null, - }); + if (isIntegrationPreviewUnavailable(error)) { + setPlannedMutation(null); + refresh(); + return; + } + setPlannedMutation({ operation, plan: null, loading: false, failure: t("integrations.preview.failed") }); } }; - /* - * Re-read this surface's own data, then ask for the plan again. - * - * The state resource refreshes through the ordinary profile/client read, and the - * server's integration reads are the loads that retain an export roster. No new - * endpoint and no write: the preview stays a read, and the retry only repeats - * the question now that an answer exists. - */ - const recoverPlannedMutation = async () => { - const pendingOperation = plannedMutation; - if (!pendingOperation) return; - // Close and any newer preview move the generation; a stale recovery must not revive it. - const generation = previewGenerationRef.current; - const defaults = pendingOperation.droidReasoningDefaults; - /* - * This read IS the recovery: the server retains its export roster from an - * ordinary integration load, so waiting for this one is what makes the - * next preview answerable. The resource refresh below only repaints the - * surface with the same data; it cannot be awaited for the answer. - */ - try { - await loadIntegrationState(apiBase, client, undefined, profileId, hermesProfile); - } catch { - // A failed read is not a plan; fall through and let the preview say so - // again rather than reporting a second, unrelated error. - } - if (generation !== previewGenerationRef.current) return; - refresh(); - await requestMutation(pendingOperation.operation, defaults, true); - }; - const closePlannedMutation = () => { previewGenerationRef.current += 1; previewAbortRef.current?.abort(); @@ -539,8 +503,6 @@ function FileIntegrationControls({ plan={plannedMutation.plan} planLoading={plannedMutation.loading} planFailure={plannedMutation.failure} - previewRecovery={plannedMutation.recovery} - onPreviewRecovery={recoverPlannedMutation} missingStorePath={status.supersededReason === "missing-store" ? status.supersededBy : undefined} onClose={closePlannedMutation} onConfirm={async plan => { if (plan) await mutate(plan); }} diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index 88ac713e035..ee8c136ace4 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -12,7 +12,6 @@ import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import RestoreDialog from "./RestoreDialog"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; -import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery"; import { loadCursorIntegrationStatus } from "./cursor-api"; import { buildOverviewRows, @@ -35,6 +34,7 @@ import { bindingFor, deleteJournalEntry, IntegrationApiError, + isIntegrationPreviewUnavailable, isMissingJournalEntry, type IntegrationJournalRow, type IntegrationStatus, @@ -110,7 +110,6 @@ interface BulkDisableState { actions: BulkDisableAction[]; loading: boolean; failure: string | null; - recovery: PreviewRecovery | null; stale: boolean; failures: string[]; } @@ -145,7 +144,6 @@ function OverviewCard({ onOpen, onToggle, onOverwrite, - onChooseProfile, }: { row: OverviewRow; pending: boolean; @@ -154,8 +152,6 @@ function OverviewCard({ onToggle: (() => void) | null; /** Present only for a conflicted file client; null everywhere else. */ onOverwrite: (() => void) | null; - /** Aside only: one plan names one profile, so the aggregate card cannot replace. */ - onChooseProfile: (() => void) | null; }) { const t = useT(); const detail = row.detail ?? (row.detailKey ? t(row.detailKey, row.detailVars ?? undefined) : null); @@ -239,17 +235,6 @@ function OverviewCard({ {t("integrations.action.overwrite")} )} - {/* - Aside has no aggregate writer: a fingerprint describes one profile's - file, and the server refuses an unprofiled plan for exactly that - reason. The card therefore sends the user to the per-profile surface - instead of opening a dialog that could never load a plan. - */} - {onChooseProfile && ( - - )} ); @@ -288,7 +273,6 @@ export default function IntegrationsOverview({ plan: IntegrationMutationPlan | null; loading: boolean; failure: string | null; - recovery: PreviewRecovery | null; } | null>(null); const [bulkPlans, setBulkPlans] = useState(null); const cardPreviewAbortRef = useRef(null); @@ -477,17 +461,14 @@ export default function IntegrationsOverview({ * single-client PUT the card uses, so every client gets its own snapshot and * its own journal row, and one refusal cannot silently swallow the rest. */ - const requestDisableAll = async ( - /** Recovery replaces the failed plan set it is retrying; the first request never does. */ - replacing = false, - ) => { - if (bulkPending || (bulkPlans && !replacing) || appliedClients.length === 0) return; + const requestDisableAll = async () => { + if (bulkPending || bulkPlans || appliedClients.length === 0) return; const controller = new AbortController(); const generation = bulkPreviewGenerationRef.current + 1; bulkPreviewGenerationRef.current = generation; bulkPreviewAbortRef.current?.abort(); bulkPreviewAbortRef.current = controller; - setBulkPlans({ actions: [], loading: true, failure: null, recovery: null, stale: false, failures: [] }); + setBulkPlans({ actions: [], loading: true, failure: null, stale: false, failures: [] }); try { const actions = await Promise.all(appliedClients.map(async client => { const row = rows.find(candidate => candidate.status?.clientId === client.clientId); @@ -499,30 +480,17 @@ export default function IntegrationsOverview({ })); if (controller.signal.aborted || generation !== bulkPreviewGenerationRef.current) return; bulkPreviewAbortRef.current = null; - setBulkPlans({ actions, loading: false, failure: null, recovery: null, stale: false, failures: [] }); + setBulkPlans({ actions, loading: false, failure: null, stale: false, failures: [] }); } catch (error) { if (controller.signal.aborted || generation !== bulkPreviewGenerationRef.current) return; bulkPreviewAbortRef.current = null; - const recovery = previewRecoveryFor(error); - setBulkPlans({ - actions: [], loading: false, recovery, stale: false, failures: [], - failure: recovery === null ? t("integrations.preview.failed") : null, - }); - } - }; - - const recoverBulkPlans = async () => { - // Close and any newer bulk preview move the generation; a stale recovery must not revive it. - const generation = bulkPreviewGenerationRef.current; - try { - await loadIntegrationStates(apiBase); - } catch { - // Fall through: the next preview answers again, and its refusal is the - // message the user can act on. + if (isIntegrationPreviewUnavailable(error)) { + setBulkPlans(null); + refresh(); + return; + } + setBulkPlans({ actions: [], loading: false, failure: t("integrations.preview.failed"), stale: false, failures: [] }); } - if (generation !== bulkPreviewGenerationRef.current) return; - refresh(); - await requestDisableAll(true); }; const closeBulkPlans = () => { @@ -579,7 +547,7 @@ export default function IntegrationsOverview({ if (staleActions.length > 0) { refresh(); setBulkPending(false); - setBulkPlans({ actions: staleActions, loading: false, failure: null, recovery: null, stale: true, failures: failed }); + setBulkPlans({ actions: staleActions, loading: false, failure: null, stale: true, failures: failed }); setBulkResult(failed.length === 0 ? null : { tone: "err", text: t("integrations.bulk.partial", { clients: failed.join("; ") }) }); @@ -680,62 +648,29 @@ export default function IntegrationsOverview({ } }; - const requestFilePlan = async ( - row: OverviewRow, - operation: Exclude, - /** Recovery replaces the failed plan it is retrying; the first request never does. */ - replacing = false, - ) => { - if (!row.status || (plannedCard && !replacing)) return; + const requestFilePlan = async (row: OverviewRow, operation: Exclude) => { + if (!row.status || plannedCard) return; const controller = new AbortController(); const generation = cardPreviewGenerationRef.current + 1; cardPreviewGenerationRef.current = generation; cardPreviewAbortRef.current?.abort(); cardPreviewAbortRef.current = controller; - setPlannedCard({ row, operation, plan: null, loading: true, failure: null, recovery: null }); + setPlannedCard({ row, operation, plan: null, loading: true, failure: null }); try { const plan = await previewIntegrationMutation(apiBase, row.status.clientId, operation, controller.signal); if (controller.signal.aborted || generation !== cardPreviewGenerationRef.current) return; cardPreviewAbortRef.current = null; - setPlannedCard({ row, operation, plan, loading: false, failure: null, recovery: null }); + setPlannedCard({ row, operation, plan, loading: false, failure: null }); } catch (error) { if (controller.signal.aborted || generation !== cardPreviewGenerationRef.current) return; cardPreviewAbortRef.current = null; - /* - * No retained roster is NOT a dead end. Closing the dialog, as this used - * to, looked like the button did nothing; keep the dialog and offer the - * one read that makes the next preview answerable. - */ - const recovery = previewRecoveryFor(error); - setPlannedCard({ - row, operation, plan: null, loading: false, recovery, - failure: recovery === null ? t("integrations.preview.failed") : null, - }); - } - }; - - /* - * Read the collection again, then re-ask for the plan. - * - * The collection read is an ordinary authoritative load and is what retains - * the export roster the preview reads. Waiting for it — rather than the - * fire-and-forget refresh() — is the whole recovery; nothing here writes a - * client file, and the preview stays side-effect free. - */ - const recoverPlannedCard = async () => { - const planned = plannedCard; - if (!planned) return; - // Close and any newer preview move the generation; a stale recovery must not revive it. - const generation = cardPreviewGenerationRef.current; - try { - await loadIntegrationStates(apiBase); - } catch { - // The preview will answer (or refuse) again; a second error banner here - // would only obscure the one the user can act on. + if (isIntegrationPreviewUnavailable(error)) { + setPlannedCard(null); + refresh(); + return; + } + setPlannedCard({ row, operation, plan: null, loading: false, failure: t("integrations.preview.failed") }); } - if (generation !== cardPreviewGenerationRef.current) return; - refresh(); - await requestFilePlan(planned.row, planned.operation, true); }; const closePlannedCard = () => { @@ -785,12 +720,9 @@ export default function IntegrationsOverview({ result={cardResults[row.id] ?? null} onOpen={() => navigateHash(row.hash)} onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null} - onOverwrite={row.status !== null && row.status.clientId !== "aside" && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed + onOverwrite={row.status !== null && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed ? () => void requestFilePlan(row, "overwrite") : null} - onChooseProfile={row.status?.clientId === "aside" && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed - ? () => navigateHash(row.hash) - : null} /> ); @@ -1006,8 +938,6 @@ export default function IntegrationsOverview({ plan={plannedCard.plan} planLoading={plannedCard.loading} planFailure={plannedCard.failure} - previewRecovery={plannedCard.recovery} - onPreviewRecovery={recoverPlannedCard} onClose={closePlannedCard} onConfirm={async plan => { if (!plan) return; @@ -1024,8 +954,6 @@ export default function IntegrationsOverview({ planStale={bulkPlans.stale} planLoading={bulkPlans.loading} planFailure={bulkPlans.failure} - previewRecovery={bulkPlans.recovery} - onPreviewRecovery={recoverBulkPlans} onClose={closeBulkPlans} onConfirm={async () => { await disableAll(bulkPlans); }} /> diff --git a/gui/src/pages/integrations/preview-recovery.ts b/gui/src/pages/integrations/preview-recovery.ts deleted file mode 100644 index 2fbf7510bbf..00000000000 --- a/gui/src/pages/integrations/preview-recovery.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isIntegrationPreviewUnavailable } from "./integration-api"; - -/** - * What a preview that answered 409 `integration_preview_unavailable` means, and the one - * action that can honestly fix it. - * - * The server refuses rather than gathering, because a preview that discovered models would - * refresh credentials and write the provider cache — a write wearing a read's name. The - * retained roster is filled by an ORDINARY load instead. On the dashboard that load is the - * same collection read the Integrations page already performs on mount, so the recovery is - * "read the page's own data again, then plan": no new write path, and the preview stays a - * read. - * - * `null` means the error is not this refusal, so the caller should surface its ordinary - * failure copy. - */ -export type PreviewRecovery = "catalog-unavailable"; - -export function previewRecoveryFor(error: unknown): PreviewRecovery | null { - return isIntegrationPreviewUnavailable(error) ? "catalog-unavailable" : null; -} diff --git a/gui/tests/integrations-preview-recovery.test.tsx b/gui/tests/integrations-preview-recovery.test.tsx deleted file mode 100644 index dc979748967..00000000000 --- a/gui/tests/integrations-preview-recovery.test.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { Window } from "happy-dom"; -import { act } from "react"; -import type { ReactElement } from "react"; -import type { Root } from "react-dom/client"; - -/** - * Two ways a reported-conflict action could not produce a plan. - * - * 1. The Overview's Replace button for Aside sent an aggregate - * `/api/client-integrations/preview` with no profile, which the server must - * refuse: a plan describes one independently changing file. The Aside - * surface already owns per-profile conflicts, so the card's action belongs - * there rather than in a dead dialog. - * 2. A preview answers 409 `integration_preview_unavailable` when no export - * roster has been retained yet, or when a provider cache revision retired - * it. The Overview used to close the dialog and leave the user with a - * button that appeared to do nothing. Recovery has to be explicit: an - * ordinary collection read gathers the roster (the same load the page - * already performs), and only then is the preview asked again. The preview - * itself stays side-effect free. - */ - -const globals = [ - "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT", -] as const; - -let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; -let testWindow: Window; -let container: HTMLElement; -let root: Root | null = null; -let apiBase = ""; -type RecordedRequest = { path: string; method: string; body: unknown }; -let requests: RecordedRequest[]; -let clients: Record[]; -let preview: (body: Record) => Response; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); -} - -function fileStatus(overrides: Record = {}): Record { - return { - clientId: "hermes", state: "current", installed: true, - configPath: "/tmp/home/.hermes/config.yaml", snapshotCount: 1, retentionDegraded: false, - ...overrides, - }; -} - -function asideStatus(overrides: Record = {}): Record { - return fileStatus({ clientId: "aside", configPath: "/tmp/aside/u/1/models.json", ...overrides }); -} - -function plan(clientId: string, operation: string, extra: Record = {}) { - // The client parser accepts only a p:<32 lowercase hex> fingerprint. - const digit = ({ apply: "1", overwrite: "2", disable: "3", restore: "4" } as Record)[operation] ?? "9"; - return { - version: 1, clientId, operation, - state: operation === "apply" ? "absent" : "current", foreignEdit: "none", - changes: [ - { kind: operation === "disable" ? "remove" : "replace", path: "providers.opencodex" }, - { kind: "snapshot", path: "$snapshot" }, { kind: "ownership", path: "$ownership" }, - { kind: "journal", path: "$journal" }, - ], - fingerprint: `p1:${digit.repeat(32)}`, - canApply: true, willChange: true, - ...extra, - }; -} - -beforeEach(() => { - previousGlobals = Object.fromEntries( - globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), - ) as typeof previousGlobals; - testWindow = new Window({ url: "http://localhost/#integrations" }); - Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); - Object.defineProperties(globalThis, { - document: { configurable: true, value: testWindow.document }, - window: { configurable: true, value: testWindow }, - navigator: { configurable: true, value: testWindow.navigator }, - localStorage: { configurable: true, value: testWindow.localStorage }, - sessionStorage: { configurable: true, value: testWindow.sessionStorage }, - IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true }, - }); - requests = []; - apiBase = `http://ocx-preview-recovery-${Math.random().toString(36).slice(2)}.invalid`; - clients = [fileStatus()]; - preview = body => json(plan(String(body.clientId), String(body.operation))); - const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = new URL(String(input instanceof Request ? input.url : input)); - const request = { - path: url.pathname + url.search, - method: (init?.method ?? "GET").toUpperCase(), - body: init?.body ? JSON.parse(String(init.body)) : undefined, - }; - requests.push(request); - if (request.method === "POST" && /\/preview$/.test(url.pathname)) return preview(request.body as Record); - if (url.pathname.includes("/journal")) return json({ operations: [] }); - if (url.pathname === "/api/client-integrations") return json({ clients }); - const profileState = url.pathname.match(/^\/api\/client-integrations\/aside\/profiles\/(\d+)$/); - if (profileState) { - const profileId = Number(profileState[1]); - return json(clients.find(row => row.profileId === profileId) ?? asideStatus({ profileId, current: false, enabled: true })); - } - if (url.pathname === "/api/startup-health") return json({ routingInjected: false, status: "native", recommendedCommand: null }); - if (url.pathname === "/api/keys") return json({ keys: [] }); - if (url.pathname === "/api/claude-desktop/status") { - return json({ desiredEnabled: true, installed: true, observedKind: "standard", applied: false, stale: false, activeProfile: null, appliedAt: null }); - } - if (url.pathname === "/api/native-integrations") return json({ clients: [] }); - if (url.pathname === "/api/claude-code") return json({ enabled: false }); - if (url.pathname === "/api/grok") return json({ present: false, models: [] }); - if (url.pathname === "/api/codex-agent-roles") return json({ lazycodex: { detected: false }, omoJsonc: null, roles: [] }); - if (url.pathname === "/api/subagent-models") return json({ available: [] }); - if (request.method === "PUT") { - const scoped = url.pathname.match(/^\/api\/client-integrations\/aside\/profiles\/(\d+)$/); - return scoped - ? json({ ok: true, clientId: "aside", profileId: Number(scoped[1]), changed: true, state: "current", message: "updated" }) - : json({ ok: true, clientId: "hermes", changed: true, state: "current", message: "updated" }); - } - return json({ error: `Unexpected request: ${request.path}` }, 404); - }) as typeof fetch; - Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); - Object.defineProperty(testWindow, "fetch", { configurable: true, value: mockFetch }); - container = testWindow.document.createElement("div") as unknown as HTMLElement; - testWindow.document.body.appendChild(container as never); -}); - -afterEach(async () => { - if (root) { - const mounted = root; - await act(async () => { mounted.unmount(); }); - root = null; - } - testWindow.close(); - for (const key of globals) { - const descriptor = previousGlobals[key]; - if (descriptor) Object.defineProperty(globalThis, key, descriptor); - else Reflect.deleteProperty(globalThis, key); - } -}); - -async function settle(ms = 40): Promise { - await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, ms)); }); -} - -async function mount(node: () => Promise<{ element: ReactElement; name: string }>): Promise { - const [{ createRoot }, { LanguageProvider }, loaded] = await Promise.all([ - import("react-dom/client"), import("../src/i18n/provider"), node(), - ]); - await act(async () => { - root = createRoot(container); - root.render({loaded.element}); - }); - await settle(); -} - -function buttons(): HTMLButtonElement[] { - return Array.from(container.querySelectorAll("button")); -} - -function buttonByText(text: string): HTMLButtonElement | undefined { - return buttons().find(button => (button.textContent ?? "").trim() === text); -} - -test("a conflicted Aside card routes to the per-profile surface instead of an unprofiled preview", async () => { - clients = [asideStatus({ state: "conflict", reason: "unowned-key" })]; - const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview"); - await mount(async () => ({ name: "overview", element: })); - - // The aggregate preview cannot describe one profile; the card must not try. - const action = buttonByText("Choose profile"); - expect(action).toBeDefined(); - expect(buttonByText("Replace")).toBeUndefined(); - - await act(async () => { action!.click(); }); - expect(testWindow.location.hash).toBe("#integrations/aside"); - expect(requests.filter(request => request.path.endsWith("/api/client-integrations/preview"))).toHaveLength(0); - expect(container.querySelector("dialog[open]")).toBeNull(); -}); - -test("an unavailable preview keeps the dialog open and retries after an ordinary catalog load", async () => { - clients = [fileStatus({ state: "conflict", reason: "unowned-key" })]; - let previews = 0; - let collections = 0; - let collectionsAtFirstPreview = -1; - preview = body => { - previews += 1; - if (previews === 1) { - collectionsAtFirstPreview = collections; - return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409); - } - if (collections <= collectionsAtFirstPreview) { - return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409); - } - return json(plan(String(body.clientId), String(body.operation))); - }; - const originalFetch = globalThis.fetch; - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input instanceof Request ? input.url : input); - if ((init?.method ?? "GET").toUpperCase() === "GET" && url.endsWith("/api/client-integrations")) collections += 1; - return originalFetch(input, init); - }) as typeof fetch, - }); - Object.defineProperty(testWindow, "fetch", { configurable: true, value: globalThis.fetch }); - - const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview"); - await mount(async () => ({ name: "overview", element: })); - - await act(async () => { buttonByText("Replace")!.click(); }); - await settle(); - - const dialog = container.querySelector("dialog[open]"); - expect(dialog).not.toBeNull(); - const retry = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Load catalog and retry") as HTMLButtonElement | undefined; - expect(retry).toBeDefined(); - expect(dialog!.textContent).toContain("model catalog is not loaded"); - // No plan exists yet, so confirming must not look available. - const refusedConfirm = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement; - expect(refusedConfirm.disabled).toBe(true); - - await act(async () => { retry!.click(); }); - await settle(); - - expect(collections).toBeGreaterThan(collectionsAtFirstPreview); - const confirmed = container.querySelector("dialog[open]")!; - expect(confirmed.textContent).toContain("Operation: Replace"); - const confirm = Array.from(confirmed.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement; - expect(confirm.disabled).toBe(false); - await act(async () => { confirm.click(); }); - await settle(); - expect(requests.find(request => request.method === "PUT")?.body).toEqual({ - enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan("hermes", "overwrite").fingerprint, - }); -}); - -test("a per-profile Aside overwrite offers the same catalog retry and keeps its scope", async () => { - clients = [asideStatus({ profileId: 4, current: false, enabled: true, state: "conflict", reason: "foreign-edit" })]; - let previews = 0; - preview = body => { - previews += 1; - if (previews === 1) return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409); - return json(plan("aside", String(body.operation), { profileId: 4 })); - }; - - const { default: FileIntegrationPage } = await import("../src/pages/integrations/FileIntegrationPage"); - await mount(async () => ({ name: "aside profile", element: })); - - const replace = buttonByText("Replace"); - expect(replace).toBeDefined(); - await act(async () => { replace!.click(); }); - await settle(); - - const dialog = container.querySelector("dialog[open]"); - expect(dialog).not.toBeNull(); - const retry = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Load catalog and retry") as HTMLButtonElement | undefined; - expect(retry).toBeDefined(); - await act(async () => { retry!.click(); }); - await settle(); - - expect(container.querySelector("dialog[open]")?.textContent).toContain("Operation: Replace"); - const confirm = Array.from(container.querySelector("dialog[open]")!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement; - await act(async () => { confirm.click(); }); - await settle(); - expect(requests.find(request => request.method === "PUT")?.path).toBe("/api/client-integrations/aside/profiles/4"); - expect(requests.find(request => request.method === "PUT")?.body).toEqual({ - enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan("aside", "overwrite", { profileId: 4 }).fingerprint, - }); -}); - -test("a recovery read still pending when the dialog closes does not reopen it, and the retry waits for the read", async () => { - clients = [fileStatus({ state: "conflict", reason: "unowned-key" })]; - let previews = 0; - preview = body => { - previews += 1; - return previews === 1 - ? json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409) - : json(plan(String(body.clientId), String(body.operation))); - }; - let release: (() => void) | null = null; - let gate: Promise | null = null; - const originalFetch = globalThis.fetch; - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input instanceof Request ? input.url : input); - if (gate && (init?.method ?? "GET").toUpperCase() === "GET" && url.endsWith("/api/client-integrations")) await gate; - return originalFetch(input, init); - }) as typeof fetch, - }); - Object.defineProperty(testWindow, "fetch", { configurable: true, value: globalThis.fetch }); - - const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview"); - await mount(async () => ({ name: "overview", element: })); - await act(async () => { buttonByText("Replace")!.click(); }); - await settle(); - expect(container.querySelector("dialog[open]")).not.toBeNull(); - - gate = new Promise(resolve => { release = resolve; }); - await act(async () => { buttonByText("Load catalog and retry")!.click(); }); - await settle(); - // The retry may not ask again before the roster-retaining read has answered. - expect(previews).toBe(1); - await act(async () => { buttonByText("Close")!.click(); }); - await settle(); - expect(container.querySelector("dialog[open]")).toBeNull(); - await act(async () => { release!(); gate = null; }); - await settle(80); - // A dismissed action stays dismissed: no second preview, no reopened dialog. - expect(previews).toBe(1); - expect(container.querySelector("dialog[open]")).toBeNull(); -}); - diff --git a/native/ocx-child/src/parity.rs b/native/ocx-child/src/parity.rs index ff2ea5d3217..3d5be5ad6a1 100644 --- a/native/ocx-child/src/parity.rs +++ b/native/ocx-child/src/parity.rs @@ -69,11 +69,7 @@ fn json_safe_nonneg_int(val: &serde_json::Value) -> Option { None } -/// 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. +/// Strict exact-key parser matching `parseLinkChildParityReport` in `src/link/child-parity.ts`. pub fn parse_link_child_parity_report(raw_json: &str) -> Option { if raw_json.len() > LINK_CHILD_PARITY_MAX_BYTES { return None; diff --git a/readme/README.fr.md b/readme/README.fr.md index fcb05695ccf..1a6820fbd91 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -15,7 +15,7 @@

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -88,7 +88,7 @@ opencodex est une passerelle locale légère entre vos outils de programmation e ### Installation personnelle (CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+ ; le runtime Bun est inclus automatiquement +npm install -g @bitkyc08/opencodex # Node 18+ ; le runtime Bun est inclus automatiquement ocx start # proxy + tableau de bord sur localhost:10100 ``` @@ -250,7 +250,7 @@ leur arrivée dans le paquet npm. Pour les agents ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # ou `ocx service` ocx init # configuration interactive : écrit ~/.opencodex/config.json et relie Codex ``` diff --git a/readme/README.ja.md b/readme/README.ja.md index ba4b91408b5..734f849f17d 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -15,7 +15,7 @@

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -88,7 +88,7 @@ opencodex は、コーディングツールとモデルプロバイダーをつ ### 個人向けインストール(CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18 以上。Bun ランタイムは自動で同梱されます +npm install -g @bitkyc08/opencodex # Node 18 以上。Bun ランタイムは自動で同梱されます ocx start # プロキシとダッシュボードが localhost:10100 で起動 ``` @@ -246,7 +246,7 @@ bun run src/cli/index.ts start エージェント向け ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # または `ocx service` ocx init # 対話式セットアップ: ~/.opencodex/config.json を書き、Codex を接続します ``` diff --git a/readme/README.ko.md b/readme/README.ko.md index b631de84ad8..6586139d130 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -15,7 +15,7 @@

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -88,7 +88,7 @@ opencodex는 코딩 도구와 모델 프로바이더를 연결하는 가벼운 ### 개인 설치 (CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; Bun 런타임은 자동으로 번들됩니다 +npm install -g @bitkyc08/opencodex # Node 18+; Bun 런타임은 자동으로 번들됩니다 ocx start # 프록시 + 대시보드: localhost:10100 ``` @@ -241,7 +241,7 @@ bun run src/cli/index.ts start 에이전트용 ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # 또는 `ocx service` ocx init # 대화형 설정: ~/.opencodex/config.json을 쓰고 Codex를 연결합니다 ``` diff --git a/readme/README.ru.md b/readme/README.ru.md index c1ac691fe02..0b1d8184243 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -15,7 +15,7 @@

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -88,7 +88,7 @@ opencodex — лёгкий локальный шлюз между вашими ### Личная установка (CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; рантайм Bun подключается автоматически +npm install -g @bitkyc08/opencodex # Node 18+; рантайм Bun подключается автоматически ocx start # прокси + панель управления на localhost:10100 ``` @@ -252,7 +252,7 @@ bun run src/cli/index.ts start Для агентов ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # или `ocx service` ocx init # интерактивная настройка: пишет ~/.opencodex/config.json и подключает Codex ``` diff --git a/readme/README.tr.md b/readme/README.tr.md index 216c2d0f570..8bb58c01bcd 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -15,7 +15,7 @@

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -88,7 +88,7 @@ opencodex, kodlama araçlarınızla model sağlayıcılarınız arasında çalı ### Kişisel kurulum (CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; Bun çalışma zamanı otomatik olarak paketlenir +npm install -g @bitkyc08/opencodex # Node 18+; Bun çalışma zamanı otomatik olarak paketlenir ocx start # proxy + kontrol paneli, localhost:10100 ``` @@ -248,7 +248,7 @@ iyileştirmeleri ve henüz yayımlanmamış düzeltmeler npm paketine ulaşmadan Ajanlar için ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # ya da `ocx service` ocx init # etkileşimli kurulum: ~/.opencodex/config.json yazar ve Codex'i bağlar ``` diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 13d4c9317a1..1c0a6ef79b6 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -15,7 +15,7 @@

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -88,7 +88,7 @@ opencodex 是连接编程工具与模型提供商的轻量级本地网关。它 ### 个人安装(CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+;Bun 运行时会自动捆绑 +npm install -g @bitkyc08/opencodex # Node 18+;Bun 运行时会自动捆绑 ocx start # 代理 + 仪表板:localhost:10100 ``` @@ -241,7 +241,7 @@ bun run src/cli/index.ts start 面向 agent ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # 或 `ocx service` ocx init # 交互式设置:写入 ~/.opencodex/config.json 并接入 Codex ``` diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 7843c589bf6..b48d492e0d5 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -15,7 +15,7 @@

```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start ``` @@ -88,7 +88,7 @@ opencodex 是連接程式開發工具與模型供應商的輕量級本機閘道 ### 個人安裝(CLI) ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+;Bun 執行環境會自動打包 +npm install -g @bitkyc08/opencodex # Node 18+;Bun 執行環境會自動打包 ocx start # 代理 + 儀表板位於 localhost:10100 ``` @@ -241,7 +241,7 @@ bun run src/cli/index.ts start 給 agent 使用 ```bash -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex ocx start # 或 `ocx service` ocx init # 互動式設定:寫入 ~/.opencodex/config.json 並接上 Codex ``` diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index cb937aa02f5..7fc9221d996 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d" + "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682" } } } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0b3ab9c3fd5..1da608a5b99 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -22,15 +22,15 @@ if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { Write-Host "Using Node v$nodeVersion" -# Install opencodex globally. npm 12 blocks dependency lifecycle scripts by default, and -# bun's postinstall is what unpacks the bundled runtime; without this approval the install -# succeeds but `ocx` cannot start (#8361). npm 8 through 11 accept the flag on global installs. +# Install opencodex globally +# If npm reports "install scripts blocked" for bun, rerun as: +# npm install -g --allow-scripts=bun @bitkyc08/opencodex # (use an elevated PowerShell if the original install was elevated) $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue if (-not $npm) { $npm = Get-Command npm -ErrorAction Stop } -& $npm.Source install -g --allow-scripts=bun @bitkyc08/opencodex +& $npm.Source install -g @bitkyc08/opencodex if ($LASTEXITCODE -ne 0) { Write-Error "npm install failed with exit code $LASTEXITCODE" exit $LASTEXITCODE diff --git a/scripts/install.sh b/scripts/install.sh index 54715adc9c7..6a25db452e9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -21,11 +21,11 @@ fi echo "Using Node $(node --version)" -# Install opencodex globally. npm 12 blocks dependency lifecycle scripts by default, and -# bun's postinstall is what unpacks the bundled runtime; without this approval the install -# succeeds but `ocx` cannot start (#8361). npm 8 through 11 accept the flag on global installs. +# Install opencodex globally +# If npm reports "install scripts blocked" for bun, rerun as: +# npm install -g --allow-scripts=bun @bitkyc08/opencodex # (keep sudo if the original install used sudo) -npm install -g --allow-scripts=bun @bitkyc08/opencodex +npm install -g @bitkyc08/opencodex if ! command -v ocx &>/dev/null; then NPM_BIN="$(npm bin -g 2>/dev/null || printf "%s/bin" "$(npm prefix -g)")" diff --git a/scripts/test-layout/layout-route-intent.json b/scripts/test-layout/layout-route-intent.json deleted file mode 100644 index 52cc2101adb..00000000000 --- a/scripts/test-layout/layout-route-intent.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "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 1ca32d3d7ec..aed2989c321 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "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"], + "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"], "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", @@ -202,7 +202,7 @@ "anthropic-empty-content.test.ts": "adapters/anthropic", "anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic", - "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-thinking-display-policy.test.ts": "adapters/anthropic", + "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-fast-opt-in.test.ts": "adapters/anthropic", "anthropic-fast-speed.test.ts": "adapters/anthropic", "anthropic-hardening.test.ts": "adapters/anthropic", @@ -777,7 +777,7 @@ "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", - "config-save-boundary.test.ts": "config", "config-thinking-display.test.ts": "config", + "config-save-boundary.test.ts": "config", "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", @@ -925,7 +925,7 @@ "docs-readme-memory-inventory.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "docs-remote-hub-claims.test.ts": "ci-workflows", - "doctor-codex-envkey-readiness.test.ts": "service", "doctor-bun-install.test.ts": "service", + "doctor-codex-envkey-readiness.test.ts": "service", "doctor-oauth.test.ts": "service", "doctor-provider-apikey.test.ts": "service", "doctor-service-memory-contract.test.ts": "service", @@ -1217,7 +1217,7 @@ "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-upstream-websocket.test.ts": "server", - "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-thinking-display.test.ts": "server", + "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", @@ -1839,7 +1839,7 @@ "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bounded-json.test.ts": "update", "update-checkpoint-codec.test.ts": "update", "update-contracts.test.ts": "update", "update-candidate-id.test.ts": "update", "update-external-metadata.test.ts": "update", "update-minisign.test.ts": "update", "update-root-snapshot.test.ts": "update", "update-signed-supply.test.ts": "update", "update-signed-version.test.ts": "update", "update-trust-root.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", "update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update", - "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-npm-allow-scripts.test.ts": "update", + "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-orchestrated-session.test.ts": "update", "update-orchestrated-entry.test.ts": "update", "update-orchestrated-apply.test.ts": "update", "update-orchestrated-arguments.test.ts": "update", "update-orchestrated-plan.test.ts": "update", "update-sync-mutex.test.ts": "update", "update-orchestrated-identity.test.ts": "update", "update-orchestrated-token.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update", "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index f01318a08f4..d49db6cfa0b 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) | 71 | +| [agents-routing](01_surface_agents-routing.md) | 68 | | [integrations](01_surface_integrations.md) | 41 | | [observe-system](01_surface_observe-system.md) | 99 | | [access-remote](01_surface_access-remote.md) | 34 | @@ -1527,20 +1527,8 @@ 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: 373 -- of those, state-changing: 230 +- declared capabilities: 370 +- of those, state-changing: 228 - 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 bdcc876c627..3a9df271275 100644 --- a/skills/ocx/references/01_surface_access-remote.md +++ b/skills/ocx/references/01_surface_access-remote.md @@ -81,7 +81,6 @@ 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. | @@ -329,7 +328,7 @@ State-changing: yes. JSON mode: `payload`. ### `ocx disconnect` -Usage: `ocx disconnect [--keep-catalog] [--recover-config --yes] [--json] | ocx disconnect --orphaned-token --yes [--json]` +Usage: `ocx disconnect [--keep-catalog] [--json] | ocx disconnect --orphaned-token --yes [--json]` Disconnect a client or preserve orphaned token files for recovery. @@ -340,9 +339,8 @@ 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 --recover-config or --orphaned-token recovery. | +| `--yes` | boolean | Explicitly confirm orphan-token recovery; requires --orphaned-token. | | `--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 10187ebd5ec..b30b7d6f50c 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: 71. +Declared capabilities: 68. ### `ocx agent subagents force` @@ -1339,69 +1339,3 @@ 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 f7e55100a69..067e554644d 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 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. +- 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. ### `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. 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. +- 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. ### `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. | -- 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. +- 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. | -- 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. +- 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 9d4e6edbac6..ce7372e8b50 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 ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` +Usage: `ocx sync [--expect-link | --expect-hub-key ] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]` Synchronize client catalogs, including Aside profiles through the running server's mutation owner. @@ -97,8 +97,6 @@ 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/adapters/anthropic.ts b/src/adapters/anthropic.ts index 87a4071f22d..f864bd06468 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1109,18 +1109,6 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const summary = (parsed._rawBody as { reasoning?: { summary?: unknown } } | undefined)?.reasoning?.summary; // Codex sends "auto" by default, so only an explicit concise/detailed request opts out of updates (#7728). const summaryRequested = summary === "concise" || summary === "detailed"; - // Operator policy for the adaptive `thinking.display` field (#8363). Unset and "auto" keep - // the built-in selection. Any model entry wins over the provider-wide value, including an - // explicit "auto" that opts one model back out. "updates" is only sendable where this - // adapter already sent it (native Opus 5.5), so anywhere else it is treated as auto rather - // than inventing a display value the model never received. - const modelThinkingDisplay = modelRecordValue(provider.modelThinkingDisplay, parsed.modelId); - const configuredThinkingDisplay = modelThinkingDisplay !== undefined - ? modelThinkingDisplay - : provider.thinkingDisplay; - const thinkingDisplayPolicy = configuredThinkingDisplay === "updates" && !nativeOpus55 - ? "auto" - : configuredThinkingDisplay ?? "auto"; const thinkingToggleWire = declaresThinkingToggleWire(provider, parsed.modelId); const reasoningControlsUnsupported = reasoningControlsAreUnsupported(provider, parsed.modelId); const capabilities = resolveReasoningCapabilities(provider, parsed.modelId); @@ -1157,16 +1145,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // thinking blocks, so a Chat client sees minutes of heartbeats and no reasoning delta // during a long think (#5824). Native Opus 5.5 selects its display below; // other models request summarized thinking unless the caller hides it. - // Under the default policy this stays the pre-existing selection, including its local - // hideThinkingSummary behavior; a configured "summarized" overrides that hiding. - // An explicit request summary forces summarized on every adaptive model (#7881), so it - // also outranks a configured "omitted". "updates" already degraded to auto above - // because only native Opus 5.5 can send it. - // Local hideThinkingSummary only speaks for the built-in policy, where it keeps its - // pre-existing meaning; an operator choice replaces that local preference. - const hiddenThinkingDisplay = (thinkingDisplayPolicy === "omitted" && !summaryRequested) - || (thinkingDisplayPolicy === "auto" && parsed.options.hideThinkingSummary); - body.thinking = hiddenThinkingDisplay + body.thinking = parsed.options.hideThinkingSummary ? { type: "adaptive" } : { type: "adaptive", display: "summarized" }; const effort = adaptiveEffort(effectiveReasoning); @@ -1214,12 +1193,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } if (nativeOpus55 && !reasoningControlsUnsupported && thinkingEnabled !== false) { - // An explicit per-request summary (concise/detailed) still forces summarized (#7881). - body.thinking = summaryRequested || thinkingDisplayPolicy === "summarized" - ? { type: "adaptive", display: "summarized" } - : thinkingDisplayPolicy === "omitted" - ? { type: "adaptive" } - : { type: "adaptive", display: "updates" }; + body.thinking = { type: "adaptive", display: summaryRequested ? "summarized" : "updates" }; } // One discriminator governs the display beta and response classification, including reuse. progressUpdates = (body.thinking as { display?: string } | undefined)?.display === "updates"; diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 97d6167f30a..c6ff9d971a3 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -22,7 +22,6 @@ import { contentPartsToText, parseDataUrl } from "./image"; import { bindVertexRequestAuthority, GCP_CREDENTIAL_MARKER_PREFIX, gcpCredentialMarkerAccount, getVertexAccessTokenWithAuthority } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; -import { readDisplaySafeErrorPayloadText } from "./upstream-http-error"; import { isVertexTruncatedTurn, vertexTruncationErrorEvent } from "./google-truncation"; import { antigravitySessionAnchor, antigravityUserAgentForWireModel, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; import { summarizeGoogleWireShape } from "./google-wire-shape"; @@ -947,15 +946,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // session replay cache), but durable-store eviction is intentionally narrower: it only runs // when the error text explicitly mentions a signature, so a generic tool-schema // INVALID_ARGUMENT does not destroy valid durable signatures. - // - // `httpStatus` is the real upstream status when the rejection arrived as an HTTP error. The - // normalized Vertex/Antigravity error text keeps only Google's message, not its status enum, - // so a 400 such as "bad model" would otherwise leave the poisoned session entry in place (#8054). - function handleSignatureRejection(errorMessage?: string, httpStatus?: number) { + function handleSignatureRejection(errorMessage?: string) { const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; const text = errorMessage ?? ""; - const isInvalidArgument = httpStatus === 400 || /invalid_argument|invalid argument/i.test(text); + const isInvalidArgument = /invalid_argument|invalid argument/i.test(text); const isSignatureError = /signature|thought_signature|thoughtSignature/i.test(text); // The in-memory Antigravity replay cache only exists for CCA/Vertex, so clearing it stays // scoped to those modes (replayModel/replaySession are undefined elsewhere anyway). @@ -987,23 +982,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // 429 rotation and raw error formatting. ...(provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist" ? { - fetchResponse: async (request: AdapterRequest, ctx?: AdapterFetchContext): Promise => { - const res = await (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)( + fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise => + (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)( request, ctx, { toolSchemaProfile, toolSchemaPolicy }, - ); - // A rejected replay usually arrives as a real HTTP 400, and the dispatcher reports a - // non-ok response without ever calling parseStream/parseResponse — the only other - // callers of clear-on-invalid. Without this, the bad signature stays cached and is - // replayed on every turn until the TTL expires (#8054). Read a clone so the body the - // dispatcher formats is untouched, and await it so the next turn cannot race ahead of - // the eviction. No resend here: payload rejection stays resend-is-futile. - if (res.status === 400) { - handleSignatureRejection(await readDisplaySafeErrorPayloadText(res.clone(), ctx?.abortSignal), res.status); - } - return res; - }, + ), formatErrorBody: (status: number, _headers: Headers, payloadText: string): string => (provider.googleMode === "cloud-code-assist" ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText), } diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 3b91d3b26c4..25d0f85e509 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 { assertClientLifecycleHeld, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesktopStore } from "./desktop-remote-store"; import { canonicalDirectory } from "./desktop-remote-store-io"; import { @@ -571,7 +571,6 @@ 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 @@ -582,7 +581,7 @@ export function removeDesktop3pStandardPivot( ): Desktop3pRemovalResult { const libraryPath = resolveDesktop3pConfigLibraryPath(options); try { - const remove = (held: ClientLifecycleHeld): Desktop3pRemovalResult => withConfigMutationLockSync(() => { + return withClientLifecycleSync(held => 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}` }; @@ -622,9 +621,7 @@ 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 }; - }); - if (options.lifecycleHeld) { assertClientLifecycleHeld(options.lifecycleHeld); return remove(options.lifecycleHeld); } - return withClientLifecycleSync(remove, options.lifecycleLockDeps); + }), options.lifecycleLockDeps); } catch (error) { return { ok: false, changed: false, kind: "write_failed", libraryPath, reason: desktopMutationFailureReason(error) }; } @@ -727,10 +724,9 @@ export function writeDesktop3pConfig( nativeContextCap?: NativeContextLimitsInput, lifecycleLockDeps?: ClientLifecycleLockDeps, refreshOnly?: { appliedFingerprint: string; admit: () => boolean }, - lifecycleHeld?: ClientLifecycleHeld, ): { written: boolean; path: string; reason?: string; fingerprint?: string } { try { - const write = () => withConfigMutationLockSync(() => { + return withClientLifecycleSync(() => withConfigMutationLockSync(() => { const connection = readClientConnectionState(); if (connection.kind === "invalid" || connection.kind === "mismatched") { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: `desktop_client_state_${connection.kind}` }; @@ -789,9 +785,7 @@ 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); - }); - if (lifecycleHeld) { assertClientLifecycleHeld(lifecycleHeld); return write(); } - return withClientLifecycleSync(write, lifecycleLockDeps); + }), 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 d9b1d4c373b..322970c0560 100644 --- a/src/claude/desktop-gateway-state.ts +++ b/src/claude/desktop-gateway-state.ts @@ -25,12 +25,10 @@ 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 cc79200d22a..ecefac46cc6 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, canonical, digest, origin, parseBaseline, parseDisconnect, parseOwner, parseState, projection, projectionHash, mergeProjection, + DesktopStoreError, 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, overwriteConflict = false): void { +export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: ArtifactPending["kind"], tokenFingerprint: string, desiredSelection?: string | null): void { const state = bundle.state!; const targetPath = profilePath(bundle.paths.library, state.targetId); let current = io.read(targetPath); @@ -143,18 +143,7 @@ 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) { - // 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); - } + } else if (before !== state.lastProjectionHash) throw new DesktopStoreError("conflict"); 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 1ae510dfbd5..3e18307c980 100644 --- a/src/claude/desktop-remote-store.ts +++ b/src/claude/desktop-remote-store.ts @@ -81,32 +81,24 @@ 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 - || !validDestination) throw new DesktopStoreError("conflict"); + || serviceApiTokenFingerprint(options.apiKey) !== token.fingerprint || origin(options.baseUrl) !== options.owner.serverUrl) 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: local ? options.baseUrl : options.owner.serverUrl, + inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: 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, options.overwriteConflict); + artifact(io, bundle, value, "apply", token.fingerprint, bundle.state!.targetId); cleanBackup(io, bundle, [token.fingerprint, ...serviceGenerations()]); return success(io, bundle, "applied"); }); @@ -131,15 +123,6 @@ 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/bundled-bun-diagnostic.ts b/src/cli/bundled-bun-diagnostic.ts deleted file mode 100644 index d9d8535a83c..00000000000 --- a/src/cli/bundled-bun-diagnostic.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * `ocx doctor` check for a package install whose bundled Bun dependency never finished - * installing (#8361). - * - * npm 12 blocks dependency lifecycle scripts by default. The `bun` npm package downloads nothing - * in its tarball: its postinstall copies the selected `@oven/bun-*` executable into `bin/`. An - * install that skipped that script succeeds and leaves a ~450-byte placeholder, so the launcher - * fails later with "the `bun` dependency is not installed". Doctor is where an operator looks - * after that failure, so it names the state and the exact command that repairs it. - * - * The check is observe-only and never fails the doctor run: a leftover PATH Bun or an explicit - * `OPENCODEX_BUN_PATH` can still be serving requests, which makes this a degraded install rather - * than an unusable surface. - */ -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import * as bunRuntime from "../lib/bun-runtime"; -import * as installDetection from "../update/install-detection.mjs"; - -const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); - -export const BUNDLED_BUN_MISSING_DETAIL = "the `bun` dependency is not installed"; -export const NPM_BUN_REPAIR_COMMAND = "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; - -type PackageInstaller = "npm" | "pnpm" | "bun" | "mise"; - -export type BundledBunDiagnostic = - | { status: "ready"; path: string } - | { status: "not-applicable" } - | { status: "missing"; installer: PackageInstaller; lines: string[] }; - -export interface BundledBunDiagnosticDeps { - platform?: NodeJS.Platform; - bundledPath?: () => string | null; - detectOwnership?: () => { installer: string; owner?: { tool: string } | null }; -} - -/** The command that reinstalls this package while allowing bun's postinstall. */ -export function bundledBunRepairCommand(installer: PackageInstaller, tool?: string | null): string | null { - if (installer === "npm") return NPM_BUN_REPAIR_COMMAND; - if (installer === "pnpm") return "pnpm add -g --allow-build=bun @bitkyc08/opencodex"; - if (installer === "bun") return "bun add -g @bitkyc08/opencodex"; - return tool ? `mise upgrade ${tool}` : null; -} - -export function collectBundledBunDiagnostic(deps: BundledBunDiagnosticDeps = {}): BundledBunDiagnostic { - const bundled = (deps.bundledPath ?? bunRuntime.bundledBunPath)(); - if (bundled) return { status: "ready", path: bundled }; - - const ownership = (deps.detectOwnership - ?? (() => installDetection.detectInstallOwnershipFromPath(PACKAGE_ROOT)))(); - const installer = ownership.installer; - // Only the mise verdict carries an owner; the others have no tool to name. - const ownerTool = "owner" in ownership ? ownership.owner?.tool ?? null : null; - // A source checkout has no bundled dependency by design, and a standalone binary carries its - // own runtime; neither is a skipped-postinstall symptom. - if (installer !== "npm" && installer !== "pnpm" && installer !== "bun" && installer !== "mise") { - return { status: "not-applicable" }; - } - - const repair = bundledBunRepairCommand(installer, ownerTool); - const windows = (deps.platform ?? process.platform) === "win32"; - const lines = [ - ` !! ${BUNDLED_BUN_MISSING_DETAIL} (its postinstall was skipped or the dependency is incomplete)`, - " Allow bun's own install script; npm 12 blocks lifecycle scripts by default,", - " and --ignore-scripts or ignore-scripts=true in an npmrc blocks it on any npm", - ]; - if (!repair) { - lines.push(" This mise-owned installation has no verified tool name; upgrade it through mise."); - return { status: "missing", installer, lines }; - } - if (windows && installer === "npm") { - // A running bun.exe holds node_modules/bun/bin open, so npm fails with EBUSY. The instruction - // has to stop first; `ocx update` does this itself, but a manual reinstall does not. - lines.push(" Stop the proxy first so Windows releases node_modules/bun/bin, then run:"); - lines.push(" ocx stop"); - lines.push(` ${repair}`); - lines.push(" Restart afterwards: ocx service restart (or ocx start when unmanaged)"); - } else { - lines.push(" Fix: reinstall with the package manager that owns this install:"); - lines.push(` ${repair}`); - } - return { status: "missing", installer, lines }; -} - -/** Render the doctor "Bundled Bun runtime" section lines. */ -export function formatBundledBunDiagnosticLines(diagnostic: BundledBunDiagnostic): string[] { - if (diagnostic.status === "ready") return [` ok ready: ${diagnostic.path}`]; - if (diagnostic.status === "not-applicable") return []; - return diagnostic.lines; -} diff --git a/src/cli/capabilities-access-remote.ts b/src/cli/capabilities-access-remote.ts index d5773102f0e..add2702b22f 100644 --- a/src/cli/capabilities-access-remote.ts +++ b/src/cli/capabilities-access-remote.ts @@ -4,13 +4,12 @@ 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] [--recover-config --yes] [--json] | ocx disconnect --orphaned-token --yes [--json]", + usage: "ocx disconnect [--keep-catalog] [--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 --recover-config or --orphaned-token recovery." }, + { name: "--yes", value: "boolean", summary: "Explicitly confirm orphan-token recovery; requires --orphaned-token." }, { 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 5f2b8a4f47b..b79e3ba6a65 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":"--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."}], + 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."}], 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 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."], + 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."], 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. 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."], + 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."], 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,8 +770,6 @@ 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." }, @@ -789,7 +787,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 ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", + usage: "ocx sync [--expect-link | --expect-hub-key ] [--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 5df20204675..338240652c8 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: ["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."], + 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."], }, { 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: ["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."], + 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."], }, { command: ["integration","client","restore"], diff --git a/src/cli/capabilities-route-to.ts b/src/cli/capabilities-route-to.ts deleted file mode 100644 index 002883519b3..00000000000 --- a/src/cli/capabilities-route-to.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 8f1dda7f061..7a3c82376e7 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -13,7 +13,6 @@ 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, @@ -24,7 +23,6 @@ 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 5e668fa7cdc..209c4a05c15 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -7,12 +7,11 @@ 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 { assertClientLifecycleHeld, withClientLifecycle, withClientLifecycleSync, type ClientLifecycleHeld, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; +import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock"; import { DESKTOP_FAMILIES, moveDesktopRoute, @@ -306,16 +305,12 @@ 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, held?: ClientLifecycleHeld): Promise { +async function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): Promise { try { - 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); - }); + return await settleConfigRoot(configRoot(), () => withClientLifecycleSync(() => { + const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode)); + return outcome.status !== "unavailable"; + }, deps.lifecycleLockDeps)); } catch { return false; } @@ -440,21 +435,8 @@ 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(), () => setHomeIntegrationEnabled("claude-desktop", true)); + const desired = await withFirstPartySecretsSettled(getConfigDir(), () => setIntegrationEnabled("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. @@ -470,8 +452,7 @@ async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: Clien 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, - lifecycleLockDeps: deps.lifecycleLockDeps, lifecycleHeld: held })); } + try { removed = await settleConfigRoot(configRoot(), () => removeDesktop3pStandardPivot({ appliedFingerprint: readConfigForAdmittedMutation().claudeCode?.desktopProfile?.appliedFingerprint ?? null, replaceWhileEnabled: true })); } catch (error) { removed = { ok: false, changed: false, kind: "write_failed", libraryPath: library.libraryPath, reason: error instanceof WindowsSecretAclSettleTimeoutError ? "acl_settlement_timeout" : "desktop_mutation_failed" }; @@ -479,7 +460,7 @@ async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: Clien 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, held); + const modeSaved = !removed.changed || await saveDesktopMode("first-party", deps); 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}`, @@ -487,7 +468,7 @@ async function applyFirstPartyDesktopOffline(deps: ApplyProfileDeps, held: Clien ...(warning ? { warning } : {}) }; } } - const saved = await saveDesktopMode("first-party", deps, held); + const saved = await saveDesktopMode("first-party", deps); modeCommitted = saved; return { ok: true, @@ -540,7 +521,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(), () => setHomeIntegrationEnabled("claude-desktop", true)); + const desired = await settleConfigRoot(configRoot(), () => setIntegrationEnabled("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 5271a540108..b3986f7df24 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -26,7 +26,6 @@ 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, @@ -241,8 +240,8 @@ export function collectClientConnectionStatus( function parseClients(raw: string | undefined): OcxConnectedClientId[] { const values = csv(raw) ?? ["codex", "claude"]; - if (values.length < 1 || values.some(value => !isRouteClientId(value))) { - throw new CliUsageError(`--clients must contain route client ids: ${ROUTE_CLIENT_IDS.join(", ")}`, CONNECT_USAGE); + if (values.length < 1 || values.some(value => value !== "codex" && value !== "claude")) { + throw new CliUsageError("--clients must contain codex and/or claude", CONNECT_USAGE); } return values as OcxConnectedClientId[]; } @@ -391,9 +390,6 @@ 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); @@ -435,9 +431,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); @@ -473,7 +468,6 @@ 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 }, @@ -487,9 +481,9 @@ async function runLinkConnect(argv: string[], deps: ClientCommandDeps): Promise< throw terminalSafeError(error); }); const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {}); - const report = connectCompletionReport(connection, connection.selectedClients, readiness); + const report = connectCompletionReport(connection, clients, readiness); for (const line of report.lines) console.log(line); - printCodexShimReadiness(connection.selectedClients); + printCodexShimReadiness(clients); if (report.failure) throw new Error(report.failure); } @@ -544,10 +538,8 @@ 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 }); @@ -558,7 +550,7 @@ export async function handleDisconnectCommand( ]); return; } - if (yes && !recoverConfig) throw new CliUsageError("--yes requires --orphaned-token or --recover-config", DISCONNECT_USAGE); + if (yes) throw new CliUsageError("--yes requires --orphaned-token", DISCONNECT_USAGE); const teardown = await teardownClientLink({ readSidecar: readClientLinkState, connectedLinkId: () => { @@ -572,14 +564,7 @@ export async function handleDisconnectCommand( knownHostsFile: deps.linkTeardownDeps?.knownHostsFile ?? linkKnownHostsPath(), timeoutMs: deps.linkTeardownDeps?.timeoutMs, }); - 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 result = await disconnectClient({ keepCatalog }, deps); const payload = { ...result, homeRevoke: teardown.homeRevoke, @@ -591,7 +576,6 @@ 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 dba1a4abb35..1a9fd889871 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,12 +89,6 @@ 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"); @@ -179,13 +173,13 @@ const commandRunners: Record = { if (!live) { return emitBack(false, "No running proxy found. Run 'ocx start' — it injects opencodex automatically.", 1); } - const desired = setHomeIntegrationEnabled("codex", true); + const desired = setIntegrationEnabled("codex", true); if (!desired.ok) { - return emitBack(false, desiredStateRefusal(desired), desired.reason === "conflict" ? 2 : 1); + return emitBack(false, `Codex desired state was not saved (${desired.reason}).`, desired.reason === "conflict" ? 2 : 1); } const synced = await syncModelsToCodex(live.port); if (synced.status === "skipped") { - // `setHomeIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and + // `setIntegrationEnabled` 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). @@ -206,15 +200,15 @@ const commandRunners: Record = { if (removeProviderTable && !restoreJson) { console.log("⚠️ Removing [model_providers.opencodex] means conversations already tagged opencodex will stop opening."); } - const desired = setHomeIntegrationEnabled("codex", false); + const desired = setIntegrationEnabled("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, desiredStateRefusal(desired)))); + console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`))); } else { - console.error(desiredStateRefusal(desired)); + console.error(`Codex desired state was not saved (${desired.reason}).`); } return desired.reason === "conflict" ? 2 : 1; } @@ -758,12 +752,8 @@ 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/doctor.ts b/src/cli/doctor.ts index 04421d3ac65..3dea81c1020 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -8,7 +8,6 @@ * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*. */ import { cliCommandDoctorChecks, collectCliPathDiagnostics } from "./cli-path-diagnostics"; -import { collectBundledBunDiagnostic, formatBundledBunDiagnosticLines } from "./bundled-bun-diagnostic"; import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -1608,16 +1607,6 @@ export async function runDoctor(args: string[] = []): Promise { } } - // The bundled dependency is the install's own runtime, distinct from whatever Bun is running - // doctor or serving the proxy. A skipped postinstall (#8361) is invisible here otherwise. - // A source checkout or standalone binary has no bundled `bun` dependency, so the - // section has nothing to say there and prints no heading. - const bundledBunLines = formatBundledBunDiagnosticLines(collectBundledBunDiagnostic()); - if (bundledBunLines.length > 0) { - console.log("\nBundled Bun runtime"); - for (const line of bundledBunLines) console.log(line); - } - console.log("\nWHAM reachability"); const probe = await probeWham(); const detail = probe.status !== null ? `status=${probe.status}` : `error=${probe.classification}`; diff --git a/src/cli/help-catalog.ts b/src/cli/help-catalog.ts index 7743c2e0510..df29a851a4e 100644 --- a/src/cli/help-catalog.ts +++ b/src/cli/help-catalog.ts @@ -1,6 +1,5 @@ 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[] } @@ -89,16 +88,6 @@ 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 d86cc8464e2..67780c76753 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 (to, combo, policy) + ocx route Routing features (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 8f8a85f8f09..4cabb0ceae8 100644 --- a/src/cli/inspect.ts +++ b/src/cli/inspect.ts @@ -109,17 +109,8 @@ async function nativeIntegration(argv: string[], deps: RuntimeApiDeps): Promise< throw new CliUsageError(`expected on or off after ${action}`, USAGE); } - // 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; - } + // 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. 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 82e5eb9ffa2..09fa2fb392c 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -15,7 +15,6 @@ 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] @@ -241,31 +240,10 @@ 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"); - const code = await handleIntegrationPreviewCommand(argv, deps, expectedClientId); - if (code === 0 && toggle) console.error(HOME_ONLY_NOTE); - return code; + return handleIntegrationPreviewCommand(argv, deps, expectedClientId); } return runCliAction(async () => { const args = [...argv]; @@ -379,12 +357,9 @@ 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-enrollment.ts b/src/cli/link-enrollment.ts index f459c68ba47..adee88bfbae 100644 --- a/src/cli/link-enrollment.ts +++ b/src/cli/link-enrollment.ts @@ -207,10 +207,7 @@ export async function runLinkEnrollment(command: "probe" | "add", args: string[] if (code === "child_preflight_output_limit") throw new Error("The Child's ocx config show --json output exceeded the Remote Link preflight limit; trim the Child's saved configuration and retry."); if (code === "child_external_provider_conflict") throw new Error("The Child's ~/.codex/config.toml is already managed by another model provider or custom base URL; no Child files were modified."); const phase = typeof code === "string" && Object.hasOwn(PHASES, code) ? PHASES[code] : undefined; - // The server's hint is the Child's own bounded, redacted reason (#8357); a phase name - // alone left every failure to SSH reproduction and source reading. - const hint = record(error.body.error) && typeof error.body.error.hint === "string" ? error.body.error.hint : ""; - const message = phase ? "Remote Link failed during " + phase + (hint ? ": " + hint : "") : error.message; + const message = phase ? "Remote Link failed during " + phase : error.message; const recovery = ephemeralFlow ? RECOVERY_EPHEMERAL : RECOVERY_FULL; if (phase || error.body.residual === true) throw new Error(message + (error.body.residual === true ? ". " + recovery : "")); } diff --git a/src/cli/link.ts b/src/cli/link.ts index 4ddc805d9fe..c7591093281 100644 --- a/src/cli/link.ts +++ b/src/cli/link.ts @@ -7,12 +7,10 @@ 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"; import { assertSshAlias } from "../link/ssh-argv"; -import { boundHint } from "../link/ssh-runner"; import { linkStorePath } from "../link/paths"; import { isLinkPort } from "../link/ports"; import { readLinkStore, type LinkDirection, type LinkStore } from "../link/store"; @@ -38,7 +36,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 [--clients ] [--json] + ocx link issue --alias --tunnel-port [--json] ocx link status [--json] ocx link revoke --link-id [--purge] [--force --yes] [--json] ocx link reconnect --link-id [--json] @@ -248,22 +246,17 @@ async function linkRequest(path: string, init: RequestInit, deps: LinkCliDeps } catch (error) { if (error instanceof RuntimeApiError) { // Never echo an API error body: issue responses contain a one-time data key. Only the error - // code, the server's already-bounded hint and the residual flag survive (errorCodeBody). + // code survives, because callers branch on it and a code is never secret. throw new RuntimeApiError(`Link management request failed (${error.status})`, error.status, errorCodeBody(error.body)); } throw error; } } -function errorCodeBody(body: unknown): { error: { code: string; hint?: string }; residual?: true } | null { +function errorCodeBody(body: unknown): { error: { code: string }; residual?: true } | null { if (!isRecord(body) || !isRecord(body.error)) return null; const code = body.error.code; - if (typeof code !== "string" || !/^[a-z_]{1,64}$/.test(code)) return null; - // The hint is the only other field kept (#8357): the server builds it from a Child's last stderr - // line, already bounded and redacted, and it is bounded and redacted again here. The rest of - // the body is still never echoed. - const hint = typeof body.error.hint === "string" ? boundHint(body.error.hint) : undefined; - return { error: { code, ...(hint ? { hint } : {}) }, ...(body.residual === true ? { residual: true as const } : {}) }; + return typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? { error: { code }, ...(body.residual === true ? { residual: true as const } : {}) } : null; } async function runPort(args: string[], deps: LinkCliDeps): Promise { @@ -277,9 +270,6 @@ 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); @@ -290,7 +280,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, ...(selectedClients ? { selectedClients } : {}) }), + body: JSON.stringify({ alias, tunnelPort }), }, deps)); console.log(JSON.stringify(result)); } @@ -401,7 +391,7 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}): }); } -const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt", "appliedRouteIntentDigest", "routeClientOutcomes"] as const; +const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const; const WIRE_STATES = new Set(["connecting", "connected", "reconnecting", "failed", "idle"]); const CAUSES = new Set(TUNNEL_CAUSES); @@ -441,20 +431,10 @@ 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 0cb96d09997..a9f48283878 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] [--recover-config --yes] [--json]", + usage: "ocx disconnect [--keep-catalog] [--json]", summary: "Restore local client state offline and clear the remote-hub connection.", }, { name: "sync", - usage: "ocx sync [--expect-link | --expect-hub-key ] [--overwrite-conflict [--overwrite-client ]] [--local-only] [--json] [--restart-codex] [--restart-app-server-only]", + usage: "ocx sync [--expect-link | --expect-hub-key ] [--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, routing profiles and per-device client intent.", + usage: "ocx route ", + summary: "Manage combo routing and routing profiles.", }, { name: "effort", diff --git a/src/cli/route-to.ts b/src/cli/route-to.ts deleted file mode 100644 index 4627ad94374..00000000000 --- a/src/cli/route-to.ts +++ /dev/null @@ -1,169 +0,0 @@ -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 5af086d9d59..1aec4298005 100644 --- a/src/cli/sync-command.ts +++ b/src/cli/sync-command.ts @@ -50,8 +50,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { if (expectation || clientState.kind === "connected") { try { const { syncConnectedClient } = await import("../client/connect"); - const result = await syncConnectedClient({ restartCodex, ...(expectation ? { expect: expectation } : {}), - ...(syncArgs.includes("--overwrite-conflict") ? { overwriteConflict: true, ...(flags.overwriteClient ? { overwriteClient: flags.overwriteClient } : {}) } : {}) }); + const result = await syncConnectedClient(expectation ? { restartCodex, expect: expectation } : { restartCodex }); 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. @@ -76,13 +75,6 @@ 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."); @@ -199,17 +191,6 @@ 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); - 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; @@ -235,10 +216,7 @@ export async function runSyncCommand(deps: CliDispatchDeps): Promise { return { state: "failed", reason: "platform-probe-failed", exit: null }; } try { - // 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()) }); + const result = await runFanoutProcess(buildExecArgv({ alias: link.alias, knownHostsFile: linkKnownHostsPath(), argv: remotePinnedSyncArgv(link.id, platform) }), { 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 587d8257424..bbbcdf487a5 100644 --- a/src/cli/sync-fanout.ts +++ b/src/cli/sync-fanout.ts @@ -2,7 +2,6 @@ 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)."; @@ -51,9 +50,6 @@ 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); @@ -91,8 +87,6 @@ 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 5dec1fd6260..845b5e8068d 100644 --- a/src/cli/sync-flags.ts +++ b/src/cli/sync-flags.ts @@ -1,16 +1,10 @@ -import { isRouteClientId, type RouteClientId } from "../link/route-intent"; -export type SyncFanoutFlags = { ok: true; localOnly: boolean; json: boolean; overwriteClient?: RouteClientId } | { ok: false; message: string }; +export type SyncFanoutFlags = { ok: true; localOnly: boolean; json: boolean } | { ok: false; message: string }; export function parseSyncFanoutFlags(args: readonly string[]): SyncFanoutFlags { - for (const flag of ["--local-only", "--json", "--overwrite-conflict"]) { + for (const flag of ["--local-only", "--json"]) { 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.` }; } } - 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 } : {}) }; + return { ok: true, localOnly: args.includes("--local-only"), json: args.includes("--json") }; } diff --git a/src/client/connect.ts b/src/client/connect.ts index 4bf34ec57e2..af6bf711eaf 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -2,12 +2,10 @@ 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"; @@ -30,11 +28,9 @@ 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, @@ -100,15 +96,7 @@ import { import { hubStateCachePath } from "./hub-state"; import { ClientLinkStateError, clearClientLinkState, readClientLinkState } from "./link-state"; import { isLinkPort } from "../link/ports"; -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"; +import { clientSyncRuntimeGuard, ClientSyncRuntimeError } from "./sync-runtime"; class RotationRecoveryRequiredError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -122,8 +110,6 @@ 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 }; @@ -619,9 +605,7 @@ 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()); @@ -652,7 +636,6 @@ 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"); } @@ -738,62 +721,37 @@ 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 && selectedClients.includes("codex") + const homeSettings = linkMode && linkMetadata && options.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 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, { + const preflight = await injectCodexConfig(config.port, injectConfig, { validateOnly: true, routingTarget: target, catalogPath: DEFAULT_CATALOG_PATH, journalOwner: { kind: "client", apiKeyId }, - beforeClientWrite: beforeCodexWrite, + beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint), }); if (!preflight.success) throw new Error(preflight.message); - if (!options.noSync && selectedClients.includes("codex") && (!routeIntent || existsSync(getCodexHome()))) { + if (!options.noSync && options.selectedClients.includes("codex")) { const injected = await injectCodexConfig(config.port, injectConfig, { routingTarget: target, catalogPath: DEFAULT_CATALOG_PATH, journalOwner: { kind: "client", apiKeyId }, - beforeClientWrite: beforeCodexWrite, + beforeClientWrite: () => assertActiveConnectingState(persisted.fingerprint), }); if (!injected.success || injected.status === "skipped") throw new Error(injected.message); injectionCommitted = true; @@ -805,7 +763,7 @@ async function connectClientUnlocked( serverUrl, managementUrl, managementTransport: options.managementTransport, - selectedClients: [...selectedClients], + selectedClients: [...options.selectedClients], tokenEnv: "OPENCODEX_API_AUTH_TOKEN", apiKeyId, tokenFingerprint: persisted.fingerprint, @@ -821,10 +779,6 @@ 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; @@ -841,21 +795,6 @@ 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[] = []; @@ -866,25 +805,10 @@ 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 && !nativeRestoreFailed) { + if (pendingConnectFingerprint) { 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. @@ -915,7 +839,6 @@ async function connectClientUnlocked( } export interface ConnectedSyncResult { - routeIntent?: RouteIntentReceipt; catalogWritten: boolean; cacheSynced: boolean; injected: boolean; @@ -935,8 +858,6 @@ 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. @@ -1009,21 +930,17 @@ 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(); - else atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); + if (!downloaded) { validLocalCatalog(); return initial.connection; } + atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); const updated = { ...initial.connection, - selectedClients: [...connectedRouteSelectedClients(routeIntent, initial.connection.selectedClients)], - ...(downloaded ? { catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"), - catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString() } : {}), + catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"), + catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString(), }; commitClientConnection(updated); return updated; @@ -1031,7 +948,6 @@ 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"); @@ -1041,13 +957,7 @@ async function syncConnectedClientUnlocked( let homeSettings: HomeCodexSettingsSource | undefined; let appliedRecord: HomeCodexSettingsRecord | undefined; let applyRecordFailed = false; - 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"); - } - }; + if (next.selectedClients.includes("codex")) { let config = loadConfig(); // #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog. if (isLinkConnection(next)) { @@ -1061,15 +971,9 @@ 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: beforeCodexWrite, + journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite, }); if (!result.success || result.status === "skipped") throw new Error(result.message); injected = true; @@ -1088,9 +992,6 @@ 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(); @@ -1100,11 +1001,10 @@ async function syncConnectedClientUnlocked( return result.kind === "completed" && result.value; }, deps.lifecycleLockDeps); return { - catalogWritten: downloaded !== undefined, cacheSynced, injected, stale: stale || routeReceipt?.catalogPending === true || routeIntent?.stale === true, + catalogWritten: downloaded !== undefined, cacheSynced, injected, stale, ...(homeSettings ? { homeSettings } : {}), ...(appliedRecord ? { appliedRecord } : {}), ...(applyRecordFailed ? { applyRecordFailed } : {}), - ...(routeReceipt ? { routeIntent: routeReceipt } : {}), connection: next, }; } @@ -1169,101 +1069,28 @@ function verifyDisconnectCatalog(receipt: DesktopDisconnectReceipt): void { } } -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; - } +function restoreConnectedCodex(connection: OcxClientConnectionConfig): void { + if (!connection.selectedClients.includes("codex")) return; + const owner = journalOwner(); 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) { - 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; + if (!restoreJournalState().complete) throw new Error("disconnect refused: Codex journal restore was partial"); } 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; recoverConfig?: boolean } = {}, + options: { keepCatalog?: boolean; expectedOwner?: DesktopRemoteOwner } = {}, 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(); @@ -1303,14 +1130,11 @@ 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. - const recoveryBackupDir = prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored") - ? restoreConnectedCodex(prepared.connection, prepared.receipt, options.recoverConfig === true) : undefined; + if (prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored")) { + restoreConnectedCodex(prepared.connection); + } return withClientLifecycleSync(held => withConfigMutationLockSync(() => { const read = readDesktopDisconnectReceipt(); @@ -1361,17 +1185,6 @@ 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"); @@ -1395,7 +1208,6 @@ 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 fede84eb9d7..3452e602ee1 100644 --- a/src/client/link-home.ts +++ b/src/client/link-home.ts @@ -7,8 +7,7 @@ * process that took the tunnel port never sees the key. */ import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { parseHomeRouteIntentDocument, parseRouteClientOutcomes, ROUTE_CLIENT_IDS, routeIntentDigest, routeClientOutcomesForPeer, type HomeRouteIntentDocument, type RouteClientOutcome } from "../link/route-intent"; +import { join } from "node:path"; import { atomicWriteFile, isMissingPathError } from "../config/atomic-write"; import { readBoundedResponseBytes } from "../lib/bounded-body"; import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; @@ -21,7 +20,6 @@ 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"; @@ -338,22 +336,19 @@ 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, `"schema-rejected"` - * for a payload/schema refusal, or `"failed"` for every other refusal/transport failure. + * `"accepted"`, `"unsupported"` for a Home that predates the route, or `"failed"`. */ export async function sendLinkChildParityReport( connection: LinkHomeConnection, admissionKey: string, report: LinkChildParityReport, deps: LinkHomeDeps = {}, -): Promise<"accepted" | "unsupported" | "schema-rejected" | "failed"> { +): Promise<"accepted" | "unsupported" | "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, ...(report.routeClientOutcomes === undefined ? {} : { - routeClientOutcomes: routeClientOutcomesForPeer(report.routeClientOutcomes), - }) }); + const body = JSON.stringify(report); const request: RequestInit = { method: "POST", headers: { @@ -372,88 +367,5 @@ 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 f70425d965f..83c6163fc7a 100644 --- a/src/client/link-join.ts +++ b/src/client/link-join.ts @@ -316,21 +316,14 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string const thisAlias = localAlias(deps); let result: SshRunResult; try { - const issue = (selectedClients?: OcxConnectedClientId[]) => deps.runner.run( + result = await deps.runner.run( buildExecArgv({ alias: input.alias, - argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), ...(selectedClients ? ["--clients", selectedClients.join(",")] : []), "--json"]), + argv: remoteOcxArgv(["link", "issue", "--alias", thisAlias, "--tunnel-port", String(tunnelPort), "--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)); } @@ -390,7 +383,6 @@ 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 7fa14024e88..ff4d4e1f437 100644 --- a/src/client/link-parity-reporter.ts +++ b/src/client/link-parity-reporter.ts @@ -8,12 +8,9 @@ * 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, readHomeRouteIntentRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord, type HomeRouteIntentRecord } from "./link-home"; +import { readHomeCodexSettingsRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord } 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; @@ -28,7 +25,6 @@ function nextReportSequence(): number { export interface LinkParityReporter { start(): void; stop(): void; - requestReport(): void; } export interface LinkParityReporterDeps { @@ -37,9 +33,6 @@ 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; @@ -51,24 +44,18 @@ 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; - const report: LinkChildParityReport = Number.isFinite(applied) + return 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); @@ -79,8 +66,6 @@ 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; @@ -90,51 +75,20 @@ 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; } - 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); - } + const report = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record); + const outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed"; 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); - 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); - } + schedule(LINK_PARITY_RETRY_MS[failures]!); }; return { @@ -148,14 +102,6 @@ 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 deleted file mode 100644 index 51338048cae..00000000000 --- a/src/client/route-intent-apply.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** 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 deleted file mode 100644 index ee0b1c0fa9f..00000000000 --- a/src/client/route-intent-file-originals.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 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 deleted file mode 100644 index 2e73e1001d8..00000000000 --- a/src/client/route-intent-native-originals.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * 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 deleted file mode 100644 index e5119332bb3..00000000000 --- a/src/client/route-intent-watch.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** 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 1d1044f51ed..75e3462f147 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -34,15 +34,11 @@ 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; @@ -171,9 +167,6 @@ 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) { @@ -384,9 +377,6 @@ 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) { @@ -418,8 +408,6 @@ export async function startClientRuntime( credentialObservation = null; activeReporter?.stop(); activeReporter = null; - activeRouteWatch?.stop(); activeRouteWatch = null; - activeRouteSync?.stop(); activeRouteSync = null; try { await activeSupervisor?.stop(); } catch (error) { @@ -499,8 +487,6 @@ 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 c11d93824fe..883b013b6e9 100644 --- a/src/client/sync-runtime.ts +++ b/src/client/sync-runtime.ts @@ -35,13 +35,6 @@ 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/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 09178baa28e..8b2e88613d7 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -38,7 +38,6 @@ import { retainedNativeInheritanceTemplate } from "./native-template"; import type { RawCatalog, RawEntry } from "./parsing"; import { isSpawnableCodexCandidate } from "../exec-invocation"; import { validatedCodexExecInvocation, validatedCodexExecInvocationAsync } from "../exec-boundary"; -import { macCodexAppCommands } from "../mac-app-bundle"; import { isPlainObject } from "../../lib/plain-data"; import { parsePersistedCodexRuntime, @@ -52,7 +51,6 @@ import { import type { DeepReadonly, EffortClampDiagnostic, - ResolveCodexRuntimeResult, ResolvedCodexRuntime, } from "../runtime"; import type { @@ -334,37 +332,6 @@ function bundledRuntimeDeps(deps: BundledCatalogDeps) { }; } -/** - * Candidates for the bundled-catalog probe. - * - * When discovery finds no Codex CLI it answers with the bare `codex` sentinel, so the probe - * asks a command that does not exist while a real CLI sits inside ChatGPT.app. Discovery only - * offers that app candidate after classifying the bundle's XML `Info.plist` identity, so a - * bundle it cannot classify (a binary plist, or an identity assertion that moved with an app - * update) left `ocx connect status` reporting `unverified` on a macOS Child whose only Codex - * was the bundled CLI (#8360). - * - * The known app locations are therefore added after a bare sentinel. This list grants no - * trust: every execution still passes the app-bundle signature and ownership gate in - * `exec-boundary.ts`, so an unverifiable bundle yields `null` rather than an invented ladder. - * Windows needs no entry here because its Codex App install root under LOCALAPPDATA is already - * enumerated by `installedCodexCandidates` in `runtime.ts`. - */ -function bundledCatalogCandidates( - deps: BundledCatalogDeps, - resolved: ResolveCodexRuntimeResult | null, -): string[] { - const candidates = deps.commandCandidates?.() ?? [resolved!.runtime.command]; - // An explicit empty list asks for no probe at all. - if (candidates.length === 0) return candidates; - if ((deps.platform ?? process.platform) !== "darwin") return candidates; - // Only the unresolved sentinel is a bare name; a PATH or installed selection is absolute, and - // that selection is the runtime the operator is already running, not a gap to fill. - if (candidates.some(command => command.includes("/") || command.includes("\\"))) return candidates; - const exists = deps.existsSync ?? existsSync; - return [...candidates, ...macCodexAppCommands(deps).filter(command => exists(command))]; -} - function* bundledCatalogSteps( candidates: string[], cacheKey: string | null, @@ -423,7 +390,7 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): Readonly const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.execFile && !deps.configDir && !deps.env; const resolved = deps.commandCandidates ? null : resolveAndPersistCodexRuntime(bundledRuntimeDeps(deps)); const inputs = codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps)); - const steps = bundledCatalogSteps(bundledCatalogCandidates(deps, resolved), + const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command], useCache ? bundledRuntimeKey(resolved!.runtime) : null, () => inputs === codexRuntimeSelectionIdentity(bundledRuntimeDeps(deps))); let step = steps.next(); @@ -482,7 +449,7 @@ export function loadBundledCodexCatalogAsync( const runtimeEpoch = codexRuntimeStateEpoch(); const current = () => active() && runtimeEpoch === codexRuntimeStateEpoch() && flight.inputs === codexRuntimeSelectionIdentity(runtimeDeps); - const steps = bundledCatalogSteps(bundledCatalogCandidates(deps, resolved), + const steps = bundledCatalogSteps(deps.commandCandidates?.() ?? [resolved!.runtime.command], useCache ? bundledRuntimeKey(resolved!.runtime) : null, current, false); let step = steps.next(); while (!step.done) { diff --git a/src/codex/journal-config-restore.ts b/src/codex/journal-config-restore.ts deleted file mode 100644 index 5f928131ee0..00000000000 --- a/src/codex/journal-config-restore.ts +++ /dev/null @@ -1,217 +0,0 @@ -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 0836cbca561..fb8311a551e 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -10,7 +10,6 @@ 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. @@ -32,7 +31,6 @@ 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. @@ -96,7 +94,6 @@ 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; @@ -250,7 +247,6 @@ 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); @@ -358,7 +354,7 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner } export function restoreJournalState( - options: { heldConfigWriteLock?: LockHandle; preserveConfigEdits?: boolean; recoverConfigForClient?: string; beforeWrite?: () => void } = {}, + options: { heldConfigWriteLock?: LockHandle } = {}, ): RestoreJournalResult { if (codexHomeIsAbsent(CODEX_HOME)) return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, @@ -372,7 +368,6 @@ 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, @@ -382,12 +377,9 @@ 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 ((!recovery && comparison.unverified) || (recovery && !profileUnchanged)) { + if (comparison.unverified) { return { configRestored: comparison.configAlreadyOriginal, profileRestored: comparison.profileAlreadyOriginal, @@ -396,18 +388,10 @@ export function restoreJournalState( configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete: false, - unverified: comparison.unverified, + unverified: true, }; } - 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; @@ -424,16 +408,12 @@ 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 || fieldRestore !== null) && !configRestored) { - options.beforeWrite?.(); - publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, fieldRestore ?? comparison.originalConfig, undefined, hooks)); + if (configUnchanged && !configRestored) { + publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalConfig, undefined, hooks)); configRestored = true; configRewritten = true; } @@ -465,7 +445,6 @@ export function restoreJournalState( catch { complete = false; } } return { - ...(recoveryBackupDir ? { recoveryBackupDir } : {}), configRestored, profileRestored, configRewritten, diff --git a/src/codex/shim-restore-lock.ts b/src/codex/shim-restore-lock.ts index 1de3f257519..27d1eae8a78 100644 --- a/src/codex/shim-restore-lock.ts +++ b/src/codex/shim-restore-lock.ts @@ -32,107 +32,70 @@ interface ShimRestoreLockRecord { createdAt: number; } -interface ShimRestoreLockOwner { +interface ShimRestoreLockSnapshot { record: ShimRestoreLockRecord; ownerPath: string; - fingerprint: ShimPathFingerprint; -} - -interface ShimRestoreLockSnapshot { lockIdentity: FileIdentity; - /** Sorted by owner filename so two snapshots compare entry by entry. */ - owners: ShimRestoreLockOwner[]; + fingerprint: ShimPathFingerprint; } function restoreLockPath(): string { return join(getConfigDir(), "codex-shim.autorestore.lock"); } -function readShimRestoreLockOwner(path: string, entry: string): ShimRestoreLockOwner | null { - if (!entry.endsWith(".json")) return null; - const ownerPath = join(path, entry); - const probe = stableShimPathProbe(ownerPath); - if (!probe || probe.fingerprint.kind !== "file" || probe.fingerprint.size > 4096) return null; - try { - const value = JSON.parse(probe.prefix) as Partial; - if (value.version !== 1 || typeof value.token !== "string" || value.token.length === 0 - || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 - || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; - if (entry !== `${value.token}.json`) return null; - return { record: value as ShimRestoreLockRecord, ownerPath, fingerprint: probe.fingerprint }; - } catch { - return null; - } -} - -/** - * Every entry must be a valid token-named owner record; one unaccounted entry (foreign file, - * unparsable or empty record, mismatched name) makes the whole lock unreadable. A normal lock - * holds one owner. Two or more are not produced by the current acquire path, but have been - * observed in the field (#8317) and must stay recoverable rather than block repair forever. - */ function readShimRestoreLockSnapshot(path: string): ShimRestoreLockSnapshot | null { let lockIdentity: BigIntStats; let entries: string[]; try { lockIdentity = lstatSync(path, { bigint: true }); if (!lockIdentity.isDirectory()) return null; - entries = readdirSync(path).sort(); + entries = readdirSync(path); } catch { return null; } - if (entries.length === 0) return null; - const owners: ShimRestoreLockOwner[] = []; - for (const entry of entries) { - const owner = readShimRestoreLockOwner(path, entry); - if (!owner) return null; - owners.push(owner); - } + if (entries.length !== 1 || !entries[0].endsWith(".json")) return null; + const ownerPath = join(path, entries[0]); + const probe = stableShimPathProbe(ownerPath); + if (!probe || probe.fingerprint.kind !== "file" || probe.fingerprint.size > 4096) return null; try { + const value = JSON.parse(probe.prefix) as Partial; + if (value.version !== 1 || typeof value.token !== "string" || value.token.length === 0 + || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 + || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; + if (entries[0] !== `${value.token}.json`) return null; const currentLockIdentity = lstatSync(path, { bigint: true }); if (!currentLockIdentity.isDirectory() || !sameFileIdentity(lockIdentity, currentLockIdentity)) return null; + return { + record: value as ShimRestoreLockRecord, + ownerPath, + lockIdentity, + fingerprint: probe.fingerprint, + }; } catch { return null; } - return { lockIdentity, owners }; -} - -/** Release only ever removes a lock it solely owns; an ambiguous set is left to stale recovery. */ -function soleShimRestoreLockOwner(path: string): ShimRestoreLockOwner | null { - const snapshot = readShimRestoreLockSnapshot(path); - return snapshot?.owners.length === 1 ? snapshot.owners[0] : null; } function sameShimRestoreLock(left: ShimRestoreLockSnapshot, right: ShimRestoreLockSnapshot): boolean { - return sameFileIdentity(left.lockIdentity, right.lockIdentity) - && left.owners.length === right.owners.length - && left.owners.every((owner, index) => owner.record.token === right.owners[index].record.token - && sameFingerprint(owner.fingerprint, right.owners[index].fingerprint)); + return left.record.token === right.record.token + && sameFileIdentity(left.lockIdentity, right.lockIdentity) + && sameFingerprint(left.fingerprint, right.fingerprint); } -/** - * Reclaim requires every owner record to be past the stale window and to name a dead PID. - * Any live, fresh or unaccounted entry keeps the lock: age alone never evicts a live holder, - * and an entry this code cannot attribute might belong to an owner it cannot judge. - */ function reclaimStaleRestoreLock(path: string, beforeDelete?: () => void): boolean { const observed = readShimRestoreLockSnapshot(path); if (!observed) return false; - const now = Date.now(); - for (const owner of observed.owners) { - const createdAt = Math.max(owner.record.createdAt, owner.fingerprint.mtimeMs); - if (now - createdAt <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false; - if (isProcessAlive(owner.record.pid)) return false; - } + const createdAt = Math.max(observed.record.createdAt, observed.fingerprint.mtimeMs); + if (Date.now() - createdAt <= CODEX_SHIM_RESTORE_LOCK_STALE_MS) return false; + if (isProcessAlive(observed.record.pid)) return false; const current = readShimRestoreLockSnapshot(path); if (!current || !sameShimRestoreLock(observed, current)) return false; beforeDelete?.(); try { - // The token is part of each owner filename. Even if the lock directory is - // replaced after the comparison, these unlinks cannot target a successor's - // differently named owner record. A failure part-way leaves a smaller set - // of the same dead owners, which the next attempt reclaims. - for (const owner of observed.owners) unlinkSync(owner.ownerPath); + // The token is part of the owner filename. Even if the lock directory is + // replaced after the comparison, this unlink cannot target a successor's + // differently named owner record. + unlinkSync(observed.ownerPath); rmdirSync(path); return true; } catch { @@ -169,7 +132,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL released = true; try { closeSync(fd!); } catch { /* stale recovery handles an uncertain lock */ } try { - const current = soleShimRestoreLockOwner(path); + const current = readShimRestoreLockSnapshot(path); if (identity && current && current.record.token === record.token && sameFileIdentity(identity, current.fingerprint.identity)) { unlinkSync(ownerPath); @@ -182,7 +145,7 @@ function tryAcquireShimRestoreLock(beforeStaleDelete?: () => void): ShimRestoreL if (fd !== null) { try { closeSync(fd); } catch { /* best-effort close before ownership cleanup */ } try { - const current = soleShimRestoreLockOwner(path); + const current = readShimRestoreLockSnapshot(path); if (identity && current && current.record.token === record.token && sameFileIdentity(identity, current.fingerprint.identity)) { unlinkSync(ownerPath); diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 1ba0b7decbc..e06104c0709 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -13,7 +13,6 @@ import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, REASONING_SUMMARY_DELIVERY_VALUES, - THINKING_DISPLAY_VALUES, UPSTREAM_HTTP_VERSION_VALUES, type OcxProviderConfig, type ModelCapabilities, @@ -30,7 +29,6 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([ "x-amz-security-token", ]); const REASONING_SUMMARY_DELIVERY_SET = new Set(REASONING_SUMMARY_DELIVERY_VALUES); -const THINKING_DISPLAY_SET = new Set(THINKING_DISPLAY_VALUES); const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; const MAX_MODEL_DISPLAY_NAME_LENGTH = 128; @@ -387,33 +385,6 @@ export function reasoningSummaryDeliveryRecordConfigError( return null; } -/** Validate `thinkingDisplay`: one closed vocabulary shared by config and management writes. */ -export function thinkingDisplayConfigError(value: unknown, field = "thinkingDisplay"): string | null { - if (value === undefined) return null; - if (typeof value !== "string" || !THINKING_DISPLAY_SET.has(value)) { - return `${field} must be one of: ${THINKING_DISPLAY_VALUES.join(", ")}`; - } - return null; -} - -/** Validate `modelThinkingDisplay`: a plain map of model id to the same closed vocabulary. */ -export function modelThinkingDisplayConfigError(value: unknown, field = "modelThinkingDisplay"): string | null { - if (value === undefined) return null; - if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; - for (const [key, entry] of Object.entries(value)) { - const safeKey = JSON.stringify(redactSecretString(key)); - if (!key.trim()) return `${field} keys must be nonblank model ids`; - if (["__proto__", "prototype", "constructor"].includes(key)) { - return `${field} keys must not be reserved object keys`; - } - const entryError = thinkingDisplayConfigError(entry, `${field}.${safeKey}`); - if (entryError) return entryError; - } - return null; -} - /** Validate a provider's per-model wire override map against runtime routing rules. */ export function modelAdapterRecordConfigError( value: unknown, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 3838c5a949e..b57c3efa537 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -60,8 +60,6 @@ import { providerHeadersConfigError, providerForwardClientHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, - thinkingDisplayConfigError, - modelThinkingDisplayConfigError, credentialPinnedProviderConfigError, } from "../provider-validation"; import { @@ -769,26 +767,6 @@ export const configSchema = z.object({ message: reasoningSummaryDeliveryError, }); } - const thinkingDisplayError = thinkingDisplayConfigError( - (provider as { thinkingDisplay?: unknown }).thinkingDisplay, - ); - if (thinkingDisplayError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "thinkingDisplay"], - message: thinkingDisplayError, - }); - } - const modelThinkingDisplayError = modelThinkingDisplayConfigError( - (provider as { modelThinkingDisplay?: unknown }).modelThinkingDisplay, - ); - if (modelThinkingDisplayError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelThinkingDisplay"], - message: modelThinkingDisplayError, - }); - } const minMaxOutputError = positiveIntegerConfigError( (provider as { minMaxOutputTokens?: unknown }).minMaxOutputTokens, "minMaxOutputTokens", diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 594f910c11d..5df3213e410 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1,5 +1,4 @@ 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"; @@ -30,7 +29,6 @@ import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter, PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS, - THINKING_DISPLAY_VALUES, UPSTREAM_HTTP_VERSION_VALUES, type OcxProviderConfig, type OcxConfig, @@ -362,10 +360,6 @@ export const providerConfigSchema = z.object({ inferContextWindow: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), - // Validated against a closed vocabulary here as well as at the write boundaries, so a - // hand-edited typo cannot load silently and leave the operator with the built-in display (#8363). - thinkingDisplay: z.enum(THINKING_DISPLAY_VALUES).optional(), - modelThinkingDisplay: z.record(z.string().min(1), z.enum(THINKING_DISPLAY_VALUES)).optional(), promoteAdditionalTools: z.boolean().optional(), preserveResponsesReasoningContent: z.boolean().optional(), preserveResponsesInputItemIds: z.boolean().optional(), @@ -963,7 +957,7 @@ export const remoteGuiConfigSchema = z.object({ allowInsecureHttp: z.boolean().optional(), }).strict(); -const connectedClientIdSchema = z.enum(["codex", "claude", "claude-desktop", "grok", ...EXPORT_CLIENT_IDS]); +const connectedClientIdSchema = z.enum(["codex", "claude"]); const clientTimestampSchema = z.string().datetime({ offset: true }); const clientTransportSchema = z.enum(["hub", "link"]); const linkTransportSchema = z.object({ @@ -986,7 +980,7 @@ export const clientConnectionSchema = z.object({ managementTransport: z.enum(["direct", "relay"]), transport: clientTransportSchema.optional(), link: linkTransportSchema.optional(), - selectedClients: z.array(connectedClientIdSchema).superRefine((clients, ctx) => { + selectedClients: z.array(connectedClientIdSchema).min(1).max(2).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/owned-refresh.ts b/src/integrations/owned-refresh.ts index 8885f64c595..7f9e848e9a7 100644 --- a/src/integrations/owned-refresh.ts +++ b/src/integrations/owned-refresh.ts @@ -44,14 +44,8 @@ export interface OwnedIntegrationRefreshOutcome { } /** - * Returns `null` when the client has never been connected by OpenCodex, or - * when it was connected and has since been uninstalled. Callers use that - * distinction to report "left alone" rather than "skipped". - * - * An uninstalled client keeps its ownership record: an implicit refresh must - * not edit ownership, and a reinstall resumes refreshing. Reporting it as a - * failure instead turned every model toggle into a "pi is not installed" - * warning for a client the user no longer has. + * Returns `null` when the client has never been connected by OpenCodex. + * Callers use that distinction to report "left alone" rather than "skipped". */ export async function refreshOwnedIntegration( input: OwnedIntegrationRefreshInput, @@ -78,7 +72,6 @@ export async function refreshOwnedIntegration( input.io?.now ?? Date.now, () => refreshIntegrationCoordinated(bound, options), ); - if (!result.ok && result.reason === "not_installed") return null; return result.ok ? { client: input.clientId, diff --git a/src/integrations/route-intent-catalog.ts b/src/integrations/route-intent-catalog.ts deleted file mode 100644 index 161f1c5df09..00000000000 --- a/src/integrations/route-intent-catalog.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** 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 deleted file mode 100644 index e2669673eac..00000000000 --- a/src/integrations/route-intent-connected.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** 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 deleted file mode 100644 index 1c56a98bef2..00000000000 --- a/src/integrations/route-intent-contract.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 2ec9d6e288d..00000000000 --- a/src/integrations/route-intent-disconnect.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** 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 deleted file mode 100644 index 73cd8030a07..00000000000 --- a/src/integrations/route-intent-initial.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** 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 deleted file mode 100644 index 83cae967ba6..00000000000 --- a/src/integrations/route-intent-state.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 3469351c00f..00000000000 --- a/src/integrations/route-intent-tick.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 5f61eecc898..00000000000 --- a/src/integrations/route-intent.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** 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/lib/bun-runtime-preflight.ts b/src/lib/bun-runtime-preflight.ts index eb71a7551d4..3f3e7f6a68b 100644 --- a/src/lib/bun-runtime-preflight.ts +++ b/src/lib/bun-runtime-preflight.ts @@ -9,7 +9,7 @@ export const RUNTIME_PREFLIGHT_TIMEOUT_MS = 5_000; export const RUNTIME_PREFLIGHT_GUIDANCE = "The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. " + "Windows application policy may deny writes from this executable location. " + "Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, " - + "or reinstall opencodex with npm install -g --allow-scripts=bun @bitkyc08/opencodex, then retry. No alternate runtime was selected."; + + "or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected."; export class RuntimePreflightError extends Error { readonly code = "OCX_RUNTIME_PREFLIGHT_FAILED"; diff --git a/src/link/child-parity.ts b/src/link/child-parity.ts index bd7cc4d6b53..220ffbc7f43 100644 --- a/src/link/child-parity.ts +++ b/src/link/child-parity.ts @@ -14,12 +14,11 @@ */ 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 = 8192; +export const LINK_CHILD_PARITY_MAX_BYTES = 1024; const LINK_ID = /^lnk_[0-9a-f]{16}$/; /** 2001-09-09 .. 2286-11-20: any epoch-millisecond time a real clock reports. */ @@ -34,8 +33,6 @@ 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. @@ -48,8 +45,6 @@ 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; } @@ -70,9 +65,7 @@ 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 }; + return { linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest: digest, sequence }; } export interface LinkChildParityStore { @@ -115,10 +100,6 @@ 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, }); @@ -128,7 +109,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, ...(parity.routeClientOutcomes === undefined ? {} : { routeClientOutcomes: routeClientOutcomesForPeer(parity.routeClientOutcomes) }) }; + return parity; }, clear() { reports.clear(); }, }; diff --git a/src/link/management-routes.ts b/src/link/management-routes.ts index c2b859f0a8c..98500320fca 100644 --- a/src/link/management-routes.ts +++ b/src/link/management-routes.ts @@ -16,8 +16,6 @@ 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", @@ -73,7 +71,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 || url.pathname === LINK_ROUTE_INTENT_PATH || url.pathname === LINK_ROUTE_CATALOG_PATH) return req.method === "GET" && url.search === ""; + if (url.pathname === LINK_CODEX_SETTINGS_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 b50281cb55c..ff6d84e6ed8 100644 --- a/src/link/remote-windows.ts +++ b/src/link/remote-windows.ts @@ -1,4 +1,3 @@ -import type { RouteClientId } from "./route-intent"; import { Buffer } from "node:buffer"; import { deflateRawSync, inflateRawSync } from "node:zlib"; @@ -211,27 +210,16 @@ Get-ChildItem -LiteralPath $dir -Filter '.ocx-child.purge.*.old' -Force -EA 0|%{ `); } -/** - * 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` +export function windowsPinnedSyncArgv(linkId: string): string[] { + return packedPowerShellArgv(NATIVE_PROCESS + dataLine({ args: ["sync", "--expect-link", linkId, "--json"] }) + OCX_CANDIDATES + String.raw` foreach($x in ($c|select -Unique)){ - 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)} + 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)} $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=@(` + minimum + String.raw`);$cmp=0 + $a=@($m.Groups[1].Value,$m.Groups[2].Value,$m.Groups[3].Value);$b=@('2','93','0');$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('` + lacks + String.raw`');exit 121} + 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} [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 deleted file mode 100644 index 1f740c5cca6..00000000000 --- a/src/link/route-intent-watch.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** 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 deleted file mode 100644 index de1d578aedb..00000000000 --- a/src/link/route-intent.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** 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 c471c495e8c..5647d6bb168 100644 --- a/src/link/ssh-argv.ts +++ b/src/link/ssh-argv.ts @@ -1,4 +1,3 @@ -import { isRouteClientId, type RouteClientId } from "./route-intent"; /** * Argument vectors for the system OpenSSH client used by machine links. * @@ -20,7 +19,7 @@ import { isRouteClientId, type RouteClientId } from "./route-intent"; */ import { isAbsolute } from "node:path"; -import { POWERSHELL_ENCODED_MAX, ROUTE_OVERWRITE_MIN_CHILD_VERSION, windowsOcxArgv, windowsPinnedSyncArgv } from "./remote-windows"; +import { POWERSHELL_ENCODED_MAX, windowsOcxArgv, windowsPinnedSyncArgv } from "./remote-windows"; export type TunnelDirection = "R" | "L"; @@ -304,26 +303,6 @@ try_ocx() { ${REMOTE_OCX_CANDIDATES} exit ${REMOTE_COMMAND_NOT_FOUND}`; -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]; +export function remotePinnedSyncArgv(linkId: string, platform: "posix" | "windows" = "posix"): string[] { + return platform === "windows" ? windowsPinnedSyncArgv(linkId) : ["sh", "-c", REMOTE_PINNED_SYNC_SCRIPT, "ocx", linkId]; } diff --git a/src/providers/github-copilot-auto.ts b/src/providers/github-copilot-auto.ts index e42347342be..29797761aa6 100644 --- a/src/providers/github-copilot-auto.ts +++ b/src/providers/github-copilot-auto.ts @@ -130,32 +130,15 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM let models: CopilotModel[]; try { models = await fetchCopilotAutoModels(transport, signal, beforeSend, 60_000, concurrency); } catch (error) { - if (signal?.aborted) throw error; - // A manual account switch makes `beforeSend` return false, which cancels this - // control-plane send. That is not a client abort: fall back to the requested model - // so the dispatch path revalidates the selection and rebuilds on the new account. - if (error instanceof ProviderOutboundSendCancelledError) { - return { provider: transport, modelId: requestedModel, auto: false }; - } + if (signal?.aborted || error instanceof ProviderOutboundSendCancelledError) throw error; // Permission discovery is additive for legacy paid configurations. A temporary // discovery outage must not turn a previously usable named route into a refusal. return { provider: transport, modelId: requestedModel, auto: false }; } if (!copilotRequiresAuto(provider, models)) return { provider: transport, modelId: requestedModel, auto: false }; - // The same authority fence guards the later control-plane sends: a manual switch that - // lands during the session or intent step falls back too, so dispatch rebuilds on the - // new account instead of failing the whole negotiation. A client abort still rejects. - const fallBackOnSwitch = (error: unknown) => { - if (!signal?.aborted && error instanceof ProviderOutboundSendCancelledError) - return { provider: transport, modelId: requestedModel, auto: false } as const; - throw error; - }; - let session: Awaited>; - try { - session = await requestJson(transport, "/models/session", signal, - { auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency); - } catch (error) { return fallBackOnSwitch(error); } + const session = await requestJson(transport, "/models/session", signal, + { auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency); const pool = strings(session.available_models); if (typeof session.session_token !== "string" || !session.session_token || session.session_token.length > 32_768 || !/^[\x21-\x7e]+$/.test(session.session_token) || !pool.length) throw new Error("GitHub Copilot Auto session omitted its routing credentials"); @@ -170,11 +153,8 @@ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedM ? typeof lastUser.content === "string" ? lastUser.content : lastUser.content.flatMap(/** Extract text parts for intent negotiation without forwarding image or other multimodal payloads. */ part => part.type === "text" ? [part.text] : []).join("\n") : ""; - let intent: Awaited>; - try { - intent = await requestJson(transport, "/models/session/intent", signal, - { prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency); - } catch (error) { return fallBackOnSwitch(error); } + const intent = await requestJson(transport, "/models/session/intent", signal, + { prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency); const modelId = strings(intent.candidate_models).find(/** Choose the first server-ranked candidate that belongs to this credential-bound session pool. */ candidate => pool.includes(candidate)); if (!modelId) throw new Error("GitHub Copilot Auto intent returned no eligible model"); diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 62dde304cd3..a97202d8bdc 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -103,8 +103,6 @@ export const PROVIDER_MODEL_RENAME_ROLES = { modelSupportsVerbosity: "record", supportsVerbosity: "none", modelReasoningSummaryDelivery: "record", - thinkingDisplay: "none", - modelThinkingDisplay: "record", modelPreferHostedTools: "record", supportsOpenAiWebSearchToolFields: "none", xaiResponsesXSearch: "none", diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 9a258c0628d..e34d581d787 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -24,13 +24,7 @@ async function quotaFetch(providerName: string, config: OcxProviderConfig, url: } const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; -/** - * Kimi's regional (kimi.ai) Coding API. Its `/usages` answers a Coding Plan API key the same way - * the kimi.com origin does (#8332). Only key auth may use it: the probe then calls the origin that - * already receives this key for inference, so it adds no credential destination. OAuth tokens - * stay on the kimi.com origin they were issued for. - */ -const KIMI_CODE_REGIONAL_BASE_URL = "https://api.kimi.ai/coding/v1"; +const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`; const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai"; const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`; const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`; @@ -1125,18 +1119,6 @@ export function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean { return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL; } -/** - * The Kimi Coding origin whose `/usages` a quota probe may call with this provider's bearer, or - * null. It is always the configured inference origin, matched exactly against Kimi's own origins, - * so a user-edited or lookalike host never receives the credential. - */ -function kimiCodeQuotaBaseUrl(config: Pick): string | null { - const normalized = normalizedBaseUrl(config.baseUrl); - if (normalized === KIMI_CODE_BASE_URL) return normalized; - if (normalized === KIMI_CODE_REGIONAL_BASE_URL && (config.authMode ?? "key") === "key") return normalized; - return null; -} - export function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean { const normalized = normalizedBaseUrl(baseUrl); // OAuth preset points at the API root; the Provider-API preset at /provider/v1. @@ -1288,10 +1270,9 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig, accountId?: str export async function fetchKimiQuota(provider: string, config: OcxProviderConfig, accessToken: string): Promise { // Never release credentials to a user-edited or lookalike provider host. - const baseUrl = kimiCodeQuotaBaseUrl(config); - if (!baseUrl) return null; + if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null; if (!accessToken) return null; - const response = await quotaFetch(provider, config, `${baseUrl}/usages`, { + const response = await quotaFetch(provider, config, KIMI_CODE_USAGE_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -1454,7 +1435,7 @@ type KeyQuotaReader = (name: string, provider: OcxProviderConfig) => Promise { const bearer = await resolveKimiQuotaBearer(config); return bearer ? fetchKimiQuota(id, config, bearer) : null; diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index fa3e6f6b29a..bacdb6dfb97 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -43,7 +43,6 @@ export type StaticProviderPolicyField = | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "showThinkingSummary" | "escapeBuiltinToolNames" | "googleMode" | "project" | "location" | "modelCapabilities" | "modelAutoCompactTokenLimits" | "modelSuppressSyntheticMax" | "modelReasoningSummaryDelivery" - | "thinkingDisplay" | "modelThinkingDisplay" | "codexToolMode" | "modelAdapters"; type StaticProviderPolicyShape = Pick; export type ResolvedProviderStaticPolicy = Readonly> & Readonly<{ @@ -183,8 +182,6 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode "supportsVerbosity", "responsesItemIdRepair", "showThinkingSummary", "escapeBuiltinToolNames", "googleMode", "project", "location", ] as const) putScalar(key, entry?.[key] as StaticProviderPolicyShape[typeof key] | undefined); - // Operator-only display policy; no registry entry declares it (#8363). - put("thinkingDisplay", provider.thinkingDisplay, provider.thinkingDisplay !== undefined ? "operator" : "unknown"); const legacyClinePassLadder = entry !== undefined && input.providerName === "cline-pass" && provider.reasoningWireFormat === "gateway-object" && sameStringArray(provider.reasoningEfforts, ["low"]); @@ -221,7 +218,6 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode for (const key of [ "modelAutoCompactTokenLimits", "modelSuppressSyntheticMax", "modelReasoningSummaryDelivery", "codexToolMode", "modelAdapters", - "modelThinkingDisplay", ] as const) putScalar(key, undefined); put("modelCapabilities", mergeReasoningModelCapabilities(entry?.modelCapabilities, provider.modelCapabilities), provider.modelCapabilities !== undefined ? "operator-capability" : entry?.modelCapabilities !== undefined ? "registry" : "unknown"); diff --git a/src/routing/home-route-capture.ts b/src/routing/home-route-capture.ts deleted file mode 100644 index 55ef3ad0684..00000000000 --- a/src/routing/home-route-capture.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 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 deleted file mode 100644 index 32ae8226567..00000000000 --- a/src/routing/home-route-intent.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** 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 deleted file mode 100644 index cf209864fec..00000000000 --- a/src/routing/route-intent-state.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** 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 deleted file mode 100644 index a67095346d3..00000000000 --- a/src/routing/route-to-live-settings.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 2b12d60d17d..00000000000 --- a/src/routing/route-to-local.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** 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 { initializeHomeRouteIntentDefaults, readHomeRouteIntentState } from "./home-route-intent"; -import { routeToDeviceStatus, type RouteToDeviceStatus, type RouteToStatus } from "./route-to-status"; -import { withDurableRouteNativeSettings } from "./route-to-live-settings"; -import { dropLegacyHomeNativeOverrides } 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); -} - -/** - * `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. - */ -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 } = {}): Promise { - return withLinkApply(async () => { - commit?.(); - const local = withDurableRouteNativeSettings(config); - // 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. - initializeHomeRouteIntentDefaults(local, version, undefined, { whenLinked: true }); - dropLegacyHomeNativeOverrides(); - const decidedIntent = deps.onlyIntent?.(); - const document = projectHomeRouteIntent(local, version, "home", readHomeRouteIntentState(local), - { 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 ? undefined : [deps.onlyClient]; - // A single-client write keeps the other clients' last proven outcomes in the Home receipt. - const scope = only ? { retainSkippedOutcomes: true, 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 }); - return 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 deleted file mode 100644 index c944491ca62..00000000000 --- a/src/routing/route-to-status.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index e7c5e2d863a..00000000000 --- a/src/routing/route-to-sync-child.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/auth-cors.ts b/src/server/auth-cors.ts index 40ba18f5c16..da6e6e32f31 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -29,8 +29,6 @@ import { providerHeadersConfigError, providerForwardClientHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, - thinkingDisplayConfigError, - modelThinkingDisplayConfigError, upstreamHttpVersionConfigError, credentialPinnedProviderConfigError, } from "../config/provider-validation"; @@ -951,10 +949,6 @@ export function providerManagementConfigError( raw.modelSupportsReasoningSummaries, ); if (reasoningSummaryDeliveryError) return `provider ${name} ${reasoningSummaryDeliveryError}`; - const thinkingDisplayError = thinkingDisplayConfigError(raw.thinkingDisplay); - if (thinkingDisplayError) return `provider ${name} ${thinkingDisplayError}`; - const modelThinkingDisplayError = modelThinkingDisplayConfigError(raw.modelThinkingDisplay); - if (modelThinkingDisplayError) return `provider ${name} ${modelThinkingDisplayError}`; const modelAdaptersError = modelAdapterRecordConfigError(raw.modelAdapters, "modelAdapters", name, typed); if (modelAdaptersError) return `provider ${name} ${modelAdaptersError}`; const credentialPinError = credentialPinnedProviderConfigError(name, typed); @@ -1181,8 +1175,6 @@ const PROVIDER_CONFIG_FIELD_POLICY = { modelSupportsVerbosity: "editor", supportsVerbosity: "editor", modelReasoningSummaryDelivery: "editor", - thinkingDisplay: "editor", - modelThinkingDisplay: "editor", modelPreferHostedTools: "editor", supportsOpenAiWebSearchToolFields: "editor", xaiResponsesXSearch: "editor", diff --git a/src/server/index/link-listener.ts b/src/server/index/link-listener.ts index bc1e088314c..4f020381c9c 100644 --- a/src/server/index/link-listener.ts +++ b/src/server/index/link-listener.ts @@ -11,7 +11,6 @@ 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"; @@ -37,10 +36,6 @@ 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 = { @@ -105,32 +100,6 @@ 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; @@ -144,10 +113,6 @@ 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 []; @@ -211,8 +176,6 @@ 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(() => { @@ -224,9 +187,6 @@ 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; @@ -271,7 +231,6 @@ 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 bb264d8c5c1..a6b8e3a9c99 100644 --- a/src/server/index/link-management.ts +++ b/src/server/index/link-management.ts @@ -12,9 +12,7 @@ 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, 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 { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes"; 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"; @@ -78,21 +76,9 @@ 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 deleted file mode 100644 index f68f9f12fed..00000000000 --- a/src/server/index/route-intent-watch.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 6abe81aaaa5..867228df12b 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -398,7 +398,6 @@ 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 d8f4f7df9ca..dc206ce4e2e 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -176,12 +176,6 @@ 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 @@ -376,7 +370,6 @@ 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 0e2dccb6648..a3f27971255 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -9,7 +9,6 @@ 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"; @@ -1122,9 +1121,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise { - const result = setHomeIntegrationEnabled("claude-desktop", true); + const result = setIntegrationEnabled("claude-desktop", true); if (result.ok) mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); return result; }); @@ -1213,10 +1212,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise { - const result = setHomeIntegrationEnabled("claude-desktop", true); + const result = setIntegrationEnabled("claude-desktop", true); if (result.ok) mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); return result; }); @@ -1843,10 +1841,6 @@ 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 2ca717145b7..1d95ac15456 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -48,8 +48,6 @@ 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"; @@ -283,33 +281,6 @@ 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, @@ -715,12 +686,10 @@ 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; @@ -739,14 +708,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise= 200 && aside.status < 300 ? recordHomeIntegrationDecision(ctx, "aside", aside) : aside; + if (aside) return aside; if (url.searchParams.has("profile") && (url.pathname === INTEGRATION_COLLECTION_PATH || url.pathname.startsWith(INTEGRATION_ROUTE_PREFIX)) && !INTEGRATION_HISTORY_PATHS.includes(url.pathname)) { @@ -1003,20 +966,13 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise= 200 && asideRestore.status < 300 - ? recordHomeIntegrationDecision(ctx, "aside", asideRestore) : asideRestore; + if (asideRestore) return asideRestore; let restoreClientId: IntegrationClientId | undefined; try { const store = integrationStore(ctx); @@ -1082,9 +1038,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise ownedIntent(store, operation.clientId)) : restored; + return jsonResponse(result satisfies IntegrationRestoreEnvelope, 200, req, ctx.config); } catch (error) { if (error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError) { return jsonResponse({ @@ -1177,14 +1131,6 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise ({ enabled: parsed.enabled === true, - ...(modelNames !== undefined && (requestedClient === "hermes" || requestedClient === "commandcode") ? { modelNames } : {}) })) : toggled; + return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config); } catch (error) { if (error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError) { return jsonResponse({ diff --git a/src/server/management/link-child-preflight.ts b/src/server/management/link-child-preflight.ts index d3332f03dee..6bcedc8ce90 100644 --- a/src/server/management/link-child-preflight.ts +++ b/src/server/management/link-child-preflight.ts @@ -1,4 +1,4 @@ -import { SSH_MAX_OUTPUT_BYTES, boundHint, sshFailureHint } from "../../link/ssh-runner"; +import { SSH_MAX_OUTPUT_BYTES } from "../../link/ssh-runner"; /** * Home enrollment reads one Child command before it may restart that Child: `ocx config show @@ -19,56 +19,3 @@ export const CHILD_CONFIG_TOO_LARGE_MESSAGE = "The Child configuration is too la /** The operator-facing hint for a Child whose config output exceeded even the raised cap. */ export const CHILD_CONFIG_TOO_LARGE_HINT = `ocx config show --json on the Child exceeded ${CHILD_PREFLIGHT_MAX_OUTPUT_BYTES} bytes; trim the Child's saved configuration and retry`; - -/** - * Why the Child's effective configuration could not be read (#8357): a failed `config show` - * names its own last stderr line (bounded and redacted by `sshFailureHint`, e.g. an invalid - * saved configuration), and a successful one that printed no usable JSON says so. - */ -export function childConfigFailureHint(result: { code: number; stderr: string }): string { - if (result.code !== 0) return sshFailureHint(result.stderr) ?? `ocx config show --json exited ${result.code} on the Child`; - return "ocx config show --json on the Child did not print a configuration with a valid port"; -} - -/** `ocx config validate --json` prints one small object; this bounds a misbehaving Child. */ -export const CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES = 16 * 1024; - -/** - * The validation reason a full `ocx` Child gives for its own saved configuration, or undefined. - * - * `config show` on an invalid configuration prints only a generic warning whose path can push - * the pointer past the 160-code-point hint cap, so it never names the defect (#8357). `config - * validate --json` answers `{ "ok": false, "error": "schema_invalid: : " }`, naming - * fields and expected types, never configured values. Only that `error` string is read from - * stdout, and `boundHint` still redacts and caps it. A Child without the command (`ocx-child`, - * an older `ocx`) prints nothing usable and the caller keeps the `config show` reason. - */ -export function childConfigValidationHint(result: { code: number; stdout: string }): string | undefined { - if (result.code === 0) return undefined; - let parsed: unknown; - try { parsed = JSON.parse(result.stdout); } catch { return undefined; } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - const { ok, error } = parsed as { ok?: unknown; error?: unknown }; - if (ok !== false || typeof error !== "string") return undefined; - return boundHint(`Child config is invalid: ${error}`); -} - -/** - * The hint for an unreadable Child configuration: the Child's own validation reason when `config - * show` failed and `config validate` can name it, otherwise `childConfigFailureHint`. The - * extra command runs only on this failure path and is read-only on the Child. - */ -export async function childConfigFailureReason( - configured: { code: number; stderr: string }, - validate: () => Promise<{ code: number; stdout: string }>, -): Promise { - if (configured.code !== 0) { - try { - const reason = childConfigValidationHint(await validate()); - if (reason) return reason; - } catch { - // A timeout or transport failure here must not mask the config show reason. - } - } - return childConfigFailureHint(configured); -} diff --git a/src/server/management/link-enrollment.ts b/src/server/management/link-enrollment.ts index 0d58e8c3c2c..c157e964fa9 100644 --- a/src/server/management/link-enrollment.ts +++ b/src/server/management/link-enrollment.ts @@ -1,4 +1,3 @@ -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"; @@ -15,7 +14,7 @@ import { withConfigMutationLockSync } from "../../config/mutation-lock"; import type { ManagementContext } from "./context"; import { readManagementJsonBodyOr } from "./body"; import { fail, exactBody, port, isRecord, runnerFor, readStoreFor, writeStoreFor, knownHostsFile, putKnownHost, issueKey, compensateNewLink, type LinkRouteState } from "./link-route-state"; -import { CHILD_CONFIG_TOO_LARGE_HINT, CHILD_CONFIG_TOO_LARGE_MESSAGE, CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES, childConfigFailureReason } from "./link-child-preflight"; +import { CHILD_CONFIG_TOO_LARGE_HINT, CHILD_CONFIG_TOO_LARGE_MESSAGE, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES } from "./link-child-preflight"; const PROBE_TTL_MS = 5 * 60_000; const MIN_REMOTE_OCX = [2, 66, 0] as const; @@ -290,10 +289,7 @@ export async function apply(ctx: ManagementContext, state: LinkRouteState): Prom const configured = await run(ocx(["config", "show", "--json"]), budget, CHILD_PREFLIGHT_MAX_OUTPUT_BYTES); let childConfig: unknown = null; try { if (configured.code === 0) childConfig = JSON.parse(configured.stdout); } catch { /* fail closed */ } - if (!isRecord(childConfig) || !port(childConfig.port)) { - const hint = await childConfigFailureReason(configured, () => run(ocx(["config", "validate", "--json"]), budget, CHILD_CONFIG_VALIDATE_MAX_OUTPUT_BYTES)); - return await failed(fail("child_preflight_failed", "The Child effective configuration could not be read.", 502, hint)); - } + if (!isRecord(childConfig) || !port(childConfig.port)) return await failed(fail("child_preflight_failed", "The Child effective configuration could not be read.", 502)); if ((childConfig.runtimeRole ?? "standalone") !== "standalone") return await failed(fail("child_restart_unsupported", "The Child must run standalone OpenCodex; an existing Hub or Child cannot be converted by Add Child.", 409)); const childPort = childConfig.port; if (remotePlatform === "posix" && (await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return await failed(fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409)); @@ -315,9 +311,6 @@ 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 42dc39d8490..d58522417c4 100644 --- a/src/server/management/link-route-state.ts +++ b/src/server/management/link-route-state.ts @@ -6,7 +6,6 @@ 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"; @@ -182,7 +181,6 @@ 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 fc90a0961d6..116a498c725 100644 --- a/src/server/management/link-routes.ts +++ b/src/server/management/link-routes.ts @@ -1,5 +1,3 @@ -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"; @@ -77,12 +75,9 @@ async function candidates(ctx: ManagementContext): Promise { } async function issue(ctx: ManagementContext): Promise { - const raw = await readManagementJsonBodyOr(ctx.req, null); - const body = exactBody(raw, isRecord(raw) && Object.hasOwn(raw, "selectedClients") ? ["alias", "tunnelPort", "selectedClients"] : ["alias", "tunnelPort"]); + const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["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); } @@ -110,7 +105,6 @@ 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"; @@ -261,7 +255,6 @@ 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 6dd26a34bde..be01108fbf8 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -6,12 +6,6 @@ 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. * @@ -68,8 +62,6 @@ 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" @@ -123,12 +115,7 @@ export interface NativeRefusalEnvelope { /** Present on every post-commit refusal; absent only for pre-commit refusals. */ desiredEnabled?: boolean; residualPaths?: string[]; - diagnostic?: InterceptSecretDiagnostic | NativeConfigDiagnostic; -} - -interface NativeConfigDiagnostic { - stage: "config"; - cause: "SQLITE_NOTADB" | "SQLITE_CORRUPT" | "SQLITE_CANTOPEN" | "SQLITE_IOERR" | "EACCES" | "EPERM" | "ENOENT" | "EIO" | "unknown"; + diagnostic?: InterceptSecretDiagnostic; } export type NativePostCommitRefusalEnvelope = Omit & { @@ -140,7 +127,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", @@ -287,66 +274,12 @@ 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 isConfigMutationContention(error: unknown): boolean { +function isLockContention(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"; @@ -401,7 +334,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: NativeDecisionContext): Promise { +async function handleCodexToggle(ctx: ManagementContext): Promise { const { req } = ctx; if (codexToggleFlight) { return refusal(409, "codex", "config_busy", @@ -421,27 +354,21 @@ async function handleCodexToggle(ctx: NativeDecisionContext): Promise const enabled = body.enabled; const { setCodexIntegrationEnabled } = await import("../../codex/desired-state"); - let decisionResult!: ReturnType; + const persisted = setCodexIntegrationEnabled(enabled); /* * `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. */ - 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; + if (!persisted.ok && persisted.reason !== "missing") { + return refusal( + persisted.retryable ? 409 : 500, + "codex", + persisted.retryable ? "config_busy" : "write_failed", + persisted.message, + ); + } const durable = persisted.ok; if (enabled) { @@ -508,7 +435,7 @@ async function handleCodexToggle(ctx: NativeDecisionContext): Promise } } -async function handleGrokToggle(ctx: NativeDecisionContext): Promise { +async function handleGrokToggle(ctx: ManagementContext): Promise { const { req, config, deps } = ctx; /* * The guard stands BEFORE the first await, or two concurrent PUTs could both @@ -555,7 +482,7 @@ async function handleGrokToggle(ctx: NativeDecisionContext): Promise { * on, where the other order leaves artifacts the next start undoes. */ const { setGrokIntegrationEnabled } = await import("../../codex/desired-state"); - let decisionResult!: ReturnType; + const persisted = setGrokIntegrationEnabled(enabled); /* * `missing` does NOT block the toggle here. * @@ -567,20 +494,14 @@ async function handleGrokToggle(ctx: NativeDecisionContext): Promise { * on the envelope says. `conflict` and `invalid` are different: another * writer won, or the file is malformed and must not be overwritten. */ - 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; + if (!persisted.ok && persisted.reason !== "missing") { + return refusal( + persisted.retryable ? 409 : 500, + "grok", + persisted.retryable ? "config_busy" : "write_failed", + persisted.message, + ); + } const durable = persisted.ok; const desiredEnabled = durable ? loadConfig().clientIntegrations?.grok !== false : enabled; @@ -780,7 +701,7 @@ async function persistDesktopModeMarker(config: ManagementContext["config"], des let claudeDesktopToggleFlight: Promise | null = null; -async function handleClaudeDesktopToggle(ctx: NativeDecisionContext): Promise { +async function handleClaudeDesktopToggle(ctx: ManagementContext): 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."); @@ -798,15 +719,17 @@ async function handleClaudeDesktopToggle(ctx: NativeDecisionContext): Promise { + const persisted = await settleConfigRoot(configRoot(), () => { const result = setIntegrationEnabled("claude-desktop", enabled); if (result.ok) { committedDesiredEnabled = enabled; mirrorDesiredEnabledOntoSnapshot(ctx.config, "claude-desktop", enabled); } - return result.ok ? true : refusal(result.retryable ? 409 : 500, "claude-desktop", result.retryable ? "config_busy" : "write_failed", result.message); + return result; }); - if (decisionRefusal) return decisionRefusal; + if (!persisted.ok) { + return refusal(persisted.retryable ? 409 : 500, "claude-desktop", persisted.retryable ? "config_busy" : "write_failed", persisted.message); + } const desiredEnabled = loadConfig().clientIntegrations?.["claude-desktop"] !== false; committedDesiredEnabled = desiredEnabled; const current = loadConfig(); @@ -999,7 +922,7 @@ function persistedIntentConfig(snapshot: ManagementContext["config"]): Managemen } } -async function handleNativeIntegrationRoutesUnlocked(ctx: NativeDecisionContext): Promise { +export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; if (url.pathname === "/api/native-integrations" && req.method === "GET") { @@ -1026,51 +949,53 @@ async function handleNativeIntegrationRoutesUnlocked(ctx: NativeDecisionContext) } const enabled = body.enabled; - 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 (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. 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 @@ -1109,34 +1034,3 @@ async function handleNativeIntegrationRoutesUnlocked(ctx: NativeDecisionContext) 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 2eca1eea959..53e025eed8d 100644 --- a/src/server/management/protocol-routes.ts +++ b/src/server/management/protocol-routes.ts @@ -22,7 +22,6 @@ 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, @@ -116,12 +115,6 @@ 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/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index 4b205098427..6b1a9b0d550 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -60,13 +60,7 @@ export const PROVIDER_COMPAT_CARRY_FIELDS = [ ...RETRY_POLICY_FIELDS, ] as const satisfies readonly (keyof OcxProviderConfig)[]; -export const PROVIDER_DISPLAY_CARRY_FIELDS = [ - "hideRawReasoning", - // The Anthropic thinking display policy (#8363) is operator configuration the dashboard form - // cannot send either, so an unrelated save must not silently return the model to the default. - "thinkingDisplay", - "modelThinkingDisplay", -] as const satisfies readonly (keyof OcxProviderConfig)[]; +export const PROVIDER_DISPLAY_CARRY_FIELDS = ["hideRawReasoning"] as const satisfies readonly (keyof OcxProviderConfig)[]; export type ProviderCompatCarryField = typeof PROVIDER_COMPAT_CARRY_FIELDS[number]; type ProviderDisplayCarryField = typeof PROVIDER_DISPLAY_CARRY_FIELDS[number]; @@ -137,8 +131,7 @@ export function carryProviderCompatFields( for (const field of PROVIDER_DISPLAY_CARRY_FIELDS) { if (sample.submitted.has(field)) continue; const stored = live[field]; - // Copy maps rather than alias them, like the compatibility settings below. - if (stored !== undefined) target[field] = stored !== null && typeof stored === "object" ? structuredClone(stored) : stored; + if (stored !== undefined) target[field] = stored; } if (!providerOverwriteKeepsDestination(candidate, live, sample)) return; for (const field of PROVIDER_COMPAT_CARRY_FIELDS) { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 26bba95ceac..63e4089e46f 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -120,7 +120,6 @@ import { } from "../auth-cors"; import { providerCatalogCapabilityConfigError } from "./provider-capability-config"; import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; -import { applyThinkingDisplayPatchFields } from "./provider-thinking-display-patch"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -689,11 +688,6 @@ function applyProviderPatchFields( } touched = true; } - if (Object.hasOwn(rawBody, "thinkingDisplay") || Object.hasOwn(rawBody, "modelThinkingDisplay")) { - const displayError = applyThinkingDisplayPatchFields(next, rawBody); - if (displayError) return displayError; - touched = true; - } if (Object.hasOwn(rawBody, "noStructuredOutputModels")) { const value = rawBody.noStructuredOutputModels; if (value === null) { diff --git a/src/server/management/provider-thinking-display-patch.ts b/src/server/management/provider-thinking-display-patch.ts deleted file mode 100644 index 313278405d6..00000000000 --- a/src/server/management/provider-thinking-display-patch.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { OcxProviderConfig } from "../../types"; -import { thinkingDisplayConfigError } from "../../config/provider-validation"; -import { isPlainRecord } from "./shared"; - -type ThinkingDisplay = NonNullable; - -/** - * Apply the `thinkingDisplay` / `modelThinkingDisplay` PATCH mask (#8363). - * - * Split out of provider-routes.ts, which sits under the 2,000-line file-size threshold; the - * behaviour stays identical to the sibling per-model map patches there. A model entry of - * `null` deletes one key, `null` on the whole map clears it, and provider-level `null` or "" - * clears the scalar. - */ -export function applyThinkingDisplayPatchFields( - next: OcxProviderConfig, - rawBody: Record, -): { error: string } | null { - if (Object.hasOwn(rawBody, "thinkingDisplay")) { - const value = rawBody.thinkingDisplay; - if (value === null || value === "") { - delete next.thinkingDisplay; - } else { - const error = thinkingDisplayConfigError(value); - if (error) return { error }; - next.thinkingDisplay = value as ThinkingDisplay; - } - } - if (Object.hasOwn(rawBody, "modelThinkingDisplay")) { - const value = rawBody.modelThinkingDisplay; - if (value === null) { - delete next.modelThinkingDisplay; - } else { - if (!isPlainRecord(value)) return { error: "modelThinkingDisplay must be a plain object or null" }; - const merged = Object.assign( - Object.create(null) as Record, - next.modelThinkingDisplay ?? {}, - ); - for (const [model, policy] of Object.entries(value as Record)) { - if (!model.trim()) return { error: "modelThinkingDisplay keys must be nonblank model ids" }; - if (policy === null) delete merged[model]; - else { - const entryError = thinkingDisplayConfigError(policy, `modelThinkingDisplay.${JSON.stringify(model)}`); - if (entryError) return { error: entryError }; - merged[model] = policy as ThinkingDisplay; - } - } - if (Object.keys(merged).length > 0) next.modelThinkingDisplay = merged; - else delete next.modelThinkingDisplay; - } - } - return null; -} - diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 318b80d6eb5..6655a587d94 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -383,9 +383,6 @@ 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 deleted file mode 100644 index 654617b6107..00000000000 --- a/src/server/management/route-to-routes.ts +++ /dev/null @@ -1,235 +0,0 @@ -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/server/relay.ts b/src/server/relay.ts index bdd5c26e39e..0e29bd85c85 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -227,19 +227,9 @@ export function refusalFailedTailFrame( ); } -/** - * A bare upstream error event: the `type: "error"` SSE event, or an untyped envelope that - * carries only an `error` object (`{"error":{...}}`, #8216). Some OpenAI-compatible gateways - * end a stream with the untyped form; without this it was dropped and EOF reported - * `adapter_eof`. A typed event of any other kind is never bare, even with an `error` field. - */ -function isBareUpstreamErrorEvent(root: JsonRecord): boolean { - return root.type === "error" || (root.type === undefined && asJsonRecord(root.error) !== null); -} - function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined { - const root = asJsonRecord(payload); - if (!root || !isBareUpstreamErrorEvent(root)) return undefined; + if (!payload || typeof payload !== "object" || Array.isArray(payload) + || (payload as { type?: unknown }).type !== "error") return undefined; const message = upstreamErrorMessageFromPayload(payload); return message ? redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS) : undefined; } @@ -266,7 +256,7 @@ function boundedBareUpstreamError(payload: unknown): { errorCode: string | undefined; } | undefined { const root = asJsonRecord(payload); - if (!root || !isBareUpstreamErrorEvent(root)) return undefined; + if (!root || root.type !== "error") return undefined; const message = boundedBareUpstreamErrorMessage(payload); const response = asJsonRecord(root.response); // Precedence is {@link upstreamErrorMessageFromPayload}'s, so the envelope @@ -282,7 +272,7 @@ function boundedBareUpstreamError(payload: unknown): { ]; const code = candidates.map(candidate => stringField(candidate, "code")) .find(candidate => candidate !== undefined)?.slice(0, 128); - // Root `type` is the SSE event discriminator (`"error"` or absent), not an error class. Only nested + // Root `type` is the SSE event discriminator (`"error"`), not an error class. Only nested // error records can authoritatively name classes such as rate_limit_error or server_error. const errorType = candidates.slice(0, -1).map(candidate => stringField(candidate, "type")) .find(candidate => candidate !== undefined)?.slice(0, 128); @@ -290,15 +280,13 @@ function boundedBareUpstreamError(payload: unknown): { ? (isTerminalRefusalCode(code) ? code : undefined) : message === undefined ? undefined : safetyRefusalCodeFromMessage(message); if (message !== undefined) return { message, refusalCode, errorType, errorCode: code }; - if (refusalCode !== undefined) { - return { message: terminalRefusalFallbackMessage(refusalCode), refusalCode, errorType, errorCode: code }; - } - // A structured code or class with no message is still the upstream's verdict, so it must end in - // `response.failed` rather than `adapter_eof`. Its own bounded identifiers are the only - // diagnostic it sent; the tail redacts and masks them like any other message. - const identity = code ?? errorType; - if (identity === undefined) return undefined; - return { message: `Upstream error without a message (${identity})`, refusalCode, errorType, errorCode: code }; + if (refusalCode === undefined) return undefined; + return { + message: terminalRefusalFallbackMessage(refusalCode), + refusalCode, + errorType, + errorCode: code, + }; } export type SseTerminalOutputBoundary = { @@ -682,9 +670,8 @@ function parseSsePayload(payload: string): unknown | undefined { } function isPolicyRewriteType(parsed: unknown): boolean { - const root = asJsonRecord(parsed); - if (!root) return false; - return root.type === "response.failed" || root.type === "response.incomplete" || isBareUpstreamErrorEvent(root); + const type = asJsonRecord(parsed)?.type; + return type === "response.failed" || type === "response.incomplete" || type === "error"; } /** @@ -815,9 +802,7 @@ function createFirstOutputReporter(onFirstOutput?: () => void): { } export function terminalStatusFromParsed(parsed: unknown): ResponsesTerminalStatus | null { - const root = asJsonRecord(parsed); - // An untyped bare error envelope (#8216) is classified like the `error` event it stands for. - const type = root && isBareUpstreamErrorEvent(root) ? "error" : root?.type; + const type = asJsonRecord(parsed)?.type; switch (type) { case "response.completed": return "completed"; @@ -1734,10 +1719,8 @@ export function consumeForInspection( logCtx, onCompletedResponse, onParsedPayload: payload => { - // Same extraction as the client boundary, so a refusal or code sent without a message is - // still a failure here and cannot be recorded as an incomplete (non-failed) turn. - const bare = boundedBareUpstreamError(payload); - if (bare !== undefined) bareUpstreamError = bare.message; + const message = boundedBareUpstreamErrorMessage(payload); + if (message !== undefined) bareUpstreamError = message; options?.onParsedPayload?.(payload); }, onFirstOutput, diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 1df234bd3a2..a306ee4aadd 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -210,23 +210,6 @@ function payloadType(payload: string): string | null { } } -/** - * An untyped `{"error":{...}}` envelope (#8216): not a Responses event, but not a malformed frame - * either. The SSE relay boundary keeps the error and reports it as `response.failed` at EOF unless a - * real terminal arrives first, so the pump skips the frame and lets that terminal reach the client. - * Mirrors the untyped half of `isBareUpstreamErrorEvent` in `relay.ts`. - */ -function isUntypedErrorEnvelope(payload: string): boolean { - try { - const json = JSON.parse(payload) as unknown; - if (!json || typeof json !== "object" || Array.isArray(json)) return false; - const root = json as { type?: unknown; error?: unknown }; - return root.type === undefined && !!root.error && typeof root.error === "object" && !Array.isArray(root.error); - } catch { - return false; - } -} - function terminalStatusFromType(type: string): ResponsesTerminalStatus | null { switch (type) { case "response.completed": @@ -307,7 +290,6 @@ export async function pumpResponsesSseToWebSocket( /* payload observation must not affect WebSocket delivery */ } const type = payloadType(payload); - if (!type && isUntypedErrorEnvelope(payload)) return false; if (!type) { reportTerminal("incomplete"); sendProtocolError(ws, 502, "Invalid JSON payload in upstream SSE frame"); diff --git a/src/service/disconnected-client-restart.ts b/src/service/disconnected-client-restart.ts deleted file mode 100644 index 1d80d57a840..00000000000 --- a/src/service/disconnected-client-restart.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 and foreign/unproven processes never enter this cleanup path. - */ -export async function prepareDisconnectedClientServiceRestart(deps: DisconnectedClientRestartDeps): Promise { - const disconnected = deps.disconnected ?? (() => { - assertNoClientDisconnectPending(); - return readClientConnectionState().kind === "disconnected" && !pendingClientConnectMayOwnToken(); - }); - if (!disconnected()) 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; - }); - if (verify(pid) !== pid || !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/repair.ts b/src/service/repair.ts index e553937998a..615d1bfc890 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -13,14 +13,13 @@ 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, killWindowsServiceWrapperProcesses } from "./windows-ops"; +import { writeWindowsSchedulerAssets, reregisterWindowsSchedulerTask, windowsSchedulerRegistrationMatchesSnapshot, restoreWindowsSchedulerTaskIfAbsent, startWindows, stopWindows, statusWindowsXml } 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 @@ -37,10 +36,6 @@ 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>; startScheduler?: () => void; writeSchedulerState?: (runtime: DurableBunRuntime) => void; writeNativeState?: (runtime: DurableBunRuntime) => void; @@ -75,7 +70,6 @@ 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; } @@ -199,46 +193,8 @@ 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); @@ -300,29 +256,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise : "Task Scheduler registration is not a recognized legacy OpenCodex definition; it was preserved for manual review.", ); } - 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(); }); - 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 */ } - } + 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 @@ -445,7 +379,6 @@ 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.ts b/src/types.ts index 1c7d579f3a7..8da67a3db8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,14 +24,12 @@ export { } from "./types/tools"; export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; -export type { ThinkingDisplayPolicy } from "./types/wire"; export type { AnthropicInstanceId } from "./providers/anthropic-instance-id"; export { ANTHROPIC_INSTANCE_IDS, ANTHROPIC_PRIMARY_INSTANCE, ANTHROPIC_POOL2_INSTANCE, isAnthropicInstanceId } from "./providers/anthropic-instance-id"; export type { AnthropicAccountPoolConfig, AnthropicModelRoute } from "./types/anthropic-account-pool"; export { UPSTREAM_HTTP_VERSION_VALUES, REASONING_SUMMARY_DELIVERY_VALUES, - THINKING_DISPLAY_VALUES, OPENAI_PROVIDER_TIER_VERSION, MODEL_ADAPTER_OVERRIDE_ALLOWED, captureWireAdapterHardPins, diff --git a/src/types/config.ts b/src/types/config.ts index d9cb3a88516..1bc480ef5c4 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" | "claude-desktop" | "grok" | import("../clients/client-ids").ExportClientId; +export type OcxConnectedClientId = "codex" | "claude"; /** * Redaction policy for management and CLI projections (#3859). diff --git a/src/types/provider.ts b/src/types/provider.ts index 9bd9c6708fe..22be33e0882 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1,4 +1,4 @@ -import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode, ThinkingDisplayPolicy } from "./wire"; +import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./wire"; import type { AccountAutoPausePolicy, AccountPauseDetails } from "./account-auto-pause"; import type { AnthropicAccountPoolConfig } from "./anthropic-account-pool"; @@ -841,14 +841,6 @@ export interface OcxProviderConfig { * Presence also advertises reasoning-summary support for that routed model. */ modelReasoningSummaryDelivery?: Record; - /** - * Anthropic adaptive `thinking.display` policy for this provider (#8363). `auto` and omission - * keep the built-in selection: native Opus 5.5 progress updates, summarized thinking elsewhere. - * `updates` is only sendable to native Opus 5.5 and degrades to `auto` for other models. - */ - thinkingDisplay?: ThinkingDisplayPolicy; - /** Per-model override of `thinkingDisplay`, keyed by the exact upstream model id. */ - modelThinkingDisplay?: Record; /** * Exact-model hosted tools that win collisions with Codex client tool declarations. * Use for non-forward Responses gateways that reserve a hosted tool namespace server-side. diff --git a/src/types/wire.ts b/src/types/wire.ts index 8c3767889ea..2db38cbaa9d 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -25,16 +25,6 @@ export const REASONING_SUMMARY_DELIVERY_VALUES = [ export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number]; -/** - * Anthropic adaptive `thinking.display` policy (#8363). `auto` is the built-in behavior: - * native Opus 5.5 progress updates, summarized thinking elsewhere unless the caller hides it. - * `updates` is only sendable to native Opus 5.5 and degrades to `auto` for every other model. - * `omitted` sends adaptive thinking with no display field, matching hidden thinking. - */ -export const THINKING_DISPLAY_VALUES = ["auto", "summarized", "updates", "omitted"] as const; - -export type ThinkingDisplayPolicy = (typeof THINKING_DISPLAY_VALUES)[number]; - /** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */ export type CodexAccountMode = "direct" | "pool"; diff --git a/src/update/index.ts b/src/update/index.ts index 68b5b101491..2b380ea3ed0 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -364,13 +364,7 @@ export function updateCommand(installer: Installer, tag: Channel, resolvedVersio return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] }; } const bin = "npm"; - // npm 12 blocks dependency lifecycle scripts by default, and bun's postinstall is what - // copies the selected @oven/bun-* executable into bun/bin. Without this narrow approval - // the install succeeds but leaves bun's un-downloaded placeholder, so the next `ocx` - // cannot start (#8361). npm 6 through 11 run bun's postinstall anyway and install with the - // flag on global installs, so it is unconditional rather than version-gated; some npm 11 - // releases print an "Unknown cli config" warning for it and still install. - const args = ["install", "-g", "--allow-scripts=bun", `${PKG}@${target}`]; + const args = ["install", "-g", `${PKG}@${target}`]; return { bin, args }; } diff --git a/structure/catalog.md b/structure/catalog.md index c0310c0aa7b..f9193633c27 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -96,7 +96,7 @@ Cache invalidation reports an unchanged derived cache separately from a failed r Custom rows replace discovered rows that encode to the same Codex-facing slug. For a provider whose captured discovery declares an order (`preferFirst`), `src/codex/catalog/routed-gather.ts` puts the replacement in the discovered row's slot; every other provider keeps discovered rows first and custom rows after them, as before. Coverage: `tests/codex-integration/catalog-custom-ordering.test.ts`. -On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. `src/codex/catalog/bundled.ts` treats unsupported bundled-model commands, spawn failures and structurally invalid model rows (null, arrays or other non-objects) as unavailable in both synchronous and asynchronous loading, preserving the existing catalog fallback. On macOS, when discovery ends on the bare `codex` sentinel because it found no CLI, the same loader also probes the known ChatGPT.app locations from `src/codex/mac-app-bundle.ts` before reporting unavailable (#8360), so `ocx connect status` readiness can report the bundled CLI's ladder when the bundle identity gate could not classify it; the candidate list grants no trust, and an unverifiable bundle still yields null because every execution passes the exec-boundary checks. This split is +On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. `src/codex/catalog/bundled.ts` treats unsupported bundled-model commands, spawn failures and structurally invalid model rows (null, arrays or other non-objects) as unavailable in both synchronous and asynchronous loading, preserving the existing catalog fallback. This split is required because empty or partial provider discovery must preserve routed entries and genuine user-native rows from the file that will be overwritten; a bundled catalog never contains those rows. Retained sync and evidence-bound convergence share an explicit observed-state merge policy and resolve unbanded priorities through the [native source-priority contract](subagents.md#native-source-priority), including live/published/cache parity. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex diff --git a/structure/cli-management.md b/structure/cli-management.md index decf2222511..44e9455289a 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -6,11 +6,7 @@ ## CLI readiness diagnostics -`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/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/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 fe992ce4a3d..0d78675636a 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -7,7 +7,8 @@ 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 @@ -20,18 +21,17 @@ 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/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/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. | | `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 0c7be676ee8..a9ac4855fbd 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -43,7 +43,7 @@ user off a Windows home they were running against, and a stat failure other than local home rather than switching to a different one. Codex runtime discovery (src/codex/runtime.ts) also reads this home on Linux: after an explicit runtime, PATH, and the ordinary install locations, it enumerates the direct children of /bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly set path that is unreadable or not a directory is an error, not a fallback: silently using a different home than the operator named would write provider state where nobody is looking for it. A proven missing home (including explicit `CODEX_HOME`) resolves lexically; native restore, remove and journal reconcile succeed without creating a directory or acquiring the config write lock. Dangling links do not prove absence, and existing homes retain ownership checks. Lock-path resolution never creates parents; unresolved parents yield a typed, handled refusal. Scalar and multi-agent feature edits refuse missing homes; prompt commits and native feature commands retain their directory creation before locking. The management native feature toggle resolves and retains the spawn invocation before preparing a home; unavailable executables return the toggle failure without creating it. Failed native toggles and prompt commits keep a home they created (`src/codex/prepared-home.ts`): a pathname-based removal cannot prove it still names that directory, and an empty home is what Codex itself creates. A fresh install can have that directory but no `config.toml` yet; applying the integration then creates an empty `config.toml` there (never overwriting an existing file) and continues, while a missing home directory is refused with instructions to start Codex once or set `CODEX_HOME` (issue 5422). -On macOS, `src/codex/runtime.ts` tries the system then user ChatGPT.app bundle after environment, persisted selection, shim, PATH, established installs and a runnable bare fallback, preferring `Contents/Resources/codex-cli/bin/codex` over `Contents/Resources/codex`. Bundle membership compares the normalized and canonical (symlink-resolved) forms of the path and bundle with a segment boundary, including backing executables; candidate ordering keys on the generated app commands, so an established install path that links into the app keeps its precedence. Discovery and probe prechecks require exactly one top-level `CFBundleIdentifier` equal to `com.openai.codex` in a regular XML `Contents/Info.plist`, read in-process with a 64 KiB limit; the whole document must be valid plist XML, so binary, malformed (anywhere, not only in the identity pair), oversized or unavailable metadata fails closed. App candidates never supersede a runnable persisted selection, including legacy/discovered records and the fast path, but full discovery can report them in `newerAvailable`; invalid selections retain `replacedConfigured` recovery. When a verified app candidate exists, deferred discovery skips the bare fallback as a candidate and retains it only as the no-candidate sentinel. When discovery ends on that sentinel because no Codex CLI was found, the bundled-catalog probe in `src/codex/catalog/bundled.ts` also tries the same app locations before reporting unavailable, so `ocx connect status` readiness reports the bundled CLI's ladder even when the identity gate could not classify the bundle (#8360); the probe grants no trust and every execution still passes the exec-boundary signature and ownership checks. +On macOS, `src/codex/runtime.ts` tries the system then user ChatGPT.app bundle after environment, persisted selection, shim, PATH, established installs and a runnable bare fallback, preferring `Contents/Resources/codex-cli/bin/codex` over `Contents/Resources/codex`. Bundle membership compares the normalized and canonical (symlink-resolved) forms of the path and bundle with a segment boundary, including backing executables; candidate ordering keys on the generated app commands, so an established install path that links into the app keeps its precedence. Discovery and probe prechecks require exactly one top-level `CFBundleIdentifier` equal to `com.openai.codex` in a regular XML `Contents/Info.plist`, read in-process with a 64 KiB limit; the whole document must be valid plist XML, so binary, malformed (anywhere, not only in the identity pair), oversized or unavailable metadata fails closed. App candidates never supersede a runnable persisted selection, including legacy/discovered records and the fast path, but full discovery can report them in `newerAvailable`; invalid selections retain `replacedConfigured` recovery. When a verified app candidate exists, deferred discovery skips the bare fallback as a candidate and retains it only as the no-candidate sentinel. `src/codex/home.ts` imports path expansion directly from `src/config/paths.ts`, so a fresh WSL process can resolve its Codex home without re-entering the config facade before the resolver initializes. Provider-ID approvals are home-bound; see the [custom routing contract](codex-provider-routing.md). The managed files are: @@ -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. 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 +recorded injected hashes. 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 9b2f36e22e4..93bd6bb8962 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. 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. +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. 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 b10b1e4cd28..d4d32e281d4 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,5 @@ # GUI And Management API -`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. +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. -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. +`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/desktop-supervision.md b/structure/ops/desktop-supervision.md index fb27b68cc17..1f4c2d9f216 100644 --- a/structure/ops/desktop-supervision.md +++ b/structure/ops/desktop-supervision.md @@ -127,15 +127,6 @@ formatting is the only allowlisted work between that authorizer and process crea `tests/update/update-home-launcher.test.ts` and `tests/update/update-home-recovery.test.ts` exercise refusal when preparation changes the home observation, plus stable-home starts. -The npm install argv that `updateCommand()` (and the Node launcher's own npm self-update) builds -always carries `--allow-scripts=bun`: npm 12 blocks dependency lifecycle scripts by default, and -bun's postinstall is what unpacks the bundled runtime, so a bare global install would leave the -next `ocx` unable to start (#8361). npm 8 through 11 accept the flag on global installs, so it is -not version-gated. The npm package replacement stages beside the live tree and stops the proxy -before the swap, which is also what releases `node_modules/bun/bin` on Windows; a manual -`npm install -g` under a live proxy still fails with `EBUSY`, so doctor prints the stop, install -and restart sequence for npm-owned installs instead of attempting it. - The bypass ledger classifies these guards as **early warning**, not enforcement: | Guard | Surface / tier | Known limit and residual risk | Pass-through assertion | diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 01bf73c99bd..2bb82bb72e1 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. 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, verifies bounded proxy silence, and rechecks authority before replacement. Pending disconnects, changed ownership and surviving unverified PIDs 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 +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 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 @@ -100,7 +100,7 @@ The frozen selection feeds scheduler rendering, the WinSW entry and the install- The fresh scheduler path captures root absence and freezes the runtime at entry, stages and registers its definition, removes staging and claims config ownership, then probes with `rootWasAbsent: false` immediately before `prepare()`; a refused probe rolls back the new registration before service-manager cleanup or asset/state publication. Healthy or disabled shims do not probe; refused automatic restore defers with guidance and startup continues. Non-Windows admission performs no spawn or filesystem work, selection stays pre-dotenv and `cliEntry` stays I/O-free. That cleanup (`prepareServiceInstall`) ends every recorded, conflicting and requested manager, then — Windows only — issues the same best-effort launcher/wrapper termination `ocx stop` performs, scoped to this install's canonical script and launcher tokens (an `schtasks /end` survivor can respawn the proxy mid-install; this layer guarantees neither that the wrapper actually exited nor that the new instance comes up), then stops the tracked standalone listener; a refused or unverifiable manager stop aborts before that cleanup runs. An absent root reaching admission is created with an exclusive non-recursive mode-0700 mkdir under an existing parent and left empty for later ownership claiming; the preflight never deletes the config root (refusal, concurrent creation, contents and replacements are all preserved). Existing roots must be real directories, not files or symlinks/junctions, and probe cleanup is non-recursive. -A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g --allow-scripts=bun @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added. The flag is part of the contract, not advice: npm 12 blocks dependency lifecycle scripts by default and bun's postinstall is what unpacks the binary, while npm 8 through 11 accept the flag on a global install (#8361). +A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added. ## Windows npm tray update badge diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 85fc49eaf63..2f2576e4cca 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -91,7 +91,7 @@ Kimi Coding's Chat, API-key, and optional Responses presets consume the same mod The Responses preset shares the `kimi` OAuth account and Coding endpoint, keeps Chat as the featured default, and enables adjacent tool-result repair on its Responses wire. Its metadata alias is generated from the registry; sharing authentication does not implicitly share a usage-price namespace. The separate `moonshot` API preset defaults to `kimi-k3.1-pro`; `src/providers/model-rename-migration.ts` moves discontinued K2.5 selections on official destinations to it, drops stale no-reasoning classification, and refreshes exact old 256K/empty reasoning seeds to 1M and `low`/`high`/`max` with the API `none` omission map. Explicit successor metadata and Custom destinations keep precedence. Roster repair accepts the literal pre-K3.1 seed and its post-rename form, with metadata additions measured against the original seed. K2.6 and K2.7 Code/highspeed stay seeded for existing API users. -Kimi quota in `src/providers/quota/vendor-probes-key.ts` also reads monthly `usages` at the root, under `data`, or under `quota` (including `data.quota`): `limit_month_total` / `monthTotal` precedes the `limit_month_code` / `monthCode` fallback. Containers are tried in root, `data`, `quota`, then `data.quota` order until a monthly row parses; empty or unusable earlier containers do not mask later data, and monthly lookup retains the original envelope even when legacy windows unwrap `data`. Rows prefer limit/used or remaining arithmetic, then fractional `used_ratio` / `usedRatio` in `[0, 1]`, then direct percentages; out-of-range ratios fall through and reset times remain optional. Missing monthly data stays absent, and legacy 5-hour, weekly and total subscription-credit windows stay separate. The probe calls `/usages` on the configured inference origin only when it is exactly `https://api.kimi.com/coding/v1`, or `https://api.kimi.ai/coding/v1` for key auth (#8332), so the bearer never reaches a host it was not already sent to; account-login tokens stay on `api.kimi.com`. The parser is shared by account-login and API-key probes; credential and destination guards are unchanged. +Kimi quota in `src/providers/quota/vendor-probes-key.ts` also reads monthly `usages` at the root, under `data`, or under `quota` (including `data.quota`): `limit_month_total` / `monthTotal` precedes the `limit_month_code` / `monthCode` fallback. Containers are tried in root, `data`, `quota`, then `data.quota` order until a monthly row parses; empty or unusable earlier containers do not mask later data, and monthly lookup retains the original envelope even when legacy windows unwrap `data`. Rows prefer limit/used or remaining arithmetic, then fractional `used_ratio` / `usedRatio` in `[0, 1]`, then direct percentages; out-of-range ratios fall through and reset times remain optional. Missing monthly data stays absent, and legacy 5-hour, weekly and total subscription-credit windows stay separate. The parser is shared by account-login and API-key probes; credential and destination guards are unchanged. OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its 64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow diff --git a/structure/providers/anthropic-progress.md b/structure/providers/anthropic-progress.md index 21719d60fed..5ca176a4184 100644 --- a/structure/providers/anthropic-progress.md +++ b/structure/providers/anthropic-progress.md @@ -8,12 +8,6 @@ This display choice also applies when effort is absent or `none`; it does not ad Omitted summary, `auto` (the Codex default) and `none` keep the updates default. Local `hideThinkingSummary` controls summary presentation independently; it does not select the upstream display. The selected display controls both the generated beta header and response classification on every request, including adapter reuse. -An operator override sits above that default without changing it when unset (#8363): the provider -`thinkingDisplay` value, or the exact-model `modelThinkingDisplay` entry that wins over it, can force -`summarized`, keep `updates`, or select `omitted` (adaptive thinking with no display field). `updates` -is treated as `auto` on any model this adapter never sent it to, an explicit request summary still -forces `summarized` on every adaptive model, and `hideThinkingSummary` keeps its meaning only under -the `auto` policy. Every branch derives `progressUpdates` from the final body's `display`. `src/types/request.ts` carries the complete signed update on its text-delta event. `src/adapters/anthropic-progress.ts` uses this documented display contract to classify nonempty thinking text as commentary, buffering one block under the translator budget until its final signature. diff --git a/structure/providers/google.md b/structure/providers/google.md index 6b36e11c2f4..134d06b2aec 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -155,19 +155,6 @@ A first `functionCall` that still has no real signature after replay receives th `skip_thought_signature_validator` sentinel through `applyAntigravityThoughtSignatureFallback`, on Antigravity, Vertex and AI Studio alike. AI Studio gained it for combo hops that hand Gemini a call another model issued (#7298); non-Gemini model ids never receive it. -An upstream rejection clears the session's replay entry whether it arrives inside a 200 body or -stream or as a real HTTP 400: the Antigravity/Vertex `fetchResponse` wrapper in -`src/adapters/google.ts` reads a clone of the final 400 body and runs the same clear-on-invalid -check, because the dispatcher reports a non-ok response without calling the adapter parsers. The -400 status itself counts as an invalid-argument rejection for the session clear, since the -normalized error text keeps only Google's message and not its status enum. The -next turn therefore signs its first call with the sentinel instead of replaying a session-cached -rejected value until TTL expiry (#8054). Durable-store eviction keeps its narrower gate: only a -rejection whose text names a signature evicts the injected call ids, so a generic tool-schema -`INVALID_ARGUMENT` cannot destroy valid durable signatures, and a durable entry behind a generic -rejection stays until its TTL. The rejected request itself is not resent; `payload-rejected` stays -resend-is-futile. `tests/adapters/google/google-signature-history-roundtrip.test.ts` drives the -400 through `fetchResponse` on both modes. `src/adapters/openai-responses/passthrough.ts` removes item-level `extra_content` through `stripProviderInputItemMetadata` in `src/adapters/openai-responses/request-strips.ts` only for diff --git a/structure/remote-link.md b/structure/remote-link.md index 81b5608a86f..9d74e557593 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. 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/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/` 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. @@ -154,7 +154,7 @@ A deadline still rejects late output. `src/server/management/link-enrollment.ts` owns probe, host confirmation and Home apply; `src/server/management/link-route-state.ts` supplies shared authorization and lifecycle compensation. The three routes accept the existing dashboard authority or trusted loopback admin authority; Tailscale identity sessions remain refused. Join remains operator-paired only. CLI `ocx link probe ` requests the optional `includeVersion: true` offer; legacy dashboard offers retain their three fields. Version probing executes against the temporary learned key and persists no trust. Confirm rechecks the pending generation and five-minute expiry after its SSH await, then updates the latest known_hosts atomically under the config lock. Same-alias operations fail fast on overlap; apply pins a private immutable host file and consumes confirmation before issuing a key. -`src/link/remote-probe.ts` separates command budget (15 seconds plus four times measured SSH setup, capped at 60 seconds) from a fresh 15-second relay deadline. Restart adds the existing 60-second drain and 70-second replacement windows. The runner's one deadline covers stdin writes, process exit and output drains; failure cancels readers and kills the local SSH child without an unbounded cleanup wait. Ordinary stdout and stderr cap at 64 KiB. The catalog command and the Child `config show --json` preflight in `src/server/management/link-child-preflight.ts` permit 4 MiB stdout, because no smaller command reports `port`, `runtimeRole` and `ephemeralChild` on every supported Child: full `ocx` has `config get`, the lightweight `ocx-child` binary answers only `config show --json`. A Child exceeding even that cap answers `502 child_preflight_output_limit` instead of a generic preflight failure. Coverage: `tests/clients/link-ssh-runner.test.ts`, `tests/server/link-enrollment.test.ts`. A `config show` that exits non-zero answers `child_preflight_failed` with the Child's own validation reason as the hint: a full `ocx` Child then runs `config validate --json` (16 KiB cap), and only its `error` string, which names fields and expected types and never configured values, is read through `boundHint`. A Child without that command, or one whose validate fails or times out, keeps the last `config show` stderr line (`sshFailureHint`), or names the exit code when stderr is empty; a `config show` that exits 0 without a usable port names the command. The CLI (`src/cli/link.ts` `errorCodeBody`) keeps exactly that hint, bounded and redacted again with `boundHint`, beside the error code, and still never echoes the rest of an error body (#8357). +`src/link/remote-probe.ts` separates command budget (15 seconds plus four times measured SSH setup, capped at 60 seconds) from a fresh 15-second relay deadline. Restart adds the existing 60-second drain and 70-second replacement windows. The runner's one deadline covers stdin writes, process exit and output drains; failure cancels readers and kills the local SSH child without an unbounded cleanup wait. Ordinary stdout and stderr cap at 64 KiB. The catalog command and the Child `config show --json` preflight in `src/server/management/link-child-preflight.ts` permit 4 MiB stdout, because no smaller command reports `port`, `runtimeRole` and `ephemeralChild` on every supported Child: full `ocx` has `config get`, the lightweight `ocx-child` binary answers only `config show --json`. A Child exceeding even that cap answers `502 child_preflight_output_limit` instead of a generic preflight failure. Coverage: `tests/clients/link-ssh-runner.test.ts`, `tests/server/link-enrollment.test.ts`. Before issuance, apply requires curl and effective standalone Child configuration plus a native Child health response on its configured port with `restartCapability: v1`. Already-client, mismatched-port and pre-capability runtimes are refused without enrollment. Thus a capable older standalone Child can be linked by a newer Home. Home calls existing `ocx restart` exactly once after `connect --key-stdin`; no raw stop/start or timeout replay. After client-role health, Home registers a fresh expected-key observer and asks Child's credential-free loopback `/v1/catalog`. Both a complete HTTP 200 JSON catalog and matching authenticated Home callback must occur within the relay window. Enrollment admission cannot satisfy this check. Curl ignores curlrc, proxies and redirects. Phase-specific failures compensate Home state; after possible remote commit, `residual: true` and fixed Child disconnect/restart guidance remain even if compensation fails. This is not remote rollback. @@ -184,36 +184,7 @@ 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 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`. - -`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`. +The Home learns a Child's apply from the Child (`src/link/child-parity.ts`, `src/client/link-parity-reporter.ts`). The linked client runtime POSTs `{linkId, lastApplyAt, runtimeStartedAt, appliedCodexSettingsDigest, sequence}` to `/v1/link-management/child-parity` through the connection-bound relay, shortly after start with backoff and then once a minute; a 404 stops it. On the Home the hub-link listener admits exactly that POST beside the read table, and `src/server/index/link-management.ts` handles it before the passive-read dispatch: the admitting key must own the named link, the body is capped at 1 KiB and 5 s, the parse is strict, and the only effect is an in-memory entry pruned to current links. Entries are ordered by `runtimeStartedAt`, then by `sequence` (process-wide, so a rollback keeps counting), and an older report is ignored, so a late POST from a replaced runtime or an earlier send never moves the row backward. `GET /api/link/status` and `ocx link status --json` carry `lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` and `reportedAt` (epoch ms) on every link row, null without a report; the CLI accepts their absence from an older runtime. Coverage: `tests/server/link-child-parity.test.ts`, `tests/clients/link-parity-reporter.test.ts`. ## Ephemeral Rust Child (`ocx-child`) @@ -231,6 +202,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). 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`. +Authenticated shutdown is bounded by `stop_owned_daemon`'s 3-second wait. One-shot Home calls carry a whole-request deadline (60 s for lifecycle fetches, 10 s for parity reports) enforced per read on the upstream socket, because per-read timeouts alone never end a trickling response. The relay's parity thread registers a clone of its in-flight socket in a `CancelSlot`; shutdown takes the same mutex, marks the slot cancelled, and shuts that socket down, so the request either sees the cancellation before it registers or is aborted after. A socket can be registered only once connected, so that connect is capped at 1 second. `serve` exits only after the parity thread has ended. When all 32 workers are busy, a new connection gets a short-lived control thread (at most 256) that answers only `/healthz` and the authenticated shutdown, with 1-second read and write timeouts and a 2-second deadline covering the request heads of the whole exchange. A full worker pool therefore cannot keep the shutdown connection from being handled, and a sender that trickles bytes holds a control slot for at most 2 seconds; while 256 control connections are open, a new one is closed unanswered. Coverage: `native/ocx-child/tests/lifecycle_guards.rs` (a Home that authenticates and then stalls or trickles the parity response; a relay whose workers are all blocked; control slots held by senders that trickle their request heads). Coverage: `tests/clients/link-ocx-child-conformance.test.ts`, `tests/clients/link-ephemeral-child-discovery.test.ts`, `tests/clients/link-child-plant-foundations.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/link-ocx-child-e2e.test.ts`, `gui/tests/remote-link.test.tsx`. Foreground startup requires an attached state, matching recovery journal, verified link key and matching listener port. Sync, restart and detach refuse a journal bound to different canonical homes; detach checks this before changing state or stopping the relay. Shutdown authenticates both ends on one socket without sending the private secret and never signals a process based only on a reported PID. Attach keeps a private rollback snapshot until success or completed recovery; detach can retry an incomplete rollback. Failed restore or cleanup keeps recovery records for retry. On Windows, temporary replacements are created with a protected owner-only DACL before bytes are written. diff --git a/structure/runtime.md b/structure/runtime.md index 95ad7fb7297..1aa77595d12 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -121,7 +121,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | -| `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` through the byte-capped direct-local transport when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. `src/cli/bundled-bun-diagnostic.ts` is the separate "Bundled Bun runtime" section: when `bundledBunPath()` finds no real binary it asks the package-layout detector who owns the install and prints that manager's repair command, because a skipped npm postinstall (npm 12 blocks lifecycle scripts by default) otherwise surfaces only later as the launcher's "the `bun` dependency is not installed" (#8361). It is observe-only and records no doctor failure — a PATH Bun or `OPENCODEX_BUN_PATH` can still serve — and on Windows the npm guidance stops the proxy before reinstalling, since a live `bun.exe` holds `node_modules/bun/bin` open. | +| `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` through the byte-capped direct-local transport when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. | | `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. | | `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes. `src/providers/routed-config.ts` owns request-time provider/registry resolution and the discarded-base-URL warning memo; key selection imports that leaf instead of the router, while `src/router.ts` retains the resolver and warning-reconciliation compatibility exports. The leaf preserves GCP service-account credential markers verbatim until the Vertex adapter resolves the selected account per request. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | @@ -293,7 +293,7 @@ atomic OS guarantee against unobserved PID reuse. An installed Codex shim is checked on eligible CLI startup with a direct-regular-file/1 MiB state bound and bounded metadata/prefix reads. `ocx codex` skips repair and owns handoff: unknown Windows CMD wrappers require complete bypass-aware template equality, otherwise launch is refused; content equality grants no Windows backing provenance. Other changing launchers defer and repair failures warn without changing their command's exit behavior. All mutations hold the shared atomic-mkdir interprocess lock and reread state before final revalidation, publication and state commit. Unique token-named owner records prevent release from naming a successor. -Aged locks are reclaimed only when every entry is a valid token-named owner record, every owner is past the stale window with a dead PID, and the same directory identity, token set and owner fingerprints remain immediately before deletion; reclaim unlinks each owner by its token name, then the directory. Age alone never evicts a live holder, and an unaccounted entry blocks reclaim. A directory holding several dead owners (#8317) is reclaimed the same way; release still removes only a lock its caller solely owns. +Aged locks are reclaimed only when the owner PID is dead and the same token, directory identity and owner fingerprint remain immediately before deletion; age alone never evicts a live holder. Windows repair retains complete sibling-set preflight, identical fingerprints/prefixes across a 100 ms observation interval, and reverse-order rollback if a later sibling changes. It restores stable external replacements without PATH rediscovery. Windows service/shim writes first admit the frozen selected runtime under the [write-preflight contract](ops/service-and-sidecars.md#windows-selected-runtime-write-preflight); orchestrated installs refuse Desktop supervision before selection and admit once before cleanup. Repair refuses Desktop before diagnosis, then checks ownership and auth before admission and any native repair or scheduler stop. The Node launcher’s validated PATH Bun fallback precedes CLI startup; durable admission never discovers a replacement. The fresh scheduler path claims config ownership after registration and staging removal, then admits before `prepare()`; refusal rolls back the new registration and preserves the claimed config root. Standalone admission uses the current executable in-process, without child isolation or a timeout. Unix installation owns only the private home/bin/codex wrapper and codex-shell-env.sh. The native manager-owned launcher stays in place; its recorded durable entry follows package upgrades and version rollback. `src/codex/shim-state-file.ts` decodes schema 2, mode `path-overlay`, supported Unix platform, exact private `wrapperPath`, and distinct absolute `launcherPath`; contradictory compatibility fields are invalid. State can record wrapper/env inode identities and a transaction id. `src/codex/shim-overlay.ts` compares persisted wrapper/environment ownership by inode, allowing APFS device numbers to change across reboot. Exact private paths, current-user ownership, regular-file checks and group/world-write restrictions, wrapper marker and recorded launcher binding, and exact environment content remain required. Runnable wrappers also require current generated content and a usable native launcher. Inspection snapshots, namespace revalidation, activation and publication/recovery identities still compare device plus inode to refuse concurrent replacements. `tests/codex-integration/codex-shim-overlay.test.ts` covers persisted device drift and inode/content refusals; `tests/codex-integration/codex-shim-overlay-transaction.test.ts` covers same-inspection device drift and concurrent publication. @@ -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. 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). +Activating service verbs retain their foreign/unknown durable-owner refusals. `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/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index e35b2f85bc6..6ec71fb190e 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -149,10 +149,8 @@ is not a prelude: it commits. the ordinary bounded combo-failure classifier and may advance to the next declared target; - a zero-output `response.incomplete` with no reason, `adapter_eof`, `missing_terminal_event`, or `upstream_stall_timeout` follows the same failure path; explicit semantic or unknown reasons stay terminal; -- a top-level `error` (`type: "error"`) arrives before output, in which case unknown, rate-limit, and server failures - may advance while errors explicitly classified as non-retryable 4xx remain committed. An untyped - `{"error":{...}}` envelope is not a control event, so it commits the target; the relay then reports it - as `response.failed` rather than `adapter_eof`; +- a top-level `error` arrives before output, in which case unknown, rate-limit, and server failures + may advance while errors explicitly classified as non-retryable 4xx remain committed; - another completed/incomplete terminal or the aggregate preflight byte or retained-chunk cap is reached, in which case the current target is committed conservatively. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 2bd79832c85..5f11fd37224 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -397,14 +397,14 @@ Native passthrough SSE has TWO shapes, selected per request in The shared `createSseInspector` factory in `relay.ts` retains the complete inspection side effects, including the #44 late-terminal semantics. -Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`: a `type: "error"` event, or an envelope with no root `type` that carries an `error` object (#8216). A typed event of any other kind is never bare, even when it has an `error` field. The SSE-to-WebSocket pump in `src/server/ws-bridge.ts` skips the untyped envelope rather than failing it as an invalid frame, so the boundary's terminal still reaches a WebSocket client; any other frame without a string `type` stays a protocol error. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) overload codes (`server_is_overloaded`, `overloaded_error`), and the client-refusal classes `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error` (400/401/403/404, the code repeating the class); recognized types apply only without an explicit code. -At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code or error class without a -message still produces a terminal (a refusal code gets refusal copy, any other identifier is named in the message). Codex retries codes outside its fatal set, so code and message follow the same candidate precedence; +Both client readers retain a bounded, redacted message and the first structured refusal code from a bare upstream `error`. EOF synthesis also retains narrowly recognized rate-limit codes (`rate_limit_exceeded`, `rate_limit_error`) overload codes (`server_is_overloaded`, `overloaded_error`), and the client-refusal classes `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error` (400/401/403/404, the code repeating the class); recognized types apply only without an explicit code. +At EOF without a real terminal they synthesize `response.failed` rather than `adapter_eof`; a code without a message still +produces a terminal. Codex retries codes outside its fatal set, so code and message follow the same candidate precedence; recognized refusal copy is used only when the event has no code. A read failure after refusal reports that refusal (#5176). The shared outbound rewrite masks diagnostics on real failed and incomplete terminals before SSE or buffered JSON delivery, while preserving status and output; failed turns are not retained as continuation state. Buffered JSON masks selected credentials in synthetic bare-error fields before log inspection or client formatting; request logs keep transport status. The delivering reader owns refusal evidence before EOF; asynchronous tee inspection cannot reliably supply it. The client ingress in `src/server/index/websocket-handler.ts` preserves the same in-process replay-refusal marker as the HTTP deferred logger before finalization: a synthetic 429 retains `upstream_reset_replay_refused` and `transport-ambiguous` on the row and serving attempt, with `terminalSource: synthetic` on the row. `src/server/responses/core-combo-failure.ts` carries that marker through child failure rebuilds, and the combo loop stamps the failed child attempt before adopting its log. Synthetic refusals stop before combo cooldown/account rotation and supply no quota reset or cooldown metadata. Genuine upstream 429s keep rate-limit attribution and health handling. This is independent of the upstream transport choice; `headers-only` does not prove upstream execution or quota exhaustion. `tests/responses/ws-replay-refusal-attribution.test.ts` checks direct routes; `tests/responses/combo-replay-refusal-attribution.test.ts` checks both combo ingresses, gateway and pool children, health/cooldown state and genuine 429 controls, with one send and no recovery. -Inspection still applies the same bare-error extraction at EOF for account health, so a refusal or code sent without a message is reported `failed` there too. Real terminals and caller cancellation take precedence. Unknown codes retain `upstream_error` / `upstream_server_error` in the client tail; on the streaming path the log status takes the frame's Codex WebSocket wrapper 5xx when stated (buffered JSON callers keep their own presentation status). Refusal precedence is unchanged and classification adds no replay or retry. Bare-error log status is provisional until a genuine terminal supersedes it through `src/server/request-log-terminal-status.ts`. +Inspection still applies the bare-error rule at EOF for account health. Real terminals and caller cancellation take precedence. Unknown codes retain `upstream_error` / `upstream_server_error` in the client tail; on the streaming path the log status takes the frame's Codex WebSocket wrapper 5xx when stated (buffered JSON callers keep their own presentation status). Refusal precedence is unchanged and classification adds no replay or retry. Bare-error log status is provisional until a genuine terminal supersedes it through `src/server/request-log-terminal-status.ts`. Native recovery preflight keeps the rejected body reader and bounded prefix for normal mid-stream failure, without decrypt retry. Native Responses may rebuild once when encrypted function/custom-tool output or @@ -517,7 +517,7 @@ byte-identical payloads unchanged. The client-facing boundary treats the first Responses terminal as authoritative in both relay shapes. High-confidence policy errors carried as `response.incomplete`, `response.failed`, or a -bare `error` (typed or untyped) are normalized to one `response.failed / cyber_policy` event without changing the +top-level `error` are normalized to one `response.failed / cyber_policy` event without changing the refusal outcome; later bytes cannot create a second terminal. A clean HTTP 200 EOF with no terminal instead emits one `response.incomplete` with `adapter_eof`, followed by one `[DONE]`. Delimiter-less EOF candidates follow the owning repair policy: the native boundary accepts a structurally valid diff --git a/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts b/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts deleted file mode 100644 index 1f78fd48537..00000000000 --- a/tests/adapters/anthropic/anthropic-thinking-display-policy.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { expect, test } from "bun:test"; -import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; -import { parseRequest } from "../../../src/responses/parser"; -import type { OcxProviderConfig } from "../../../src/types"; - -const base = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "synthetic" } as const; -// Routed requests reach the adapter with the native wire id, which is also the key these maps use. -const opus55 = "claude-opus-5-5"; -const sonnet5 = "claude-sonnet-5"; -const updatesBeta = "thinking-display-updates-2026-08-18"; - -async function requestBody( - provider: OcxProviderConfig, - model: string, - reasoning?: { effort?: string; summary?: string }, -): Promise<{ thinking: unknown; beta: string | null }> { - const adapter = createAnthropicAdapter(provider); - const request = await adapter.buildRequest(parseRequest({ - model, - input: "question", - ...(reasoning ? { reasoning } : {}), - })); - return { - thinking: JSON.parse(request.body as string).thinking, - beta: new Headers(request.headers).get("anthropic-beta"), - }; -} - -test("modelThinkingDisplay summarized sends summarized for native Opus 5.5 with bare effort", async () => { - const { thinking, beta } = await requestBody({ ...base, modelThinkingDisplay: { "claude-opus-5-5": "summarized" } }, opus55, { effort: "high" }); - expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); - expect(beta ?? "").not.toContain(updatesBeta); -}); - -test("provider-level summarized applies when no model entry exists", async () => { - const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "summarized" }, opus55); - expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); - expect(beta ?? "").not.toContain(updatesBeta); -}); - -test("model-level updates overrides provider summarized for Opus 5.5", async () => { - const { thinking, beta } = await requestBody({ - ...base, - thinkingDisplay: "summarized", - modelThinkingDisplay: { "claude-opus-5-5": "updates" }, - }, opus55, { effort: "high" }); - expect(thinking).toEqual({ type: "adaptive", display: "updates" }); - expect(beta).toContain(updatesBeta); -}); - -test("updates configured for a non-Opus-5.5 adaptive model keeps current behavior", async () => { - const configured = await requestBody({ ...base, thinkingDisplay: "updates" }, sonnet5, { effort: "high" }); - const unset = await requestBody({ ...base }, sonnet5, { effort: "high" }); - expect(configured.thinking).toEqual(unset.thinking); - expect(configured.thinking).toEqual({ type: "adaptive", display: "summarized" }); - expect(configured.beta ?? "").not.toContain(updatesBeta); -}); - -test("model-level auto overrides a provider-level policy for that model", async () => { - const { thinking, beta } = await requestBody({ - ...base, - thinkingDisplay: "summarized", - modelThinkingDisplay: { "claude-opus-5-5": "auto" }, - }, opus55, { effort: "high" }); - expect(thinking).toEqual({ type: "adaptive", display: "updates" }); - expect(beta).toContain(updatesBeta); - - // The provider value still applies to a model with no entry of its own. - const sonnet = await requestBody({ ...base, thinkingDisplay: "summarized" }, sonnet5, { effort: "high" }); - expect(sonnet.thinking).toEqual({ type: "adaptive", display: "summarized" }); -}); - -test("explicit request summary detailed still wins over configured updates", async () => { - const { thinking, beta } = await requestBody({ - ...base, - thinkingDisplay: "updates", - modelThinkingDisplay: { "claude-opus-5-5": "updates" }, - }, opus55, { effort: "high", summary: "detailed" }); - expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); - expect(beta ?? "").not.toContain(updatesBeta); -}); - -test("explicit request summary wins over configured omitted on every adaptive model", async () => { - for (const model of [opus55, sonnet5]) { - for (const summary of ["concise", "detailed"] as const) { - const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "omitted" }, model, { effort: "high", summary }); - expect(thinking).toEqual({ type: "adaptive", display: "summarized" }); - expect(beta ?? "").not.toContain(updatesBeta); - } - } -}); - -test("unset policy keeps Opus 5.5 updates and non-Opus summarized byte-identically", async () => { - const opus = await requestBody({ ...base }, opus55, { effort: "high" }); - expect(opus.thinking).toEqual({ type: "adaptive", display: "updates" }); - expect(opus.beta).toContain(updatesBeta); - const sonnet = await requestBody({ ...base }, sonnet5, { effort: "high" }); - expect(sonnet.thinking).toEqual({ type: "adaptive", display: "summarized" }); -}); - -test("auto policy keeps hideThinkingSummary meaning on adaptive models", async () => { - const adapter = createAnthropicAdapter({ ...base, thinkingDisplay: "auto" }); - const parsed = parseRequest({ model: sonnet5, input: "question", reasoning: { effort: "high" } }); - parsed.options.hideThinkingSummary = true; - const request = await adapter.buildRequest(parsed); - expect(JSON.parse(request.body as string).thinking).toEqual({ type: "adaptive" }); -}); - -test("configured summarized overrides local summary hiding on adaptive models", async () => { - for (const model of [opus55, sonnet5]) { - const adapter = createAnthropicAdapter({ ...base, thinkingDisplay: "summarized" }); - const parsed = parseRequest({ model, input: "question", reasoning: { effort: "high" } }); - parsed.options.hideThinkingSummary = true; - const request = await adapter.buildRequest(parsed); - expect(JSON.parse(request.body as string).thinking).toEqual({ type: "adaptive", display: "summarized" }); - } -}); - -test("omitted policy sends adaptive thinking without a summarized display", async () => { - for (const model of [opus55, sonnet5]) { - const { thinking, beta } = await requestBody({ ...base, thinkingDisplay: "omitted" }, model, { effort: "high" }); - expect(thinking).toEqual({ type: "adaptive" }); - expect(beta ?? "").not.toContain(updatesBeta); - } -}); diff --git a/tests/adapters/google/google-signature-history-roundtrip.test.ts b/tests/adapters/google/google-signature-history-roundtrip.test.ts index ebf503badbd..8cdc3674082 100644 --- a/tests/adapters/google/google-signature-history-roundtrip.test.ts +++ b/tests/adapters/google/google-signature-history-roundtrip.test.ts @@ -11,7 +11,6 @@ import { __resetAntigravityReplayCache, applyAntigravityReplay, observeAntigravityReplay, - THOUGHT_SIGNATURE_BYPASS, } from "../../../src/adapters/google-antigravity-replay"; import { parseRequest } from "../../../src/responses/parser"; import { @@ -615,70 +614,6 @@ describe("#1735 thought signature survives history replay", () => { // Unrelated call from another turn is preserved expect(lookupReplayThoughtSignature("call_unrelated_3", scope)).toBe(SIG_C); }); - - // #8054: the 200-body tests above never reach the transport. A real upstream rejection is an - // HTTP 400 that the dispatcher reports without calling parseResponse, so the clear has to run - // in the adapter's own fetch path — exercised here through fetchResponse with a non-200 send. - // The second case carries no "invalid argument" or "signature" wording: Google's normalized 400 - // text keeps only the message, so the HTTP 400 status itself has to trigger the clear. - const rejections = [ - { label: "INVALID_ARGUMENT text", message: "Request contains an invalid argument." }, - { label: "status-only", message: "bad model" }, - ] as const; - for (const mode of ["cloud-code-assist", "vertex"] as const) for (const rejection of rejections) { - test(`${mode}: a real HTTP 400 (${rejection.label}) drops the cached signature so the next turn uses the sentinel`, async () => { - const modeProvider = mode === "cloud-code-assist" - ? { ...provider, googleMode: mode, baseUrl: "https://daily-cloudcode-pa.googleapis.com", project: "test-proj", apiKey: "test-token" } as OcxProviderConfig - : provider; - const user = { role: "user", content: [{ type: "input_text", text: "issue-8054 session" }] }; - const tools = [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }]; - // Unscoped, so the durable store stays empty and only the session replay cache can sign. - const replayTurn = () => parseRequest({ - model: MODEL, - stream: false, - input: [ - user, - { type: "function_call", call_id: "call_8054", name: "shell_command", arguments: "{\"cmd\":\"pwd\"}" }, - { type: "function_call_output", call_id: "call_8054", output: "/tmp" }, - ], - tools, - }); - const firstCallSignature = (body: string) => { - const envelope = JSON.parse(body) as { request?: { contents: unknown[] } }; - const wire = mode === "cloud-code-assist" ? JSON.stringify(envelope.request) : body; - return modelParts(wire).find(part => "functionCall" in part)?.thoughtSignature; - }; - - // Turn 1: upstream mints a signature, which the replay cache observes. - const first = createGoogleAdapter(modeProvider); - await first.buildRequest(parseRequest({ model: MODEL, stream: false, input: [user], tools })); - const minted = googleBody([fcPart("shell_command", { cmd: "pwd" }, SIGNATURE)]); - await first.parseResponse!(new Response(JSON.stringify(mode === "cloud-code-assist" ? { response: minted } : minted))); - - // Turn 2 replays it and upstream rejects the request with a real 400. - const second = createGoogleAdapter(modeProvider); - const built = await second.buildRequest(replayTurn()); - expect(firstCallSignature(built.body as string)).toBe(SIGNATURE); - let sends = 0; - const executor = (async () => { - sends++; - return new Response(JSON.stringify({ - error: { code: 400, status: "INVALID_ARGUMENT", message: rejection.message }, - }), { status: 400, headers: { "content-type": "application/json" } }); - }) as unknown as typeof fetch; - const res = await second.fetchResponse!(built, { executor, timeoutMs: 5_000 }); - expect(res.status).toBe(400); - // No resend: payload rejection stays resend-is-futile. - expect(sends).toBe(1); - // The dispatcher still owns an unread body. - expect(await res.text()).toContain(rejection.message); - - // Turn 3 must not replay the rejected signature; the first call falls back to the sentinel. - const third = createGoogleAdapter(modeProvider); - const retried = await third.buildRequest(replayTurn()); - expect(firstCallSignature(retried.body as string)).toBe(THOUGHT_SIGNATURE_BYPASS); - }); - } }); describe("Antigravity Multi-Signature History Stability (Mechanism ②)", () => { diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index dd3841279c9..4d3674c44f6 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -142,9 +142,7 @@ describe("install scripts", () => { const script = await readText("scripts/install.sh"); expect(script).toContain("Node.js 18+ is required"); - // #8361: npm 12 blocks bun's postinstall by default, so a bare install produces a - // package whose bundled runtime never unpacked. Both installers must permit that one script. - expect(script).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex"); + expect(script).toContain("npm install -g @bitkyc08/opencodex"); expect(script).toContain("command -v ocx"); expect(script).toContain("ocx help"); expect(script).not.toContain("bun install -g @bitkyc08/opencodex"); @@ -155,7 +153,7 @@ describe("install scripts", () => { const script = await readText("scripts/install.ps1"); expect(script).toContain("Node.js 18+ is required"); - expect(script).toContain("& $npm.Source install -g --allow-scripts=bun @bitkyc08/opencodex"); + expect(script).toContain("& $npm.Source install -g @bitkyc08/opencodex"); expect(script).toContain("$LASTEXITCODE"); expect(script).toContain("Get-Command ocx.cmd"); expect(script).toContain("Get-Command ocx"); @@ -221,9 +219,7 @@ exit 0 expect(launcher).toContain('process.argv[2] === "update"'); expect(launcher).toContain('const selector = child?.version ?? tag;'); - // #8361: the launcher's own npm reinstall must allow bun's postinstall for the same - // reason the shipped installers do; a bare install leaves the runtime un-unpacked. - expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]'); + expect(launcher).toContain('["install", "-g", `${PKG}@${selector}`]'); expect(launcher).toContain('["add", "-g", "--allow-build=bun", `${PKG}@${selector}`]'); expect(launcher).toContain('const tag = updateTag(current);'); expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"'); diff --git a/tests/ci-workflows/skill-ocx-generated.test.ts b/tests/ci-workflows/skill-ocx-generated.test.ts index f3dc8578579..bfa715af2e0 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({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); + expect(result.status).toBe(0); 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 0eff9cda760..89b6ec9e87f 100644 --- a/tests/claude-integration/claude-config-root-writer-barrier.test.ts +++ b/tests/claude-integration/claude-config-root-writer-barrier.test.ts @@ -3,7 +3,6 @@ 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"; @@ -244,7 +243,6 @@ 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) { @@ -267,36 +265,6 @@ 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 6b92cf4444f..a54346fb4e9 100644 --- a/tests/claude-integration/claude-intercept-acl-barrier.test.ts +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -333,10 +333,6 @@ 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 0b92f2b4c2c..38ad1c134ce 100644 --- a/tests/cli/cli-capabilities.test.ts +++ b/tests/cli/cli-capabilities.test.ts @@ -22,17 +22,6 @@ 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 ed8050d21a6..71f1a676f77 100644 --- a/tests/cli/cli-capability-data.test.ts +++ b/tests/cli/cli-capability-data.test.ts @@ -8,7 +8,6 @@ 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"; @@ -42,12 +41,6 @@ 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([]); @@ -318,7 +311,6 @@ 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 dc252108403..84a44c790d6 100644 --- a/tests/cli/cli-help-paths.test.ts +++ b/tests/cli/cli-help-paths.test.ts @@ -120,15 +120,13 @@ describe("explicit CLI help paths", () => { expect(output).not.toContain("Parent help:"); }); - test("route family help names combo, policy and to and retains topic links", () => { + test("route family help names combo and policy and retains policy 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, routing profiles and per-device client intent."); + expect(result.stdout).toContain("Usage: ocx route "); + expect(result.stdout).toContain("Manage combo routing and routing profiles."); 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-enrollment.test.ts b/tests/cli/cli-link-enrollment.test.ts index 13dd81b9832..5cc2cf21dfd 100644 --- a/tests/cli/cli-link-enrollment.test.ts +++ b/tests/cli/cli-link-enrollment.test.ts @@ -167,33 +167,6 @@ describe("CLI Remote Link enrollment", () => { } }); - test("a phase failure prints the server's bounded cause, not only the phase name (#8357)", async () => { - let step = 0; - const h = harness(); - try { - const hint = "Error: defaultProvider must exist in providers"; - h.deps.fetchImpl = async () => ++step < 3 ? Response.json(step === 1 ? probe : confirm) : Response.json({ error: { code: "child_preflight_failed", message: "The Child effective configuration could not be read.", hint } }, { status: 502 }); - expect(await runLinkCommand(["add", "child-a", "--expect-fingerprint", fingerprint], h.deps)).not.toBe(0); - const message = h.err.join("\n"); - expect(message).toContain("Remote Link failed during Child preflight"); - expect(message).toContain(hint); - } finally { h.restore(); } - }); - - test("only the bounded, redacted hint survives from an error body (#8357)", async () => { - let step = 0; - const h = harness(); - try { - const secret = "ocx_data_" + "a".repeat(40); - h.deps.fetchImpl = async () => ++step < 3 ? Response.json(step === 1 ? probe : confirm) - : Response.json({ error: { code: "remote_connect_failed", message: "leak " + secret, hint: "connect said " + secret }, key: secret }, { status: 502 }); - expect(await runLinkCommand(["add", "child-a", "--expect-fingerprint", fingerprint], h.deps)).not.toBe(0); - const message = h.err.join("\n"); - expect(message).toContain("Remote Link failed during Child enrollment: connect said ocx_"); - expect(message).not.toContain(secret); - } finally { h.restore(); } - }); - test("a bare host without a local ocx-child build names where Home looks for one", async () => { const missing = { alias: "bare-b", fingerprint, keyType: "ED25519", ocxVersion: null, runtimeKind: "missing", targetTriple: "linux-arm64", plantable: false, plantUnavailableReason: "artifact_not_found" }; const h = harness([missing]); diff --git a/tests/cli/cli-link-route-intent.test.ts b/tests/cli/cli-link-route-intent.test.ts deleted file mode 100644 index 50e9c27b9ce..00000000000 --- a/tests/cli/cli-link-route-intent.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 37988957180..5831e812d30 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] [--recover-config --yes] [--json]"); + expect(findCommand("disconnect")?.usage).toBe("ocx disconnect [--keep-catalog] [--json]"); }); }); diff --git a/tests/cli/cli-route-to-aliases.test.ts b/tests/cli/cli-route-to-aliases.test.ts deleted file mode 100644 index 1bd34c9785f..00000000000 --- a/tests/cli/cli-route-to-aliases.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 8883bb5d29a..00000000000 --- a/tests/cli/cli-route-to-confirmation.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 773c19303e6..00000000000 --- a/tests/cli/cli-route-to.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -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 b5c04c025db..336cc7fc907 100644 --- a/tests/cli/cli-storage-inspect.test.ts +++ b/tests/cli/cli-storage-inspect.test.ts @@ -269,12 +269,10 @@ describe("ocx integration native", () => { expect(out).toContain("disable blocked: in use"); }); - test("a toggle PUTs the per-client native route, which records only the Home override", async () => { + test("a toggle PUTs the per-client route with a boolean", async () => { const { calls, deps } = harness(() => ({ json: { ok: true } })); const cap = capture(); - let code: number; - try { code = await handleIntegrationCommand(["native", "claude-desktop", "off"], deps); } finally { cap.restore(); } - expect(code!).toBe(0); + try { await handleIntegrationCommand(["native", "claude-desktop", "off"], deps); } finally { cap.restore(); } 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 7f1953fd075..26edbc95497 100644 --- a/tests/cli/cli-sync-expect-link.test.ts +++ b/tests/cli/cli-sync-expect-link.test.ts @@ -329,14 +329,6 @@ 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 deleted file mode 100644 index 05e21315210..00000000000 --- a/tests/cli/cli-sync-fanout-ephemeral.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 9e07721c2af..f3472bc4b71 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: stage === "preflight" ? ["codex"] : ["claude"], + selectedClients: ["claude"], managementTransport: "direct", noSync: true, - }, { fetchImpl, catalogCompatibility: { supportedEfforts: () => new Set() }, now: () => { + }, { fetchImpl, 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, catalogCompatibility: { supportedEfforts: () => new Set() } }; + const deps = { fetchImpl, lifecycleLockDeps: lockDeps }; 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 deleted file mode 100644 index aca0115ba9c..00000000000 --- a/tests/clients/client-disconnect-config-recovery.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index c45e442829d..00000000000 --- a/tests/clients/client-disconnect-edited-codex.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 1bdf4245844..00000000000 --- a/tests/clients/client-disconnect-field-snapshot.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 97f136b2942..9ce57ed293f 100644 --- a/tests/clients/client-home-unreachable.test.ts +++ b/tests/clients/client-home-unreachable.test.ts @@ -27,12 +27,6 @@ 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 deleted file mode 100644 index d64ca0ddcf6..00000000000 --- a/tests/clients/home-route-intent-upgrade.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index cc2e01ec287..00000000000 --- a/tests/clients/link-home-route-intent.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 b5c2353f6be..16069d6e97a 100644 --- a/tests/clients/link-parity-reporter.test.ts +++ b/tests/clients/link-parity-reporter.test.ts @@ -31,7 +31,6 @@ 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 deleted file mode 100644 index b47ea2728e0..00000000000 --- a/tests/clients/link-pinned-sync-overwrite.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index ec890fd07dd..00000000000 --- a/tests/clients/route-intent-aside.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 0a3836715bb..00000000000 --- a/tests/clients/route-intent-claude-surface.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 07dd6b4d1b8..00000000000 --- a/tests/clients/route-intent-codex-connect-disabled.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -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 deleted file mode 100644 index 930caceb1ba..00000000000 --- a/tests/clients/route-intent-codex-transitions.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -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 deleted file mode 100644 index e90b8925075..00000000000 --- a/tests/clients/route-intent-connect-recovery.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index ac4c8eeea09..00000000000 --- a/tests/clients/route-intent-connected-review.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index 7786d7e148a..00000000000 --- a/tests/clients/route-intent-connected.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -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 deleted file mode 100644 index c6aa6a7ce36..00000000000 --- a/tests/clients/route-intent-desktop.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 94ca4b5e736..00000000000 --- a/tests/clients/route-intent-file-originals.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index e5cb3eba7d5..00000000000 --- a/tests/clients/route-intent-home-consent.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index e5756926941..00000000000 --- a/tests/clients/route-intent-local-receipt.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 4902376be4c..00000000000 --- a/tests/clients/route-intent-materializer.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -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 deleted file mode 100644 index 84703a3d43a..00000000000 --- a/tests/clients/route-intent-native-originals.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 01addaca97a..00000000000 --- a/tests/clients/route-intent-native-review.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -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 deleted file mode 100644 index 935a4792b34..00000000000 --- a/tests/clients/route-intent-offline-mode-switch.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index 04804064e48..00000000000 --- a/tests/clients/route-intent-overwrite.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 54b7d1a1390..00000000000 --- a/tests/clients/route-intent-parity-fallback.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 62f1f1ad7cf..00000000000 --- a/tests/clients/route-intent-parity-withdrawal.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 778ffd7b8d4..00000000000 --- a/tests/clients/route-intent-parity.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index eba3462ff79..00000000000 --- a/tests/clients/route-intent-reporter.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index fd06c88384c..00000000000 --- a/tests/clients/route-intent-runtime-stop.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 2d1432d5da7..00000000000 --- a/tests/clients/route-intent-scheduler.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 3cbf8202d81..00000000000 --- a/tests/clients/route-intent-state.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 32af8940f42..00000000000 --- a/tests/clients/route-intent-watch-client.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 482c61ce4d0..00000000000 --- a/tests/clients/route-intent.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index e9803a4402b..62a2f53c82e 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -609,12 +609,13 @@ describe("ocx sync refreshes an already-owned MCode integration", () => { expect(store.listOperations("mcode").map(row => row.kind)).toEqual(["apply"]); }); - test("leaves a removed MCode alone without recreating its home or reporting a failure", async () => { + test("does not recreate the client home or config when MCode was removed", async () => { expect(applyIntegration(input(oldModels)).ok).toBe(true); removeTreeWithRetry(INTEGRATION_CLIENTS.mcode.detectDir(env, home)); - expect(await refreshOwnedIntegration(input(newModels))).toBeNull(); - expect(store.readRecords().mcode?.clientId).toBe("mcode"); + const outcome = await refreshOwnedIntegration(input(newModels)); + expect(outcome?.ok).toBe(false); + expect(outcome?.reason).toContain("mcode is not installed"); expect(existsSync(INTEGRATION_CLIENTS.mcode.detectDir(env, home))).toBe(false); expect(store.listOperations("mcode").map(row => row.kind)).toEqual(["apply"]); }); @@ -813,14 +814,9 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { const detectDir = INTEGRATION_CLIENTS[clientId].detectDir(env, home); removeTreeWithRetry(detectDir); const outcomes = await refreshOwnedCatalogIntegrations(input(filteredModels)); - // A removed file client is left alone, not reported: a warning on every model toggle - // for a client the user uninstalled is noise. Aside keeps its per-profile report. - if (clientId === "pi") expect(outcomes).toEqual([]); - else { - expect(outcomes).toHaveLength(1); - expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); - expect(outcomes[0]?.reason).toContain(`${clientId} is not installed`); - } + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ client: clientId, ok: false }); + expect(outcomes[0]?.reason).toContain(`${clientId} is not installed`); expect(existsSync(detectDir)).toBe(false); expect(store.readRecords()[clientId]).toEqual(recordBefore); expect(store.listOperations(clientId).map(row => row.kind)).toEqual(["apply"]); diff --git a/tests/codex-integration/codex-disconnect-fields.test.ts b/tests/codex-integration/codex-disconnect-fields.test.ts deleted file mode 100644 index ceb88adf80d..00000000000 --- a/tests/codex-integration/codex-disconnect-fields.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 121a6126720..00000000000 --- a/tests/codex-integration/codex-disconnect-owned-recovery.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 207bc645310..00000000000 --- a/tests/codex-integration/codex-journal-provider-reserialization.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/codex-runtime-macos-desktop.test.ts b/tests/codex-integration/codex-runtime-macos-desktop.test.ts index a1ebf35ee8c..764eee42643 100644 --- a/tests/codex-integration/codex-runtime-macos-desktop.test.ts +++ b/tests/codex-integration/codex-runtime-macos-desktop.test.ts @@ -7,8 +7,6 @@ import { setCodexRuntimeResolveCacheForTests, type ResolveCodexRuntimeDeps, } from "../../src/codex/runtime"; import { loadBundledCodexCatalog, loadBundledCodexCatalogAsync } from "../../src/codex/catalog/bundled"; -import { codexSupportedReasoningEfforts } from "../../src/codex/catalog/effort"; -import { inspectClientCatalogReadiness } from "../../src/client/catalog-compatibility"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const roots: string[] = []; @@ -318,94 +316,3 @@ describe("bundled app catalog consumer", () => { }); } }); - -/** - * #8360: a macOS Child whose only Codex is the CLI inside ChatGPT.app reported - * `readiness: unverified` ("the selected local Codex runtime did not report the reasoning - * levels it supports"). The runtime resolver only offers the app candidate after it can - * classify the bundle's XML `Info.plist` identity, so a bundle it cannot classify leaves the - * selection on the bare `codex` sentinel. The readiness probe then asked a command that does - * not exist while a real, trusted Codex sat inside the app. - */ -describe("app-bundled CLI fallback for connect readiness (#8360)", () => { - /** A catalog with a distinctive ladder, so a warm fixture value cannot pass by accident. */ - const bundledCatalog = JSON.stringify({ models: [{ - slug: "gpt-5.6-sol", - base_instructions: "Fixture instructions.", - supported_reasoning_levels: [{ effort: "low" }, { effort: "high" }], - }] }); - - /** Exec seam: only the bundled CLI answers, with its version or its bundled catalog. */ - function bundledOnlyExec(appCommand: string, spawned: string[]) { - return (file: string, args: readonly string[]) => { - spawned.push(file); - if (file !== appCommand) throw Object.assign(new Error("not found"), { code: "ENOENT" }); - return args[0] === "debug" ? bundledCatalog : "codex-cli 0.166.0-alpha.6"; - }; - } - - test("an app-bundled-only layout yields the bundled ladder and reports ready", () => { - const f = fixture(); - // The bundle identity is not classifiable (the shape an app update can leave behind), so - // discovery declines the app candidate and the resolver ends on the bare sentinel. - f.deps.readBundleIdentifier = () => null; - f.versions.set(f.current, "0.166.0-alpha.6"); - const spawned: string[] = []; - const exec = bundledOnlyExec(f.current, spawned); - const selected = resolveCodexRuntime({ ...f.deps, discoverAlternatives: false, execFileSync: exec }); - expect(selected.runtime.command).toBe("codex"); - - const efforts = codexSupportedReasoningEfforts({ - ...f.deps, execFileSync: exec, commandCandidates: () => [selected.runtime.command], - }); - expect(efforts).toEqual(new Set(["low", "high"])); - expect(inspectClientCatalogReadiness("present", bundledCatalog, { supportedEfforts: () => efforts })) - .toEqual({ kind: "ready" }); - expect(spawned).toContain(f.current); - }); - - test("app locations that do not exist leave the ladder unverified instead of inventing one", () => { - const f = fixture(); - f.deps.readBundleIdentifier = () => null; - const spawned: string[] = []; - const exec = bundledOnlyExec(f.current, spawned); - const efforts = codexSupportedReasoningEfforts({ - ...f.deps, execFileSync: exec, commandCandidates: () => ["codex"], - }); - expect(efforts).toBeNull(); - // No app binary exists, so none is offered; the bare sentinel cannot resolve at the exec - // boundary either. A missing runtime stays unverified rather than borrowing a ladder. - expect(spawned).toEqual([]); - }); - - test("a resolved absolute command is probed as given, without widening to app candidates", () => { - const f = fixture(); - const standalone = "/fixture/bin/codex"; - f.versions.set(standalone, "0.167.0"); - f.versions.set(f.current, "0.166.0-alpha.6"); - const spawned: string[] = []; - const exec = (file: string, args: readonly string[]) => { - spawned.push(file); - return args[0] === "debug" ? bundledCatalog : "codex-cli 0.167.0"; - }; - expect(codexSupportedReasoningEfforts({ - ...f.deps, execFileSync: exec, commandCandidates: () => [standalone], - })).toEqual(new Set(["low", "high"])); - expect(spawned).toEqual([standalone]); - }); - - test("the async bundled-catalog driver widens a bare sentinel the same way", async () => { - const f = fixture(); - f.versions.set(f.current, "0.166.0-alpha.6"); - const spawned: string[] = []; - const exec = bundledOnlyExec(f.current, spawned); - const catalog = await loadBundledCodexCatalogAsync({ - ...f.deps, - commandCandidates: () => ["codex"], - execFileSync: exec as never, - execFile: async (file, args) => exec(file, args), - }); - expect(catalog?.models[0]?.slug).toBe("gpt-5.6-sol"); - expect(spawned).toContain(f.current); - }); -}); diff --git a/tests/codex-integration/codex-shim-runtime-preflight.test.ts b/tests/codex-integration/codex-shim-runtime-preflight.test.ts index bca8ac902d0..c4ccf7b0cf2 100644 --- a/tests/codex-integration/codex-shim-runtime-preflight.test.ts +++ b/tests/codex-integration/codex-shim-runtime-preflight.test.ts @@ -61,8 +61,7 @@ describe("Windows shim selected-runtime preflight", () => { expect(maybeAutoRestoreCodexShim("start", ["start"], { env: {}, warn: message => warnings.push(message), restore: () => result, readConfig: () => ({ config: getDefaultConfig(), source: "default", error: null }), })).toBeUndefined(); - // #8361: the repair hint must approve bun's postinstall; npm 12 skips it by default. - expect(warnings).toEqual([expect.stringContaining("npm install -g --allow-scripts=bun @bitkyc08/opencodex")]); + expect(warnings).toEqual([expect.stringContaining("npm install -g @bitkyc08/opencodex")]); })); } diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index 3361ad40e6b..fc7819b90a7 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -1858,54 +1858,6 @@ exit 127 }); }); - // #8317: a lock directory holding several aged owner records used to be unreadable, so it was never reclaimed. - function seedAgedLockEntries(lockPath: string, pids: number[], extra: string[] = []): string[] { - mkdirSync(lockPath); - pids.forEach((pid, index) => { - const ownerPath = join(lockPath, `aged-owner-${index}.json`); - writeFileSync(ownerPath, `${JSON.stringify({ version: 1, token: `aged-owner-${index}`, pid, createdAt: 0 })}\n`, "utf8"); - utimesSync(ownerPath, new Date(0), new Date(0)); - }); - for (const name of extra) { - writeFileSync(join(lockPath, name), "unaccounted\n", "utf8"); - utimesSync(join(lockPath, name), new Date(0), new Date(0)); - } - return readdirSync(lockPath).sort(); - } - - test("an aged lock whose owner records all name dead pids is reclaimed", () => { - withInstalledShim(({ home, wrappers }) => { - const lockPath = join(home, "codex-shim.autorestore.lock"); - wrappers.forEach((path, index) => process.platform === "win32" - ? writeFileSync(path, successfulLauncher(`two-dead-owners-${index}`), "utf8") : chmodSync(path, 0o644)); - seedAgedLockEntries(lockPath, [2_147_483_647, 2_147_483_646]); - - expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait }).status).toBe("restored"); - expect(existsSync(lockPath)).toBe(false); - wrappers.forEach(path => expect(readFileSync(path, "utf8")).toContain(SHIM_MARKER)); - }); - }); - - for (const [label, pids, extra] of [ - ["one live owner", [2_147_483_647, process.pid], []], - ["an unaccounted entry", [2_147_483_647, 2_147_483_646], ["stray.tmp"]], - ] as const) { - test(`an aged multi-owner lock with ${label} is never reclaimed`, () => { - withInstalledShim(({ home, wrappers }) => { - const lockPath = join(home, "codex-shim.autorestore.lock"); - const oldWrappers = wrappers.map(path => readFileSync(path)); - wrappers.forEach((path, index) => process.platform === "win32" - ? writeFileSync(path, `replacement-${index}\n`, "utf8") : chmodSync(path, 0o644)); - const entries = seedAgedLockEntries(lockPath, [...pids], [...extra]); - - expect(autoRestoreCodexShim({ enabled: () => true, stabilitySleep: skipStabilityWait })).toEqual({ status: "deferred" }); - expect(readdirSync(lockPath).sort()).toEqual(entries); - wrappers.forEach((path, index) => process.platform === "win32" - ? expect(readFileSync(path, "utf8")).toBe(`replacement-${index}\n`) : expect(readFileSync(path)).toEqual(oldWrappers[index])); - }); - }); - } - test("stalled partial write changing during the observation interval is never promoted", () => { withInstalledShim(({ wrappers, backups }) => { if (process.platform !== "win32") { diff --git a/tests/codex-integration/native-claude-code-toggle.test.ts b/tests/codex-integration/native-claude-code-toggle.test.ts index ac45de6a116..e0ae5dffb34 100644 --- a/tests/codex-integration/native-claude-code-toggle.test.ts +++ b/tests/codex-integration/native-claude-code-toggle.test.ts @@ -217,8 +217,6 @@ 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 3ab49309175..fb45ac311c5 100644 --- a/tests/codex-integration/native-codex-toggle.test.ts +++ b/tests/codex-integration/native-codex-toggle.test.ts @@ -20,8 +20,6 @@ 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 = ""; @@ -29,9 +27,6 @@ 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 { @@ -80,19 +75,6 @@ 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") @@ -118,7 +100,6 @@ beforeEach(() => { }); afterEach(() => { - restoreSyncDiscovery(); restoreServiceManagerProbe(); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; @@ -187,7 +168,6 @@ 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/config/config-thinking-display.test.ts b/tests/config/config-thinking-display.test.ts deleted file mode 100644 index dcb3f2e8584..00000000000 --- a/tests/config/config-thinking-display.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getDefaultConfig, validateConfigCandidate } from "../../src/config"; -import type { OcxConfig } from "../../src/types"; - -function withProvider(provider: Record): OcxConfig { - return { - ...getDefaultConfig(), - defaultProvider: "anthropic", - providers: { - anthropic: { - adapter: "anthropic", - baseUrl: "https://api.anthropic.com", - apiKey: "synthetic", - ...provider, - }, - }, - } as OcxConfig; -} - -describe("anthropic thinking display policy config", () => { - test("accepts a model-keyed summarized entry, mirroring other model maps", () => { - const result = validateConfigCandidate(withProvider({ - modelThinkingDisplay: { "claude-opus-5-5": "summarized" }, - })); - expect(result.ok).toBe(true); - if (result.ok) expect(result.config.providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" }); - }); - - test("accepts every documented value at provider and model level", () => { - for (const value of ["auto", "summarized", "updates", "omitted"] as const) { - expect(validateConfigCandidate(withProvider({ thinkingDisplay: value })).ok).toBe(true); - expect(validateConfigCandidate(withProvider({ modelThinkingDisplay: { "claude-opus-5-5": value } })).ok).toBe(true); - } - }); - - test("rejects an unknown provider-level value with a clear message", () => { - const result = validateConfigCandidate(withProvider({ thinkingDisplay: "always" })); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("thinkingDisplay"); - }); - - test("rejects an unknown model-level value with a clear message", () => { - const result = validateConfigCandidate(withProvider({ - modelThinkingDisplay: { "claude-opus-5-5": "verbose" }, - })); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("modelThinkingDisplay"); - }); - - test("rejects a non-object model map and non-string values", () => { - for (const value of [[], "summarized", { "claude-opus-5-5": true }]) { - const result = validateConfigCandidate(withProvider({ modelThinkingDisplay: value })); - expect(result.ok).toBe(false); - } - }); -}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 263a1d61888..58e03d7dd46 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,9 +1,4 @@ -{"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", +{ "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", @@ -355,7 +350,6 @@ "anthropic-cooldown-recovery.test.ts": "adapters/anthropic", "anthropic-empty-content.test.ts": "adapters/anthropic", "anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic", "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-fast-opt-in.test.ts": "adapters/anthropic", - "anthropic-thinking-display-policy.test.ts": "adapters/anthropic", "anthropic-fast-speed.test.ts": "adapters/anthropic", "anthropic-hardening.test.ts": "adapters/anthropic", "anthropic-image-guard.test.ts": "adapters/anthropic", "anthropic-image-normalize.test.ts": "adapters/anthropic", "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", @@ -775,7 +769,6 @@ "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-ownership-concurrent-record.test.ts": "config", "config-ownership-removal.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config", "config-spend-ceilings.test.ts": "config", "config-spend-pool-collision.test.ts": "config", "config-user-edits.test.ts": "config", "hub-disable-live-save.test.ts": "config", - "config-thinking-display.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", "gui-desktop-sidecar-signing.test.ts": "gui", "linux-desktop-packaged-ci.test.ts": "ci-workflows", "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", @@ -862,7 +855,6 @@ "docs-provider-discovery-limits.test.ts": "ci-workflows", "docs-provider-preset-counts.test.ts": "ci-workflows", "docs-readme-memory-inventory.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "docs-remote-hub-claims.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", - "doctor-bun-install.test.ts": "service", "doctor-oauth.test.ts": "service", "doctor-provider-apikey.test.ts": "service", "doctor-service-memory-contract.test.ts": "service", "doctor.test.ts": "codex-integration", "download-cap-default.test.ts": "images", "download-connect-deadline-default.test.ts": "images", @@ -1049,7 +1041,6 @@ "management-provider-reasoning-lists.test.ts": "server", "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-upstream-websocket.test.ts": "server", "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", - "management-provider-thinking-display.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", "protocol-settings-route.test.ts": "server", "protocol-provider-summary.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", @@ -1461,7 +1452,7 @@ "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update", "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", - "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-allow-scripts.test.ts": "update", + "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-orchestrated-session.test.ts": "update", "update-orchestrated-entry.test.ts": "update", "update-orchestrated-apply.test.ts": "update", "update-orchestrated-arguments.test.ts": "update", "update-orchestrated-plan.test.ts": "update", "update-sync-mutex.test.ts": "update", "update-orchestrated-identity.test.ts": "update", "update-orchestrated-token.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update", "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", diff --git a/tests/helpers/cli-capability-data.ts b/tests/helpers/cli-capability-data.ts index 7b68124186b..5275556206b 100644 --- a/tests/helpers/cli-capability-data.ts +++ b/tests/helpers/cli-capability-data.ts @@ -8,7 +8,6 @@ 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", @@ -24,7 +23,6 @@ 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", @@ -36,7 +34,6 @@ 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/lib/bun-runtime-preflight.test.ts b/tests/lib/bun-runtime-preflight.test.ts index adea703eb0a..0c7dea985ff 100644 --- a/tests/lib/bun-runtime-preflight.test.ts +++ b/tests/lib/bun-runtime-preflight.test.ts @@ -23,9 +23,7 @@ function refusal(run: () => void, reason: RuntimePreflightReason): void { expect(error).toBeInstanceOf(RuntimePreflightError); expect(error).toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason }); expect((error as Error).message).toContain("OPENCODEX_BUN_PATH"); - // #8361: the repair command must permit bun's postinstall, or npm 12 leaves the - // bundled runtime as its placeholder and the retry fails the same way. - expect((error as Error).message).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex"); + expect((error as Error).message).toContain("npm install -g @bitkyc08/opencodex"); } } function child(result: Partial>): typeof spawnSync { diff --git a/tests/providers/github-copilot/github-copilot-account-origin.test.ts b/tests/providers/github-copilot/github-copilot-account-origin.test.ts index db62fb00594..e5d704cd9ed 100644 --- a/tests/providers/github-copilot/github-copilot-account-origin.test.ts +++ b/tests/providers/github-copilot/github-copilot-account-origin.test.ts @@ -7,11 +7,9 @@ import { getAccountSet, saveCredential as persistCredential, setActiveAccount as import { handleResponses as dispatchResponses } from "../../../src/server/responses"; import { saveConfig } from "../../../src/config"; import { setActiveProviderApiKey } from "../../../src/providers/api-keys"; -import { clearCopilotAutoModelsForTests } from "../../../src/providers/github-copilot-auto"; import type { OcxConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home"; -import { stubbedProviderFetch } from "../../helpers/catalog-provider-fetch"; const ACCOUNT_A_ORIGIN = "https://a.githubcopilot.com"; const ACCOUNT_B_ORIGIN = "https://b.githubcopilot.com"; @@ -138,9 +136,6 @@ function config(wire: Wire): OcxConfig { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://api.githubcopilot.com", - // Copilot Auto negotiates /models on the pinned outbound transport. Give it the - // fixture executor so the negotiation never resolves a real peer address. - fetch: stubbedProviderFetch, models: [model], ...(wire === "responses" ? { modelAdapters: { [model]: "openai-responses" } } : {}), }, @@ -214,9 +209,8 @@ function installFetch(options: { switchToAccountId?: string; switchOn: "refresh" | "first-dispatch" | "never"; emptyFirst?: boolean; -}): { dispatches: { origin: string; authorization: string }[]; modelsRequests: { origin: string }[] } { +}): { dispatches: { origin: string; authorization: string }[] } { const dispatches: { origin: string; authorization: string }[] = []; - const modelsRequests: { origin: string }[] = []; let refreshSwitched = false; globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); @@ -234,13 +228,6 @@ function installFetch(options: { if (url === GITHUB_USER_URL) return Response.json({ id: 101 }); const parsed = new URL(url); - if (parsed.pathname === "/models") { - modelsRequests.push({ origin: parsed.origin }); - return Response.json({ data: [ - { id: "gpt-4o", model_picker_enabled: true }, - { id: "gpt-5.4", model_picker_enabled: true }, - ] }); - } if (parsed.hostname.endsWith(".githubcopilot.com") || parsed.hostname === "api.githubcopilot.com") { dispatches.push({ origin: parsed.origin, @@ -268,7 +255,7 @@ function installFetch(options: { } return originalFetch(input, init); }) as typeof fetch; - return { dispatches, modelsRequests }; + return { dispatches }; } beforeEach(() => { @@ -288,7 +275,6 @@ beforeEach(() => { )); process.env.OPENCODEX_HOME = home; clearGenericFailoverHealth(); - clearCopilotAutoModelsForTests(); // Dispatches without starting a server, so it takes the spend-journal lease itself. Taken // last because the lease binds the home in effect at the moment it is taken. releaseSpendHome = acquireOwnedSpendHome(); @@ -464,12 +450,6 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.hostname === "api.exa.ai") return Response.json({ results: [] }); if (!url.hostname.endsWith(".githubcopilot.com")) throw new Error("Unexpected fixture request"); - if (url.pathname === "/models") { - return Response.json({ data: [ - { id: "gpt-4o", model_picker_enabled: true }, - { id: "gpt-5.4", model_picker_enabled: true }, - ] }); - } sent.push(new Headers(init?.headers).get("authorization") ?? ""); bodies.push(JSON.parse(String(init?.body))); const delta = sent.length === 1 @@ -500,18 +480,6 @@ describe("GitHub Copilot bearer/origin snapshot atomicity", () => { expect(sends).toBe(0); }); - test("Copilot Auto model negotiation is served by the fixture fetch, never real DNS", async () => { - await seedAccounts(); - const observed = installFetch({ wire: "chat", statuses: [200], switchOn: "never" }); - const response = await handleResponses(request("chat"), config("chat"), { model: "", provider: "" }); - await responseText(response); - expect(response.status).toBe(200); - // The negotiation runs on providerOutboundGet, which resolves and pins a real peer - // address unless the provider hands it an executor. This fixture must see the request. - expect(observed.modelsRequests).toEqual([{ origin: ACCOUNT_A_ORIGIN }]); - expect(observed.dispatches).toEqual([{ origin: ACCOUNT_A_ORIGIN, authorization: bearer("copilot-access-a") }]); - }); - for (const wire of ["chat", "responses"] as const) { test(`${wire} revalidates selection after pacing and before physical dispatch`, async () => { const accounts = await seedAccounts(); diff --git a/tests/providers/github-copilot/github-copilot-auto.test.ts b/tests/providers/github-copilot/github-copilot-auto.test.ts index ef9cf814699..625f2f1c17a 100644 --- a/tests/providers/github-copilot/github-copilot-auto.test.ts +++ b/tests/providers/github-copilot/github-copilot-auto.test.ts @@ -198,37 +198,13 @@ describe("Copilot Auto permissions and negotiation", () => { expect((await resolveCopilotAuto(provider, "named", parsed)).modelId).toBe("named"); expect(calls).toBe(1); }); - for (const cancelAt of ["/models/session", "/models/session/intent"] as const) { - test(`an authority change during ${cancelAt} also falls back instead of failing negotiation`, async () => { - const seen: string[] = []; - let allowed = true; - const provider = { authMode: "oauth", baseUrl: "https://api.githubcopilot.com", fetch: (async (url: string | URL | Request) => { - const path = new URL(String(url instanceof Request ? url.url : url)).pathname; - seen.push(path); - if (path.endsWith("/models")) return Response.json({ data: [{ id: "eligible", model_picker_enabled: false, supported_endpoints: ["/responses"] }] }); - if (path.endsWith("/models/session")) return Response.json({ session_token: "private-session", available_models: ["eligible"], expires_at: Math.floor(Date.now() / 1000) + 3600 }); - return Response.json({ candidate_models: ["eligible"] }); - }) as typeof fetch } as OcxProviderConfig; - // The switch lands just before the named control-plane send. - const beforeSend = () => { - if (seen.length === (cancelAt === "/models/session" ? 1 : 2)) allowed = false; - return allowed; - }; - await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, beforeSend)) - .resolves.toMatchObject({ modelId: "auto", auto: false }); - expect(seen.some(path => path.endsWith(cancelAt))).toBe(false); - }); - } - test("cancellation aborts, while an authority change falls back without a negotiation send", async () => { + test("cancellation and authority guard prevent negotiation sends", async () => { let sends = 0; const provider = { authMode: "oauth", baseUrl: "https://api.githubcopilot.com", fetch: (async () => { sends++; return Response.json({}); }) as typeof fetch } as OcxProviderConfig; await expect(resolveCopilotAuto(provider, "auto", parsed, AbortSignal.abort())).rejects.toThrow(); - // A manual account switch cancels the queued control-plane send. That is not a client - // abort: the caller revalidates the selection and rebuilds before dispatch. - await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, () => false)) - .resolves.toMatchObject({ modelId: "auto", auto: false }); + await expect(resolveCopilotAuto(provider, "auto", parsed, undefined, () => false)).rejects.toThrow(); expect(sends).toBe(0); }); }); diff --git a/tests/providers/kimi-monthly-quota.test.ts b/tests/providers/kimi-monthly-quota.test.ts index 2c6f109beaf..e48d75f6c4a 100644 --- a/tests/providers/kimi-monthly-quota.test.ts +++ b/tests/providers/kimi-monthly-quota.test.ts @@ -35,7 +35,7 @@ afterEach(() => { removeTreeWithRetry(home); }); -function config(authMode: "key" | "oauth" = "key", baseUrl = "https://api.kimi.com/coding/v1"): OcxConfig { +function config(authMode: "key" | "oauth" = "key"): OcxConfig { const name = authMode === "oauth" ? "kimi" : "kimi-code"; return { defaultProvider: name, @@ -43,16 +43,16 @@ function config(authMode: "key" | "oauth" = "key", baseUrl = "https://api.kimi.c [name]: { adapter: "openai-chat", authMode, - baseUrl, + baseUrl: "https://api.kimi.com/coding/v1", ...(authMode === "key" ? { apiKey: "kimi-monthly-fixture-key" } : {}), }, }, } as OcxConfig; } -function mockUsage(payload: unknown, url = "https://api.kimi.com/coding/v1/usages"): void { +function mockUsage(payload: unknown): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - expect(String(input)).toBe(url); + expect(String(input)).toBe("https://api.kimi.com/coding/v1/usages"); expect(init?.redirect).toBe("error"); return Response.json(payload); }) as typeof fetch; @@ -316,47 +316,3 @@ describe("Kimi monthly quota", () => { expect(reports[0]?.quota.customWindows).toEqual([{ label: "Total subscription credits", percent: 60 }]); }); }); - -describe("Kimi regional (kimi.ai) Coding Plan quota (#8332)", () => { - const regional = "https://api.kimi.ai/coding/v1"; - - test("a key-auth provider on api.kimi.ai probes its own origin's /usages", async () => { - // Field shape from public API-key reports against this endpoint: legacy counters beside - // ratio windows that can contradict them. Valid counters win over a zero ratio. - mockUsage({ - usage: { limit: 100, used: 40, remaining: 60, resetTime: resetAt }, - limits: [{ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, detail: { limit: 100, used: 100, remaining: 0, resetTime: resetAt } }], - usages: { limit_5h: { used_ratio: 0 }, limit_7d: { used_ratio: 0 }, limit_month_total: { used_ratio: 0.1, reset_time: resetAt } }, - }, `${regional}/usages`); - - const { reports } = await fetchProviderQuotaReports(config("key", `${regional}/`), true); - - expect(reports).toHaveLength(1); - expect(reports[0]?.source).toBe("kimi:usages"); - expect(reports[0]?.quota).toMatchObject({ fiveHourPercent: 100, weeklyPercent: 40, monthlyPercent: 10 }); - }); - - test.each([ - { name: "an OAuth token on api.kimi.ai", authMode: "oauth" as const, baseUrl: regional }, - { name: "a lookalike host", authMode: "key" as const, baseUrl: "https://api.kimi.ai.example.test/coding/v1" }, - { name: "another path on api.kimi.ai", authMode: "key" as const, baseUrl: "https://api.kimi.ai/v1" }, - ])("$name never receives the credential", async ({ authMode, baseUrl }) => { - if (authMode === "oauth") { - await saveCredential("kimi", { - access: "kimi-regional-fixture-access", - refresh: "kimi-regional-fixture-refresh", - expires: Date.now() + 3600_000, - }); - } - const requested: string[] = []; - globalThis.fetch = (async (input: RequestInfo | URL) => { - requested.push(String(input)); - return Response.json({ usages: { limit_month_total: monthlyRow } }); - }) as typeof fetch; - - const { reports } = await fetchProviderQuotaReports(config(authMode, baseUrl), true); - - expect(requested.filter(url => url.includes("/usages"))).toEqual([]); - expect(reports.some(report => report.source === "kimi:usages")).toBe(false); - }); -}); diff --git a/tests/providers/zcode-client.test.ts b/tests/providers/zcode-client.test.ts index ec18895efa6..86e3e3a27fc 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 canonical intent writes and legacy status/history requests. */ + /** Captures the client-integration requests the alias forwards. */ 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,10 +260,7 @@ describe("ocx zcode CLI alias", () => { method: init?.method ?? "GET", body: typeof init?.body === "string" ? JSON.parse(init.body) : null, }); - 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), { + return new Response(JSON.stringify({ ok: true, message: "done", clients: [] }), { status: 200, headers: { "content-type": "application/json" }, }); @@ -271,25 +268,18 @@ describe("ocx zcode CLI alias", () => { return { deps: { baseUrl: "http://127.0.0.1:10100", fetchImpl }, requests }; } - // 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 () => { + test("enable forwards to the client-integration route with --client zcode", async () => { const { deps, requests } = fakeRuntime(); const code = await handleZcodeCommand(["enable"], deps); expect(code).toBe(0); - expect(requests).toEqual([{ path: "/api/client-integrations/zcode", method: "PUT", body: { enabled: true } }]); + expect(requests.some(r => r.path === "/api/client-integrations/zcode" && r.method === "PUT" && (r.body as { enabled?: boolean }).enabled === true)).toBe(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).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 } }]); + expect(requests.some(r => r.path === "/api/client-integrations/zcode" && r.method === "PUT")).toBe(true); }); test("bare invocation reads status, never writes", async () => { diff --git a/tests/responses/sse-failed-tail.test.ts b/tests/responses/sse-failed-tail.test.ts index b439b3905b5..bc3975cf661 100644 --- a/tests/responses/sse-failed-tail.test.ts +++ b/tests/responses/sse-failed-tail.test.ts @@ -9,8 +9,6 @@ import { readCodexWsStage } from "../../src/server/responses/codex-ws-wire"; import { TERMINAL_REFUSAL_FALLBACK_MESSAGE } from "../../src/lib/errors"; import { TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; import { createOutboundCredentialMask } from "../../src/server/responses/terminal-error-redaction"; -import { pumpResponsesSseToWebSocket, type WsData } from "../../src/server/ws-bridge"; -import type { ServerWebSocket } from "bun"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -449,117 +447,6 @@ describe("relaySseWithFailedTail", () => { expect(doneEvents(out)).toHaveLength(1); expect(out).not.toContain("event: response.failed"); }); - - /** Client relay output plus the terminal the tee inspection branch or eager hooks reported. */ - async function relayAndInspect(mode: "tee" | "eager", raw: string): Promise<{ out: string; reported: string[] }> { - const reported: string[] = []; - const src = sourceStream([raw]); - if (mode === "eager") { - const hooks = { ...parityHooks, onSynthetic: (kind: string) => { reported.push(kind); } }; - return { out: await drain(relaySseEagerBounded(src, new AbortController(), hooks)), reported }; - } - const [client, inspection] = src.tee(); - const log: RequestLogContext = { model: "fixture-model", provider: "openai" }; - const inspected = new Promise(resolve => consumeForInspection(inspection, status => { reported.push(status); }, undefined, resolve, log)); - const out = await drain(relaySseWithFailedTail(client, new AbortController())); - await inspected; - return { out, reported }; - } - - test.each(["tee", "eager"] as const)("%s untyped error envelope ends in response.failed, not adapter_eof (#8216)", async (mode) => { - // Captured gateway shape: heartbeat, an `error` object with no root `type`, DONE, EOF. - const raw = ": PING\n\n" - + 'data: {"error":{"message":"openai_error","type":"bad_response_status_code","param":"","code":"bad_response_status_code"}}\n\n' - + "data: [DONE]\n\n"; - const { out, reported } = await relayAndInspect(mode, raw); - - expect(failedMessage(out)).toBe("openai_error"); - expect(out).toContain('"code":"upstream_server_error"'); - expect(terminalEvents(out)).toEqual(["response.failed"]); - expect(doneEvents(out)).toHaveLength(1); - expect(out).not.toContain("adapter_eof"); - expect(reported).toEqual(["failed"]); - }); - - test.each(["tee", "eager"] as const)("%s typed event carrying an error field is not a bare error (#8216)", async (mode) => { - const raw = 'data: {"type":"response.in_progress","response":{"status":"in_progress"},' - + '"error":{"message":"not a failure","code":"server_error"}}\n\n'; - const { out, reported } = await relayAndInspect(mode, raw); - - expect(terminalEvents(out)).toEqual(["response.incomplete"]); - expect(out).toContain('"reason":"adapter_eof"'); - expect(out).not.toContain("event: response.failed"); - expect(doneEvents(out)).toHaveLength(1); - expect(reported).toEqual(["incomplete"]); - }); - - test.each(["tee", "eager"] as const)("%s untyped refusal code without a message reports failed on both readers (#8216)", async (mode) => { - // The client tail already carried the refusal; tee inspection used to read only the message and - // reported `incomplete`, which account health counts as a non-failure. - const raw = 'data: {"error":{"code":"invalid_prompt"}}\n\n'; - const { out, reported } = await relayAndInspect(mode, raw); - - expect(terminalEvents(out)).toEqual(["response.failed"]); - expect(out).toContain('"code":"invalid_prompt"'); - expect(failedMessage(out)).toBe(TERMINAL_REFUSAL_FALLBACK_MESSAGE); - expect(reported).toEqual(["failed"]); - }); - - test.each([ - ["tee", "untyped"], ["eager", "untyped"], ["tee", "typed"], ["eager", "typed"], - ] as const)("%s %s error with a code but no message still ends in response.failed", async (mode, shape) => { - const raw = shape === "untyped" - ? 'data: {"error":{"code":"bad_response_status_code"}}\n\n' - : 'data: {"type":"error","code":"bad_response_status_code"}\n\n'; - const { out, reported } = await relayAndInspect(mode, raw); - - expect(terminalEvents(out)).toEqual(["response.failed"]); - expect(out).not.toContain("adapter_eof"); - expect(out).toContain('"code":"upstream_server_error"'); - // The upstream's own code is the only diagnostic it sent; keep it in the message. - expect(failedMessage(out)).toContain("bad_response_status_code"); - expect(doneEvents(out)).toHaveLength(1); - expect(reported).toEqual(["failed"]); - }); - - test.each(["tee", "eager"] as const)("%s untyped cyber_policy error is rewritten as the policy terminal (#8216)", async (mode) => { - const raw = 'data: {"error":{"type":"invalid_request_error","code":"cyber_policy","message":"blocked by policy"}}\n\n' - + 'data: {"type":"response.output_text.delta","delta":"late"}\n\n'; - const { out, reported } = await relayAndInspect(mode, raw); - - expect(terminalEvents(out)).toEqual(["response.failed"]); - expect(out).not.toContain('data: {"error":'); - expect(out).not.toContain("late"); - expect(failedMessage(out)).toBe("blocked by policy"); - expect(out).toContain('"code":"cyber_policy"'); - expect(out).toContain('"retryable":false'); - expect(doneEvents(out)).toHaveLength(1); - // The rewritten frame is a real terminal: tee inspection reports it, and eager synthesizes none. - expect(reported).toEqual(mode === "tee" ? ["failed"] : []); - }); - - test.each(["tee", "eager"] as const)("%s untyped error envelope reaches a WebSocket client as response.failed (#8216)", async (mode) => { - const raw = 'data: {"type":"response.created","response":{"id":"r1","status":"in_progress"}}\n\n' - + 'data: {"error":{"message":"openai_error","code":"bad_response_status_code"}}\n\n' - + "data: [DONE]\n\n"; - const src = sourceStream([raw]); - const relayed = mode === "tee" - ? relaySseWithFailedTail(src, new AbortController()) - : relaySseEagerBounded(src, new AbortController(), parityHooks); - const sent: string[] = []; - const terminals: string[] = []; - const ws = { - readyState: 1, - data: {} as WsData, - send(message: string) { sent.push(message); return 1; }, - } as unknown as ServerWebSocket; - await pumpResponsesSseToWebSocket(ws, relayed, { onTerminal: status => { terminals.push(status); } }); - - const types = sent.map(frame => (JSON.parse(frame) as { type?: unknown }).type); - expect(types).toEqual(["response.created", "response.failed"]); - expect((JSON.parse(sent[1]!) as { response: { error: { message: string } } }).response.error.message).toBe("openai_error"); - expect(terminals).toEqual(["failed"]); - }); }); diff --git a/tests/routing/combo-stream-preflight.test.ts b/tests/routing/combo-stream-preflight.test.ts index 5966272c2ef..fc2c62702fa 100644 --- a/tests/routing/combo-stream-preflight.test.ts +++ b/tests/routing/combo-stream-preflight.test.ts @@ -379,18 +379,6 @@ describe("combo stream preflight", () => { } }); - test("commits an untyped error envelope instead of advancing the combo (#8216)", async () => { - // Not a control event, so it is never resent to another target; the relay reports it as failed. - const source = sse( - { type: "response.created", response: { id: "r1", status: "in_progress" } }, - { error: { message: "openai_error", code: "bad_response_status_code" } }, - ); - const expected = await source.clone().text(); - const result = await preflightComboStreamResponse(source, { model: "m1", provider: "a" }); - expect(result.kind).toBe("accepted"); - expect(await result.response.text()).toBe(expected); - }); - test("passes a zero-output credential error to the ordinary combo classifier", async () => { const result = await preflightComboStreamResponse(sse({ type: "error", diff --git a/tests/routing/route-to-child-defaults.test.ts b/tests/routing/route-to-child-defaults.test.ts deleted file mode 100644 index 60e846d20c4..00000000000 --- a/tests/routing/route-to-child-defaults.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -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 deleted file mode 100644 index 12c44711b25..00000000000 --- a/tests/routing/route-to-home-decisions.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -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); diff --git a/tests/routing/route-to-home-live-flags.test.ts b/tests/routing/route-to-home-live-flags.test.ts deleted file mode 100644 index 4e8917df599..00000000000 --- a/tests/routing/route-to-home-live-flags.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -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 deleted file mode 100644 index 790e373c1b0..00000000000 --- a/tests/routing/route-to-home-native-truth.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -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 deleted file mode 100644 index 192d34c3757..00000000000 --- a/tests/routing/route-to-home-off-review.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 73677b3ab01..00000000000 --- a/tests/routing/route-to-sync-child.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 048dfe8f089..33d28690cf0 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 { existsSync, mkdtempSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; @@ -11,7 +11,6 @@ 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; @@ -199,9 +198,6 @@ 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 30b9e503cfc..acaab8848eb 100644 --- a/tests/server/link-enrollment.test.ts +++ b/tests/server/link-enrollment.test.ts @@ -10,8 +10,6 @@ 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; @@ -32,7 +30,7 @@ const result = (stdout = "", code = 0): SshRunResult => ({ code, stdout, stderr: const http = (body: unknown, status = 200) => result(`${JSON.stringify(body)}\n${status}`); const health = (role: string, restartCapability: string | null = "v1") => http({ service: "opencodex", pid: 4321, port: 10100, role, ...(restartCapability === null ? {} : { restartCapability }) }); type RunOptions = Parameters[1]; -type Step = "probe" | "uname" | "windows" | "fingerprint" | "version" | "port" | "config" | "validate" | "curl" | "before" | "connect" | "restart" | "health" | "catalog"; +type Step = "probe" | "uname" | "windows" | "fingerprint" | "version" | "port" | "config" | "curl" | "before" | "connect" | "restart" | "health" | "catalog"; interface Call { step: Step; argv: readonly string[]; options: RunOptions; } function deferred() { let resolve!: (value: T) => void; @@ -92,7 +90,6 @@ function harness(remotePlatform: "posix" | "windows" = "posix") { : command === remoteOcx(["--version"]) ? "version" : command === remoteOcx(["link", "port"]) ? "port" : command === remoteOcx(["config", "show", "--json"]) ? "config" - : command === remoteOcx(["config", "validate", "--json"]) ? "validate" : command.includes("ocx-curl-preflight") ? "curl" : (nativeArgs?.[0] === "connect" || command.includes("'connect' '--link' '--key-stdin'")) ? "connect" : command === remoteOcx(["restart"]) ? "restart" @@ -124,7 +121,6 @@ function harness(remotePlatform: "posix" | "windows" = "posix") { case "version": return result("opencodex 2.83.0\n"); case "port": return result('{"port":2200}'); case "config": return result('{"port":10100}\n'); - case "validate": return { code: 1, stdout: "", stderr: "Unknown command: config validate\n" }; case "curl": case "connect": return result(); case "before": return health("standalone"); case "restart": return result(); @@ -210,20 +206,6 @@ 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" }); @@ -348,35 +330,6 @@ describe("Home enrollment of a standalone Child", () => { expect(config.options?.maxOutputBytes).toBe(SSH_MAX_OUTPUT_BYTES); }); - test("a Child preflight failure carries the Child's own reason (#8357)", async () => { - // The line a real `ocx config show --json` prints for an invalid saved configuration: generic, - // and long enough that the 160-code-point cap cuts it before naming any defect. - const showWarning = "Warning: Config at /var/lib/opencodex-child/Library/Application Support/opencodex/config.json is invalid or unreadable; defaults are being shown for invalid settings. Run: ocx config validate. Inspect: ocx config show --source.\n"; - const h = harness(); h.confirmed(); - h.overrides.config = () => ({ code: 1, stdout: "", stderr: showWarning }); - h.overrides.validate = () => ({ code: 1, stdout: '{\n "ok": false,\n "error": "schema_invalid: defaultProvider: defaultProvider must exist in providers"\n}\n', stderr: "" }); - const payload = await denied(await h.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } }; - expect(payload.error.hint).toBe("Child config is invalid: schema_invalid: defaultProvider: defaultProvider must exist in providers"); - expect(h.calls.find(call => call.step === "validate")?.options?.maxOutputBytes).toBe(16 * 1024); - noEnrollmentEffects(h); - // A Child without `config validate` (ocx-child, an older ocx) keeps the config show reason. - const legacy = harness(); legacy.confirmed(); - legacy.overrides.config = () => ({ code: 1, stdout: "", stderr: "opencodex: invalid config\nError: defaultProvider must exist in providers\n" }); - const third = await denied(await legacy.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } }; - expect(third.error.hint).toBe("Error: defaultProvider must exist in providers"); - // A validate that times out must not mask it either. - const hung = harness(); hung.confirmed(); - hung.overrides.config = () => ({ code: 1, stdout: "", stderr: "Error: defaultProvider must exist in providers\n" }); - hung.overrides.validate = () => { throw new SshRunnerError("timeout", "ssh command timed out"); }; - const fourth = await denied(await hung.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } }; - expect(fourth.error.hint).toBe("Error: defaultProvider must exist in providers"); - const unreadable = harness(); unreadable.confirmed(); - unreadable.overrides.config = () => result("not json\n"); - const second = await denied(await unreadable.call("apply", { alias: "child" }), "child_preflight_failed") as { error: { hint: string } }; - expect(second.error.hint).toContain("config show --json"); - expect(unreadable.calls.some(call => call.step === "validate")).toBe(false); - }); - test("an output limit in Child preflight names the cap instead of failing generically", async () => { const h = harness(); h.confirmed(); h.overrides.config = () => { throw new SshRunnerError("output_limit", `ssh output exceeded ${SSH_MAX_OUTPUT_BYTES} bytes`); }; diff --git a/tests/server/link-listener-lifecycle.test.ts b/tests/server/link-listener-lifecycle.test.ts index 03615f2b390..953870a0e79 100644 --- a/tests/server/link-listener-lifecycle.test.ts +++ b/tests/server/link-listener-lifecycle.test.ts @@ -344,59 +344,4 @@ 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 deleted file mode 100644 index 0977ea8e479..00000000000 --- a/tests/server/link-route-intent.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -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-provider-thinking-display.test.ts b/tests/server/management-provider-thinking-display.test.ts deleted file mode 100644 index bfd1804ee5c..00000000000 --- a/tests/server/management-provider-thinking-display.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Management-boundary coverage for the Anthropic thinking display policy (#8363). - * - * A sibling file rather than an addition to management-provider-validation.test.ts, which - * sits at its file-size ratchet cap (the same reason recorded in d3ca5522db and #4908). - */ -import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; -import { managementFetch as fetch } from "../helpers/management-auth"; -import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { loadConfig, saveConfig } from "../../src/config"; -import { startServer } from "../../src/server"; -import { providerManagementConfigError } from "../../src/server/auth-cors"; -import * as destinationPolicy from "../../src/lib/destination-policy"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; -import type { OcxConfig } from "../../src/types"; - -setDefaultTimeout(60_000); - -const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-management-thinking-display-")); -const previousHome = process.env.OPENCODEX_HOME; - -function seedConfig(): OcxConfig { - return { - port: 0, - hostname: "127.0.0.1", - defaultProvider: "anthropic", - providers: { - anthropic: { - adapter: "anthropic", - baseUrl: "https://api.anthropic.com", - apiKey: "synthetic", - thinkingDisplay: "summarized", - }, - }, - }; -} - -afterEach(() => { - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); -}); - -describe("provider management thinking display policy", () => { - test("shared validator rejects unknown provider and model values", () => { - const provider = { adapter: "anthropic" as const, baseUrl: "https://api.anthropic.com" }; - expect(providerManagementConfigError("anthropic", { ...provider, thinkingDisplay: "summarized" })).toBeNull(); - expect(providerManagementConfigError("anthropic", { ...provider, modelThinkingDisplay: { "claude-opus-5-5": "omitted" } })).toBeNull(); - expect(providerManagementConfigError("anthropic", { ...provider, thinkingDisplay: "always" })).toContain("thinkingDisplay"); - expect(providerManagementConfigError("anthropic", { - ...provider, - modelThinkingDisplay: { "claude-opus-5-5": "verbose" }, - })).toContain("modelThinkingDisplay"); - }); - - test("PATCH merges model entries, clears one with null, and rejects an unknown value", async () => { - mkdirSync(TEST_DIR, { recursive: true }); - process.env.OPENCODEX_HOME = TEST_DIR; - saveConfig(seedConfig()); - const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); - const server = startServer(0); - try { - const patch = (body: unknown) => fetch(new URL("/api/providers?name=anthropic", server.url), { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - - const merged = await patch({ modelThinkingDisplay: { "claude-opus-5-5": "summarized" } }); - expect(merged.status).toBe(200); - expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" }); - - const rejected = await patch({ modelThinkingDisplay: { "claude-opus-5-5": "verbose" } }); - expect(rejected.status).toBe(400); - expect(await rejected.json()).toEqual(expect.objectContaining({ - error: expect.stringContaining("modelThinkingDisplay"), - })); - expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "summarized" }); - - const cleared = await patch({ modelThinkingDisplay: { "claude-opus-5-5": null } }); - expect(cleared.status).toBe(200); - expect(loadConfig().providers.anthropic!.modelThinkingDisplay).toBeUndefined(); - - const clearedProvider = await patch({ thinkingDisplay: null }); - expect(clearedProvider.status).toBe(200); - expect(loadConfig().providers.anthropic!.thinkingDisplay).toBeUndefined(); - } finally { - await server.stop(true); - resolvedError.mockRestore(); - } - }); - - test("same-destination POST keeps a policy the dashboard form cannot send", async () => { - mkdirSync(TEST_DIR, { recursive: true }); - process.env.OPENCODEX_HOME = TEST_DIR; - saveConfig({ - ...seedConfig(), - providers: { - anthropic: { - ...seedConfig().providers.anthropic!, - modelThinkingDisplay: { "claude-opus-5-5": "updates" }, - }, - }, - }); - const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); - const server = startServer(0); - try { - const response = await fetch(new URL("/api/providers", server.url), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "anthropic", - provider: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "synthetic", defaultModel: "claude-opus-5-5" }, - }), - }); - expect(response.status).toBe(200); - const saved = loadConfig().providers.anthropic!; - expect(saved.thinkingDisplay).toBe("summarized"); - expect(saved.modelThinkingDisplay).toEqual({ "claude-opus-5-5": "updates" }); - } finally { - await server.stop(true); - resolvedError.mockRestore(); - } - }); -}); - diff --git a/tests/server/management-route-intent-native-errors.test.ts b/tests/server/management-route-intent-native-errors.test.ts deleted file mode 100644 index 01f93364fc7..00000000000 --- a/tests/server/management-route-intent-native-errors.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -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 deleted file mode 100644 index 32e1894aa43..00000000000 --- a/tests/server/management-route-intent-native-review.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 717192de9d3..1fdf813cb9e 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -66,11 +66,6 @@ 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 deleted file mode 100644 index a25bacdd39c..00000000000 --- a/tests/server/route-intent-watch-boundary.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 57374a66f54..00000000000 --- a/tests/server/route-intent-watch-waiters.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index d0973ea5b24..00000000000 --- a/tests/server/route-to-ephemeral.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 77a5980e91e..00000000000 --- a/tests/server/route-to-live-settings.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 8e9c88556e7..00000000000 --- a/tests/server/route-to-policy-review.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 6a434cdfacb..00000000000 --- a/tests/server/route-to-retirement-review.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 15d295e47ea..00000000000 --- a/tests/server/route-to-routes.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -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/doctor-bun-install.test.ts b/tests/service/doctor-bun-install.test.ts deleted file mode 100644 index 0b054ef8bd0..00000000000 --- a/tests/service/doctor-bun-install.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import * as proxyLiveness from "../../src/server/proxy-liveness"; -import * as bunRuntime from "../../src/lib/bun-runtime"; -import * as installDetection from "../../src/update/install-detection.mjs"; -import { getDefaultConfig } from "../../src/config"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { runDoctor } from "../../src/cli/doctor"; -import { collectBundledBunDiagnostic, formatBundledBunDiagnosticLines } from "../../src/cli/bundled-bun-diagnostic"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { STORE_BUDGET_MS } from "../helpers/test-budget"; - -/** - * Issue #8361: npm 12 blocks lifecycle scripts by default, so `npm install -g` leaves the - * bundled `bun` dependency as its un-downloaded placeholder. The launcher then reports - * "the `bun` dependency is not installed" and only a reinstall that permits bun's own - * postinstall repairs it. Doctor must name that state and the exact command. - * - * The bundled runtime and package-manager detection are faked at their module seams so the - * case exercises `runDoctor`'s real call site rather than a helper in isolation. - */ -describe("doctor bundled Bun install guidance (#8361)", () => { - let home: string; - let codexHome: string; - let previousHome: string | undefined; - let previousCodexHome: string | undefined; - let previousExitCode: typeof process.exitCode; - let logged: string[]; - const restore: Array<() => void> = []; - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), "ocx-doctor-bun-install-")); - codexHome = join(home, "codex"); - mkdirSync(codexHome, { recursive: true }); - previousHome = process.env.OPENCODEX_HOME; - previousCodexHome = process.env.CODEX_HOME; - previousExitCode = process.exitCode; - process.env.OPENCODEX_HOME = home; - process.env.CODEX_HOME = codexHome; - writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: 9, codexAutoStart: false })); - logged = []; - const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(" ")); }); - restore.push(() => log.mockRestore()); - // Doctor's other sections probe upstream health; this fixture must stay offline. - const fetch = spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 503 })); - restore.push(() => fetch.mockRestore()); - const live = spyOn(proxyLiveness, "findLiveProxy").mockResolvedValue({ pid: null, port: 9, hostname: "127.0.0.1", source: "config" }); - restore.push(() => live.mockRestore()); - }); - - afterEach(() => { - for (const cleanup of restore.splice(0).reverse()) cleanup(); - process.exitCode = previousExitCode; - if (previousHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousHome; - if (previousCodexHome === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = previousCodexHome; - if (existsSync(home)) removeTreeWithRetry(home); - }); - - test("an npm install whose Bun postinstall was skipped prints the exact repair command", async () => { - // A skipped postinstall leaves no real binary (bundledBunPath returns null for the - // placeholder stub). The package layout still identifies npm as the owner. - const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue(null); - restore.push(() => bundled.mockRestore()); - const detection = spyOn(installDetection, "detectInstallOwnershipFromPath") - .mockReturnValue({ installer: "npm" }); - restore.push(() => detection.mockRestore()); - - await runDoctor([]); - const report = logged.join("\n"); - - expect(report).toContain("the `bun` dependency is not installed"); - expect(report).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex"); - }, STORE_BUDGET_MS); - - test("a ready bundled runtime does not suggest reinstalling", async () => { - const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue("/opt/ocx/node_modules/bun/bin/bun"); - restore.push(() => bundled.mockRestore()); - - await runDoctor([]); - const report = logged.join("\n"); - - expect(report).toContain("Bundled Bun runtime"); - expect(report).not.toContain("the `bun` dependency is not installed"); - expect(report).not.toContain("npm install -g --allow-scripts=bun"); - }, STORE_BUDGET_MS); - - test("a source checkout prints no empty Bundled Bun runtime heading", async () => { - const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue(null); - restore.push(() => bundled.mockRestore()); - const detection = spyOn(installDetection, "detectInstallOwnershipFromPath") - .mockReturnValue({ installer: "source" }); - restore.push(() => detection.mockRestore()); - - await runDoctor([]); - - expect(logged.join("\n")).not.toContain("Bundled Bun runtime"); - }, STORE_BUDGET_MS); - - test("a Windows npm install stops the proxy before reinstalling, since a live bun.exe holds bin", () => { - // Windows fails the manual reinstall with EBUSY while the service's bun.exe is running. - // `ocx update` handles this itself; the printed instruction has to match what it does. - const diagnostic = collectBundledBunDiagnostic({ - platform: "win32", - bundledPath: () => null, - detectOwnership: () => ({ installer: "npm" }), - }); - const lines = formatBundledBunDiagnosticLines(diagnostic).join("\n"); - - expect(lines).toContain("the `bun` dependency is not installed"); - expect(lines.indexOf("ocx stop")).toBeLessThan(lines.indexOf("npm install -g --allow-scripts=bun")); - expect(lines).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex"); - expect(lines).toContain("ocx service restart"); - }); -}); diff --git a/tests/service/service-disconnected-client-restart.test.ts b/tests/service/service-disconnected-client-restart.test.ts deleted file mode 100644 index 8caffb42ed9..00000000000 --- a/tests/service/service-disconnected-client-restart.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -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", 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"); }, - }); - if (mode === "foreign" || mode === "pending") await expect(invoke()).rejects.toThrow(); - else 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 refuses a PID absent from this home's runtime record", async () => { - let touched = false; - const read = spyOn(processState, "readRuntimePort").mockReturnValue(null); - try { - await expect(prepareDisconnectedClientServiceRestart({ - disconnected: () => true, findLive: async () => ({ pid: 42, role: "client" }), - alive: () => true, stopManager: () => { touched = true; }, stop: async () => { touched = true; }, - })).rejects.toThrow("ownership changed"); - 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 deleted file mode 100644 index 6eaef8b788d..00000000000 --- a/tests/service/service-restart-registration-guard.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 6562c492509..00000000000 --- a/tests/service/service-scheduler-restart-cleanup.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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"); }, - 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", "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", "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"]); - }); -}); - -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"]); - }); -}); diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts index 962a42405f7..fe4780a2aa4 100644 --- a/tests/update/update-job.test.ts +++ b/tests/update/update-job.test.ts @@ -1836,8 +1836,7 @@ describe("GUI update execution decisions", () => { describe("immutable update target (WP160)", () => { test("a resolved version pins the install target instead of the movable tag", () => { expect(updateCommand("bun", "latest", "2.7.24").args).toEqual(["add", "-g", "@bitkyc08/opencodex@2.7.24"]); - // npm 12 blocks dependency lifecycle scripts by default; see update-npm-allow-scripts.test.ts. - expect(updateCommand("npm", "latest", "2.7.24").args).toEqual(["install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@2.7.24"]); + expect(updateCommand("npm", "latest", "2.7.24").args).toEqual(["install", "-g", "@bitkyc08/opencodex@2.7.24"]); expect(updateCommandStr("bun", "latest", "2.7.24")).toContain("@bitkyc08/opencodex@2.7.24"); // Unknown version falls back to the tag (best-effort lane). expect(updateCommand("bun", "latest").args).toEqual(["add", "-g", "@bitkyc08/opencodex@latest"]); diff --git a/tests/update/update-npm-allow-scripts.test.ts b/tests/update/update-npm-allow-scripts.test.ts deleted file mode 100644 index 11d577234a1..00000000000 --- a/tests/update/update-npm-allow-scripts.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { updateCommand, updateCommandStr } from "../../src/update/index"; -import { repoPath } from "../helpers/repo-root"; - -/** - * Issue #8361: npm 12 blocks dependency lifecycle scripts by default, so an `ocx update` that - * ran a bare `npm install -g` removed the old files and installed a package whose bundled `bun` - * dependency never unpacked. The next `ocx` then failed with "the `bun` dependency is not - * installed". Every path that builds an npm install argument must approve exactly that one script. - * - * The flag is not conditional on the npm major: npm 8 through 11 all install with it on a global - * install (some npm 11 releases warn that it is unknown to the CLI config, then install). - * A version gate would add a failure mode that depends on which npm happened to be first on PATH. - */ -describe("npm update allows bun's postinstall (#8361)", () => { - test("the update command passes --allow-scripts=bun before the package spec", () => { - expect(updateCommand("npm", "latest", "2.7.24")).toEqual({ - bin: "npm", - args: ["install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@2.7.24"], - }); - // The tag fallback (registry resolution unavailable) keeps the same approval. - expect(updateCommand("npm", "preview", null).args).toEqual([ - "install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@preview", - ]); - expect(updateCommandStr("npm", "latest", "2.7.24")) - .toBe("npm install -g --allow-scripts=bun @bitkyc08/opencodex@2.7.24"); - }); - - test("the other managers keep their own build approval instead of npm's flag", () => { - expect(updateCommand("pnpm", "latest", "2.7.24").args).toEqual([ - "add", "-g", "--allow-build=bun", "@bitkyc08/opencodex@2.7.24", - ]); - expect(updateCommand("bun", "latest", "2.7.24").args).toEqual([ - "add", "-g", "@bitkyc08/opencodex@2.7.24", - ]); - }); - - test("the Node launcher's own npm reinstall carries the same approval", () => { - // The launcher is plain Node and runs before this module loads, so it repeats the argv. - // Source-pinned because loading it executes the CLI. - const launcher = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); - expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]'); - // The manual-update failure hint and the fatal runtime message name the same command. - expect(launcher).toContain('"npm install -g --allow-scripts=bun @bitkyc08/opencodex"'); - expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${latest || tag}`]'); - }); -});