diff --git a/devlog/_plan/260927_merge_train_3/030_batch3.md b/devlog/_plan/260927_merge_train_3/030_batch3.md new file mode 100644 index 00000000000..31a9ed95bbb --- /dev/null +++ b/devlog/_plan/260927_merge_train_3/030_batch3.md @@ -0,0 +1,62 @@ +# B3 — quota activation, update launcher, link join, settings reads, account selection, combo exhaustion + +Base: `dev` `06d7914e6a` (after B2 #6061). Branch `codex/train3-b3`. + +Previous D (B1+B2): both batches landed with exact-head CI (35 success, 5 path-skipped each). Direction kept: serialized +carries with review fixes as separate commits. Lesson: build only inside B, after A. + +| Item | Author | Plan | Fixes to fold | +|---|---|---|---| +| #6049 | luvs01 | Carry. Bounded, non-blocking read of the global Codex config on the settings poll path. | Layout registries: union with #6048's compaction. | +| #6037 | luvs01 | Carry. `systemd-run` resolves only from trusted root-owned paths, probed off the event loop. | `src/update/job.ts` import conflict: keep both imports (file lands at 1999 of 2000 lines). | +| #6042 | luvs01 | Carry. The Remote Link join key leaves only after the tunnel's listener ownership is proven twice. | None required; the connect-phase race stays documented in `structure/remote-link.md` as the PR states. | +| #6020 | terrytan95 | Carry; resolves #6018. Deadline-first quota activation with bounded backoff. | Retry records carry the credential generation, so an old credential's failure cannot hold back a replacement; a local `native main busy` refusal retries in one minute without growing the backoff; drop the duplicate delete. | +| #6056 | luvs01 | Close as superseded by #6020. On dev the retained earliest deadline already starts an idle window once, and #6020 stops the polling. | — | +| #6050 | luvs01 | Carry. `ocx account clear`; an account id `auto` wins over the reserved word; clearing works while main is paused. | Rewrite the dev test that pinned the old 409; revert its unrelated `shadow` default and `strategy` doc hunks; union the layout registries. | +| #5494 | (issue, found through Aside) | Implement. A 429 whose body says the token-plan quota "has been exhausted" is account exhaustion, so the combo target takes the long hold instead of being offered again every 60 s. | Regression test next to the combo exhaustion tests. | + +Held: #6027 (owner's three blockers are still open on a draft head), #6030 and #6003 (drafts), GUI PRs. + +Security-boundary items: #6037 (updater command execution) and #6042 (link join credential) have dedicated Kimi +security reviews with no blocker recorded in this unit. + +## Audit (Kimi, NEAR-PASS) and folded decisions + +- #6020 busy path: a named `NativeMainBusyError`; the retry record keeps its prior `delay` and sets `after = now + 60 s`. +- #6020 `main account unavailable`: stays in the growing backoff. It is keyed by generation, so a token that + arrives later starts clean. +- #6020 both retry maps (`retryAfterByAccount`, `quotaRefreshAfterByAccount`) carry the credential generation; a + record from another generation is dropped when read. The generation is captured before `warm()`/`refresh()` and a + failure is not recorded when it changed during the await. The second same-tick `hasScheduledWindows` delete goes, + because the generation check covers it and a leftover metadata backoff cannot gate a scheduled account. +- #6020 tests: replacement during the await, repeated busy refusal then release, reauth then rotation. +- #5494: the regex is anchored to the token-plan phrasing, + `/usage limit (?:has been )?reached|token-plan\s+\S+\s+quota has been exhausted/`, with a negative case for + "quota exhausted for this minute". The hold is the existing ten-minute exhaustion cap, not the announced reset. + +## Build and evidence + +| Commit | What | +|---|---| +| `c79fe409c6` | #6049 (layout registries unioned) | +| `33b1920cce` | #6042 | +| `b697756cdb` | #6037 (`job.ts` import conflict: both imports kept; 1999 lines) | +| `8e08f26f86` | #6020 | +| `2a49525f1d` | #6020 review fix: generation-keyed retries, flat one-minute retry on `NativeMainBusyError`, three regression tests in `codex-quota-auto-refresh-generation.test.ts` (all three fail without the fix) | +| `9fdc0c4582` | #5494: token-plan exhaustion takes the ten-minute hold; positive and per-minute negative tests (the positive fails without the fix) | +| `cafe6202ad` | #6050 (the dev test pinning the old paused-main 409 on clear is removed; the new file covers the contract) | + +Kimi's note that #6050 regressed the `shadow` defaults and the Kiro-only `strategy` note came from diffing against +an older base; the squash onto current `dev` changes only the account-selection lines in the eight locales. + +Security receipts: #6042 dedicated review, BLOCKER no (connect-phase race stays documented, as the PR states). #6037 +review found no blocking defect; the updater launcher now trusts only root-owned absolute paths, and Ingwannu's +earlier CHANGES_REQUESTED findings (lexical ancestors, synchronous probes) are fixed at the carried head. + +Aside: #6037 still shows one CHANGES_REQUESTED review and #6020 two, both from earlier heads; this batch answers +#6020's findings in `2a49525f1d`. #5494's page shows the reporter's two messages and no maintainer reply; the fix +covers the part the repository can prove (the 60-second re-offer). Why the official DeepSeek stream ended early needs +the reporter's logs. + +Local proof at `cafe6202ad`: typecheck, structure and privacy exit 0; 13 focused files 619 pass, 3 skip, 0 fail; +combo failover files 297 pass; layout and ratchet guards 27 pass. diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index 9282cf080f0..3fb8774a8ce 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -105,12 +105,13 @@ Répertoriez et changez de compte de fournisseur et de pools de clés API via le la surface est : ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them). history openai [--limit <1-200>] Recent routing decisions for one Codex pool account. current Show the active account or key. -use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection. +use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection unless an account carries that id. +clear Clear the manual Codex account selection unconditionally. refresh Force-refresh Codex or provider quota reports. auto-switch Control the Codex pool threshold. alias Set or clear an account's display name; '-' clears it. @@ -192,7 +193,7 @@ cet état et quitte toujours 0. `--json` renvoie : ### `ocx account use [--json]` -`auto` efface la sélection manuelle pour que le pool place à nouveau le travail selon sa propre stratégie. Un compte Codex peut être désigné par l'alias défini avec `ocx account alias` au lieu de son id ; cela vaut aussi pour `priority`, `pause`, `resume`, `clear-cooldown`, `remove` et `alias`. Pour les comptes Codex, `auto`, `main` et `__main__` sont réservés sans distinction de casse et ne peuvent pas être attribués comme alias. Les noms affichés des comptes OAuth et des clés API conservent leurs règles existantes. +`auto` efface la sélection manuelle pour que le pool place à nouveau le travail selon sa propre stratégie — sauf si un compte Codex porte littéralement l'id `auto`, qui l'emporte par correspondance exacte d'id ; `ocx account clear ` rétablit toujours la sélection automatique. Un compte Codex peut être désigné par l'alias défini avec `ocx account alias` au lieu de son id ; cela vaut aussi pour `priority`, `pause`, `resume`, `clear-cooldown`, `remove` et `alias`. Pour les comptes Codex, `auto`, `main` et `__main__` sont réservés sans distinction de casse et ne peuvent pas être attribués comme alias. Les noms affichés des comptes OAuth et des clés API conservent leurs règles existantes. Sélectionne un compte Codex, un compte OAuth ou une clé API existant. Pour `openai`, `main` sélectionne la connexion Codex App. Une sélection en mode Codex Pool efface l'affinité locale du processus et s'applique à la requête suivante, @@ -213,6 +214,10 @@ faire basculer la requête vers un autre compte de pool admissible. Ces transiti { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +Efface la sélection manuelle du compte Codex sans résoudre d'id de compte, donc fonctionne même lorsqu'un compte s'appelle littéralement `auto`. Pools Codex uniquement ; les autres types de fournisseur n'ont pas de sélection automatique à rétablir. + ### `ocx account refresh [--json]` Pour le groupe de comptes Codex, utilisez `ocx account refresh openai [--json]`. Cette commande force l'actualisation des quotas de compte et diff --git a/docs-site/src/content/docs/getting-started/how-it-works.mdx b/docs-site/src/content/docs/getting-started/how-it-works.mdx index c75ffed90e4..76b489f2812 100644 --- a/docs-site/src/content/docs/getting-started/how-it-works.mdx +++ b/docs-site/src/content/docs/getting-started/how-it-works.mdx @@ -51,8 +51,13 @@ account before the request is forwarded upstream. The rule is intentionally spli coalesces simultaneous windows into one request, and durably persists both reset timestamps to prevent duplicate work after restarts. Paused accounts and accounts requiring reauthentication are skipped. Activation captures successful response quota headers; - opted-in idle accounts also refresh stale quota metadata at most once every five minutes, - without needing an open dashboard. Observed reset boundaries are retained across restarts + known reset times are checked locally each minute without periodic quota queries, even when + the cached usage is old or the proxy restarts. Only missing reset times need a metadata query + after the five-minute freshness guard. Unresolved discovery and failed activations retry after + 5, 10, 20, 40, then at most every 60 minutes; these retry delays reset on proxy restart. + Successful response headers seed the next window without an extra query when available. + Dashboard refreshes and optional reset-notification polling remain independent. + Observed reset boundaries are retained across restarts until completed, so a moving idle-window timestamp cannot erase a pending activation. Metadata refresh uses the existing bounded authentication recovery; an inference 401 marks the rejected credential for reauthentication instead of repeatedly spending retries on it. diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 2eb215a1188..479fac0f0da 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -79,12 +79,13 @@ ocx login anthropic 実行中のプロキシを介してプロバイダー アカウントと API キー プールを一覧表示し、切り替えます。出荷されたヘルプ画面は次のとおりです。 ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them). history openai [--limit <1-200>] Recent routing decisions for one Codex pool account. current Show the active account or key. -use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection. +use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection unless an account carries that id. +clear Clear the manual Codex account selection unconditionally. refresh Force-refresh Codex or provider quota reports. auto-switch Control the Codex pool threshold. alias Set or clear an account's display name; '-' clears it. @@ -144,7 +145,7 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account use [--json]` -`auto` は手動の選択を解除し、プールが自身の戦略で再び配置するようにします。Codex アカウントは id の代わりに `ocx account alias` で付けたエイリアスでも指定でき、`priority`、`pause`、`resume`、`clear-cooldown`、`remove`、`alias` でも同様です。Codex アカウントでは `auto`、`main`、`__main__` は大文字・小文字を区別せず予約語として扱われるため、エイリアスとして設定できません。OAuth アカウントと API キーの表示名には従来のルールが適用されます。 +`auto` は手動の選択を解除し、プールが自身の戦略で再び配置するようにします — ただし id が `auto` の Codex アカウントが存在する場合は完全一致の id が優先され、`ocx account clear ` が常に自動選択を復元します。Codex アカウントは id の代わりに `ocx account alias` で付けたエイリアスでも指定でき、`priority`、`pause`、`resume`、`clear-cooldown`、`remove`、`alias` でも同様です。Codex アカウントでは `auto`、`main`、`__main__` は大文字・小文字を区別せず予約語として扱われるため、エイリアスとして設定できません。OAuth アカウントと API キーの表示名には従来のルールが適用されます。 既存の Codex アカウント、OAuth アカウント、または API key を選びます。`openai` で `main` は Codex App ログインを 選択します。Codex Pool の選択は process-local affinity を消去し、既存の表示タスクを含む次のリクエストから適用されます。プロキシ再起動や affinity eviction 後もタスクは未紐付けになり得ますが、処理中のリクエストは取得済みアカウントを維持します。この選択は Pool routing のみを制御し、Direct mode は caller-owned/native main credential を使い続けます。使用量ベースのプロアクティブ切り替え、401/403 再認証、429/retry-after cooldown、除外、出力前 429/402 の障害回復により、後で別の適格 Pool アカウントが選ばれる場合があります。これらの回復経路は使用量ベース切り替えが off でも有効です。アカウント変更後も OpenCodex は会話コンテキストを再生しますが、provider prompt cache は再ウォームアップが必要な場合があります。 @@ -158,6 +159,10 @@ Codex pool selection applies to the next request after clearing existing affinit { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +アカウント id を解決せずに Codex アカウントの手動選択を解除するため、`auto` という id のアカウントが存在しても機能します。Codex プール専用です。他のプロバイダー種別には復元する自動選択がありません。 + ### `ocx account refresh [--json]` Codex プールの場合は、`ocx account refresh openai [--json]` を使用します。アカウント クォータを強制的に更新し、利用可能な週次/月次のパーセンテージとリセット時間を出力します。不足しているクォータ データは、0% ではなく不明として報告されます。その JSON エンベロープは `{ accounts: AccountRow[] }` で、Codex の各行に `quota` があります。 diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 5f213530fab..3a395cca85d 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -164,12 +164,13 @@ Luna 메타데이터임을 표시해 사용합니다. 목록에 보인다는 사 실행 중인 프록시를 통해 제공자 계정과 API 키 풀을 나열하고 전환합니다. 제공되는 도움말 표면은 다음과 같습니다: ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them). history openai [--limit <1-200>] Recent routing decisions for one Codex pool account. current Show the active account or key. -use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection. +use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection unless an account carries that id. +clear Clear the manual Codex account selection unconditionally. refresh Force-refresh Codex or provider quota reports. auto-switch Control the Codex pool threshold. alias Set or clear an account's display name; '-' clears it. @@ -229,7 +230,7 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account use [--json]` -`auto`는 수동 선택을 지워 풀이 다시 자체 전략으로 작업을 배치하게 합니다. Codex 계정은 id 대신 `ocx account alias`로 지정한 별칭으로도 가리킬 수 있으며, `priority`, `pause`, `resume`, `clear-cooldown`, `remove`, `alias`에서도 마찬가지입니다. Codex 계정에서 `auto`, `main`, `__main__`은 대소문자 구분 없이 예약어이므로 별칭으로 지정할 수 없습니다. OAuth 계정과 API 키의 표시 이름에는 기존 규칙이 그대로 적용됩니다. +`auto`는 수동 선택을 지워 풀이 다시 자체 전략으로 작업을 배치하게 합니다 — 단 id가 `auto`인 Codex 계정이 있으면 정확한 id 일치가 우선되며 `ocx account clear `는 항상 자동 선택을 복원합니다. Codex 계정은 id 대신 `ocx account alias`로 지정한 별칭으로도 가리킬 수 있으며, `priority`, `pause`, `resume`, `clear-cooldown`, `remove`, `alias`에서도 마찬가지입니다. Codex 계정에서 `auto`, `main`, `__main__`은 대소문자 구분 없이 예약어이므로 별칭으로 지정할 수 없습니다. OAuth 계정과 API 키의 표시 이름에는 기존 규칙이 그대로 적용됩니다. 기존 Codex 계정, OAuth 계정 또는 API key를 선택합니다. `openai`에서 `main`은 Codex App 로그인을 선택합니다. Codex Pool 선택은 프로세스 로컬 affinity를 지우고 기존에 보이던 작업을 포함한 다음 요청부터 적용됩니다. 프록시 재시작이나 affinity eviction 뒤에도 작업이 바인딩 없는 상태가 될 수 있지만, 진행 중인 요청은 이미 확보한 계정을 유지합니다. 이 선택은 Pool 라우팅만 제어하며 Direct mode는 호출자 소유/native main credential을 계속 사용합니다. 사용량 기반 선제 전환, 401/403 재인증, 429/retry-after cooldown, 제외, 출력 전 429/402 실패 복구는 나중에 다른 적격 Pool 계정을 선택할 수 있습니다. 이러한 복구 경로는 사용량 기반 전환이 꺼져 있어도 동작합니다. 계정이 바뀌어도 OpenCodex는 대화 문맥을 재생하지만 프로바이더 측 prompt cache는 다시 예열해야 할 수 있습니다. @@ -243,6 +244,10 @@ Codex pool selection applies to the next request after clearing existing affinit { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +계정 id를 해석하지 않고 Codex 계정의 수동 선택을 지우므로 `auto`라는 id의 계정이 있어도 동작합니다. Codex 풀 전용이며 다른 공급자 유형에는 복원할 자동 선택이 없습니다. + ### `ocx account refresh [--json]` Codex 풀에는 `ocx account refresh openai [--json]`를 사용합니다. 계정 할당량을 강제로 새로 고치고 사용 가능 주간/월간 비율과 재설정 시간을 출력합니다. 할당량 데이터가 없으면 0%가 아니라 알 수 없음으로 보고합니다. JSON 봉투는 `{ accounts: AccountRow[] }`이며, Codex 행마다 `quota`가 붙습니다. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 9805136a375..93abe731d2e 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -226,12 +226,13 @@ List and switch provider accounts and API-key pools through the running proxy. T surface is: ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them). history openai [--limit <1-200>] Recent routing decisions for one Codex pool account. current Show the active account or key. -use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection. +use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection unless an account carries that id. +clear Clear the manual Codex account selection unconditionally. refresh Force-refresh Codex or provider quota reports. auto-switch Control the Codex pool threshold. alias Set or clear an account's display name; '-' clears it. @@ -420,7 +421,7 @@ that state and still exits 0. `--json` returns: ### `ocx account use [--json]` -`auto` clears the manual selection so the pool places work by its own strategy again. Any Codex account can be named by the alias set with `ocx account alias` instead of its id; that holds for `priority`, `pause`, `resume`, `clear-cooldown`, `remove` and `alias` too. For Codex accounts, `auto`, `main` and `__main__` are reserved regardless of case and cannot be assigned as aliases. OAuth and API-key display names keep their existing rules. +`auto` clears the manual selection so the pool places work by its own strategy again — unless a Codex account literally carries the id `auto`, which wins by exact-id precedence; `ocx account clear ` always restores automatic selection. Any Codex account can be named by the alias set with `ocx account alias` instead of its id; that holds for `priority`, `pause`, `resume`, `clear-cooldown`, `remove` and `alias` too. For Codex accounts, `auto`, `main` and `__main__` are reserved regardless of case and cannot be assigned as aliases. OAuth and API-key display names keep their existing rules. Selects an existing Codex account, OAuth account, or API key. For `openai`, `main` selects the Codex App login. A Codex Pool selection clears process-local affinity and applies to the next request, @@ -441,6 +442,10 @@ rotate the request to another eligible Pool account. These failure transitions r { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +Clear the manual Codex account selection without resolving an account id, so it works even when an account is literally named `auto`. Codex pools only; other provider types have no automatic selection to restore. + ### `ocx account refresh [--json]` For the Codex pool, use `ocx account refresh openai [--json]`. It force-refreshes account quotas and diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 8bf82147136..7339d3f53bb 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -94,12 +94,13 @@ ocx login anthropic Поставляемая help-surface выглядит так: ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them). history openai [--limit <1-200>] Recent routing decisions for one Codex pool account. current Show the active account or key. -use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection. +use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection unless an account carries that id. +clear Clear the manual Codex account selection unconditionally. refresh Force-refresh Codex or provider quota reports. auto-switch Control the Codex pool threshold. alias Set or clear an account's display name; '-' clears it. @@ -175,7 +176,7 @@ credential'а, это состояние тоже печатается, но к ### `ocx account use [--json]` -`auto` снимает ручной выбор, и пул снова распределяет работу по своей стратегии. Аккаунт Codex можно указать по псевдониму, заданному через `ocx account alias`, вместо id; это относится и к `priority`, `pause`, `resume`, `clear-cooldown`, `remove` и `alias`. Для аккаунтов Codex значения `auto`, `main` и `__main__` зарезервированы независимо от регистра и не могут назначаться как псевдонимы. Для отображаемых имён аккаунтов OAuth и API-ключей действуют прежние правила. +`auto` снимает ручной выбор, и пул снова распределяет работу по своей стратегии — если только аккаунт Codex буквально не имеет id `auto`: точное совпадение id выигрывает, а `ocx account clear ` всегда восстанавливает автоматический выбор. Аккаунт Codex можно указать по псевдониму, заданному через `ocx account alias`, вместо id; это относится и к `priority`, `pause`, `resume`, `clear-cooldown`, `remove` и `alias`. Для аккаунтов Codex значения `auto`, `main` и `__main__` зарезервированы независимо от регистра и не могут назначаться как псевдонимы. Для отображаемых имён аккаунтов OAuth и API-ключей действуют прежние правила. Выбирает существующий аккаунт Codex, OAuth-аккаунт или API-ключ. Для `openai` значение `main` выбирает вход Codex App. Выбор Codex Pool очищает process-local affinity и применяется к следующему запросу, включая запрос существующей видимой задачи; после перезапуска прокси или affinity eviction задача также может стать непривязанной, а выполняющиеся запросы сохраняют захваченный аккаунт. Это управляет только Pool routing; Direct mode продолжает использовать caller-owned/native main credential. Проактивное переключение по использованию, повторная аутентификация 401/403, cooldown 429/retry-after, исключение и восстановление после отказа 429/402 до вывода могут позже выбрать другой подходящий Pool-аккаунт. Эти пути восстановления остаются активными, когда переключение по использованию выключено. После смены аккаунта OpenCodex воспроизводит контекст разговора, но prompt cache провайдера может потребовать прогрева. Неизвестные провайдеры @@ -189,6 +190,10 @@ credential'а, это состояние тоже печатается, но к { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +Снимает ручной выбор аккаунта Codex без разрешения id, поэтому работает, даже когда аккаунт буквально называется `auto`. Только для пулов Codex; у других типов провайдеров нет автоматического выбора для восстановления. + ### `ocx account refresh [--json]` Для пула Codex используйте `ocx account refresh openai [--json]`. Команда принудительно diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index a437c1042bb..1e5e797f963 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -113,12 +113,13 @@ Bir sağlayıcı için saklanan OAuth kimlik bilgisini kaldırın. listeleyin ve değiştirin. Sağlanan yardım arayüzü şöyledir: ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them). history openai [--limit <1-200>] Recent routing decisions for one Codex pool account. current Show the active account or key. -use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection. +use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection unless an account carries that id. +clear Clear the manual Codex account selection unconditionally. refresh Force-refresh Codex or provider quota reports. auto-switch Control the Codex pool threshold. alias Set or clear an account's display name; '-' clears it. @@ -207,7 +208,7 @@ yine de 0 ile çıkar. `--json` şunu döndürür: ### `ocx account use [--json]` -`auto` elle yapılan seçimi temizler; havuz işi yeniden kendi stratejisiyle yerleştirir. Bir Codex hesabı, id yerine `ocx account alias` ile verilen takma adla da belirtilebilir; bu `priority`, `pause`, `resume`, `clear-cooldown`, `remove` ve `alias` için de geçerlidir. Codex hesaplarında `auto`, `main` ve `__main__` büyük/küçük harf fark etmeksizin ayrılmış sözcüklerdir ve takma ad olarak atanamaz. OAuth hesaplarının ve API anahtarlarının görünen adları için mevcut kurallar geçerlidir. +`auto` elle yapılan seçimi temizler; havuz işi yeniden kendi stratejisiyle yerleştirir — ancak id'si `auto` olan bir Codex hesabı varsa tam id eşleşmesi kazanır ve `ocx account clear ` her zaman otomatik seçimi geri yükler. Bir Codex hesabı, id yerine `ocx account alias` ile verilen takma adla da belirtilebilir; bu `priority`, `pause`, `resume`, `clear-cooldown`, `remove` ve `alias` için de geçerlidir. Codex hesaplarında `auto`, `main` ve `__main__` büyük/küçük harf fark etmeksizin ayrılmış sözcüklerdir ve takma ad olarak atanamaz. OAuth hesaplarının ve API anahtarlarının görünen adları için mevcut kurallar geçerlidir. Mevcut bir Codex hesabını, OAuth hesabını veya API anahtarını seçer. `openai` için `main` Codex App girişini seçer. Bir Codex Havuzu seçimi süreç içi yerel @@ -234,6 +235,10 @@ ayar yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır. { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +Bir hesap id'si çözümlemeden Codex hesabının elle seçimini temizler; `auto` adında bir hesap olsa bile çalışır. Yalnızca Codex havuzları içindir; diğer sağlayıcı türlerinde geri yüklenecek otomatik seçim yoktur. + ### `ocx account refresh [--json]` Codex havuzu için `ocx account refresh openai [--json]` kullanın. Hesap diff --git a/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx b/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx index d234dd1ff5d..dbd75768d0a 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx +++ b/docs-site/src/content/docs/zh-cn/getting-started/how-it-works.mdx @@ -38,7 +38,11 @@ Codex 使用 OpenAI **Responses API**。opencodex 接收通过 HTTP 与 Server-S 已报告的 5 小时及每周窗口;新添加账号不会自动启用。在 Pool 模式下,窗口到期后会通过对应账号 发送最小化、不保存的请求,并消耗少量额度;同时到期的窗口合并为一次请求。暂停、需要重新认证 的账号会被跳过,主账号硬锁限制也会得到遵守。成功响应的额度头会更新缓存;已启用且符合条件的 - 空闲账号还会每隔至少 5 分钟刷新过期的额度元数据,无需保持仪表盘打开。已观察到的到期时间会保留 + 空闲账号已有重置时间时,每分钟仅在本地检查是否到期,不会因缓存过期或代理重启而定期查询额度。 + 只有缺少重置时间时,才在五分钟新鲜度保护后补查。持续缺失信息或激活失败时,重试间隔依次为 + 5、10、20、40、60 分钟,并以 60 分钟封顶;重试间隔在代理重启后重新计算。 + 成功响应头提供下一轮时间时无需额外查询。仪表盘刷新及可选的重置通知轮询仍独立运行。 + 已观察到的到期时间会保留 至激活完成,重启或后续查询的时间变化不会丢失待处理窗口。元数据查询复用现有的有次数限制的认证 恢复逻辑;推理请求返回 401 时,被拒绝的凭据会标记为需要重新认证。失败日志仅记录不透明账号标签 和安全的状态原因。该功能独立于为传入请求选择账号的路由逻辑。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index de6092ef109..2a102ecec5c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -84,12 +84,13 @@ ocx login anthropic 通过正在运行的代理列出并切换提供方账号和 API 密钥池。随附的帮助输出如下: ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them). history openai [--limit <1-200>] Recent routing decisions for one Codex pool account. current Show the active account or key. -use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection. +use Switch the active credential; 'main' selects the Codex App login, 'auto' clears the selection unless an account carries that id. +clear Clear the manual Codex account selection unconditionally. refresh Force-refresh Codex or provider quota reports. auto-switch Control the Codex pool threshold. alias Set or clear an account's display name; '-' clears it. @@ -159,7 +160,7 @@ OAuth 账号会显示为 `Account N`,而 plan/label 列会在 plan、屏蔽后 ### `ocx account use [--json]` -`auto` 会清除手动选择,让 Pool 重新按自身策略分配工作。Codex 账号可以用 `ocx account alias` 设置的别名代替 id 来指定;`priority`、`pause`、`resume`、`clear-cooldown`、`remove` 和 `alias` 同样如此。对于 Codex 账号,`auto`、`main` 和 `__main__` 为保留字(不区分大小写),不能设为别名。OAuth 账号和 API 密钥的显示名称仍遵循原有规则。 +`auto` 会清除手动选择,让 Pool 重新按自身策略分配工作 — 但如果某个 Codex 账号的 id 恰为 `auto`,则精确 id 匹配优先;`ocx account clear ` 始终恢复自动选择。Codex 账号可以用 `ocx account alias` 设置的别名代替 id 来指定;`priority`、`pause`、`resume`、`clear-cooldown`、`remove` 和 `alias` 同样如此。对于 Codex 账号,`auto`、`main` 和 `__main__` 为保留字(不区分大小写),不能设为别名。OAuth 账号和 API 密钥的显示名称仍遵循原有规则。 选择已有的 Codex 账号、OAuth 账号或 API key。对 `openai` 而言,`main` 选择 Codex App 登录。 Codex Pool 选择会清除进程本地 affinity,并从下一次请求开始生效,包括已有可见任务的请求;代理重启或 affinity eviction 后,任务也可能变为未绑定,但进行中的请求保留已捕获账号。此选择只控制 Pool routing;Direct mode 继续使用 caller-owned/native main credential。基于用量的主动切换、401/403 重新认证、429/retry-after cooldown、排除,以及输出前 429/402 故障恢复之后仍可能选择其他合格 Pool 账号。这些恢复路径在关闭基于用量的切换时仍然有效。账号变化后 OpenCodex 会重放对话上下文,但 provider prompt cache 可能需要重新预热。未知 provider 或 id 返回退出码 1。`--json` 返回: @@ -172,6 +173,10 @@ Codex Pool 选择会清除进程本地 affinity,并从下一次请求开始生 { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +在不解析账号 id 的情况下清除 Codex 账号的手动选择,因此即使存在名为 `auto` 的账号也有效。仅适用于 Codex Pool;其他提供商类型没有可恢复的自动选择。 + ### `ocx account refresh [--json]` 对于 Codex 池,请使用 `ocx account refresh openai [--json]`。它会强制刷新账号配额, diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 59d373ee179..432f525737f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -62,12 +62,13 @@ ocx login anthropic 透過執行中的代理列出並切換供應商帳號與 API-key 池。隨附的說明介面如下: ```text -Usage: ocx account ... +Usage: ocx account ... list [provider] Codex 帳號池、OAuth 帳號與 API 金鑰(識別碼依 API 回傳遮罩顯示)。 history openai [--limit <1-200>] 單一 Codex 帳號池帳號的近期路由決策。 current 顯示現用帳號或金鑰。 -use 切換現用憑證;'main' 選擇 Codex App 登入,'auto' 清除選擇。 +use 切換現用憑證;'main' 選擇 Codex App 登入,'auto' 清除選擇,除非有帳號的 id 恰為此值。 +clear 無條件清除 Codex 帳號的手動選擇。 refresh 強制重新整理 Codex 或供應商配額報告。 auto-switch 控制 Codex 池閾值。 alias 設定或清除帳號顯示名稱;'-' 表示清除。 @@ -126,7 +127,7 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account use [--json]` -`auto` 會清除手動選擇,讓池重新依自身策略分配工作。Codex 帳號可以用 `ocx account alias` 設定的別名代替 id 來指定;`priority`、`pause`、`resume`、`clear-cooldown`、`remove` 與 `alias` 亦然。對於 Codex 帳號,`auto`、`main` 和 `__main__` 為保留字(不區分大小寫),不能設為別名。OAuth 帳號與 API 金鑰的顯示名稱仍遵循原有規則。 +`auto` 會清除手動選擇,讓池重新依自身策略分配工作 — 但若 Codex 帳號的 id 恰為 `auto`,則精確 id 比對優先;`ocx account clear ` 一律還原自動選擇。Codex 帳號可以用 `ocx account alias` 設定的別名代替 id 來指定;`priority`、`pause`、`resume`、`clear-cooldown`、`remove` 與 `alias` 亦然。對於 Codex 帳號,`auto`、`main` 和 `__main__` 為保留字(不區分大小寫),不能設為別名。OAuth 帳號與 API 金鑰的顯示名稱仍遵循原有規則。 選擇既有的 Codex 帳號、OAuth 帳號或 API 金鑰。對於 `openai`,`main` 選擇 Codex App 登入。Codex 池選擇清除行程本地親和性並套用於下一個請求,包含來自既有可見任務的請求;代理重啟或親和性驅逐也可能使任務未綁定,而進行中的請求保留其擷取的帳號。這僅控制池路由;Direct 模式繼續使用呼叫者擁有/原生的 main 憑證。基於用量的主動切換、401/403 重新認證、429/retry-after 冷卻、排除,以及 pre-output 429/402 失敗復原稍後可能選擇另一個合格的池帳號。當基於用量的切換關閉時,這些復原路徑仍然活躍。OpenCodex 在帳號變更後重播對話,但供應商端的 prompt cache 可能是冷的。未知的供應商或 id 離開 1。 在 **401/403** 時,App 登入清除該帳號的行程本地親和性並要求重新認證。 @@ -137,6 +138,10 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 { ok: true, provider, type, activeId } ``` +### `ocx account clear [--json]` + +不解析帳號 id 即清除 Codex 帳號的手動選擇,即使存在名為 `auto` 的帳號仍有效。僅適用於 Codex 池;其他提供者類型沒有可還原的自動選擇。 + ### `ocx account refresh [--json]` 對於 Codex 池,請使用 `ocx account refresh openai [--json]`。它強制重新整理帳號配額並印出可用的週/月百分比與重置時間;缺失的配額資料被回報為未知,而非 0%。其 JSON 封裝為 `{ accounts: AccountRow[] }`,每個 Codex 列上有 `quota`。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e8c6e95aa0d..53bcb676449 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,7 +168,7 @@ } }, "explicit": { - "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", + "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration", "responses-compaction-recovery.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib", "cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server", "desktop-supervised-restart.test.ts": "clients", "cli-restart-handoff.test.ts": "cli", diff --git a/src/cli/account-target.ts b/src/cli/account-target.ts index 00d875f7cf5..e0b433da7e1 100644 --- a/src/cli/account-target.ts +++ b/src/cli/account-target.ts @@ -15,7 +15,7 @@ const MAIN_ALIAS = "main"; export type CodexAccountTarget = | { id: string } - | { error: string; kind: "not_found" | "ambiguous" | "reserved" } + | { error: string; kind: "not_found" | "ambiguous" | "reserved" | "unavailable" } | { networkDown: true; transportError?: string }; /** Built-in selectors cannot be reused as Codex account aliases. */ @@ -35,18 +35,21 @@ export async function resolveCodexAccountTarget( requested: string, ): Promise { if (requested === MAIN_ALIAS || requested === MAIN_CODEX_ACCOUNT_ID) return { id: MAIN_CODEX_ACCOUNT_ID }; - if (isReservedCodexAccountWord(requested)) { - return { error: `"${requested}" is reserved; it clears the selection with \`ocx account use\` and names no account`, kind: "reserved" }; - } const res = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/accounts"); if (res.status === 0) return { networkDown: true, transportError: res.transportError }; // The list is only needed to turn an alias into an id. If the proxy cannot produce it, send // the argument as the id it may already be and let the route answer, as the CLI always did. - if (res.status !== 200) return { id: requested }; + if (res.status !== 200) { + if (!isReservedCodexAccountWord(requested)) return { id: requested }; + return { error: `Cannot safely resolve reserved account selector "${requested}" while the account list is unavailable`, kind: "unavailable" }; + } const accounts = (Array.isArray(res.json.accounts) ? res.json.accounts : []) .filter((entry): entry is { id: string; alias?: unknown } => typeof entry === "object" && entry !== null && typeof (entry as { id?: unknown }).id === "string"); if (accounts.some(account => account.id === requested)) return { id: requested }; + if (isReservedCodexAccountWord(requested)) { + return { error: `"${requested}" is reserved; it clears the selection with \`ocx account use\` and names no account`, kind: "reserved" }; + } const exact = accounts.filter(account => account.alias === requested); const matches = exact.length > 0 ? exact @@ -69,10 +72,12 @@ export async function resolveCodexUseTarget( baseUrl: string, requested: string, ): Promise { - if (requested === AUTO_ACCOUNT_ARGUMENT) return { accountId: null }; const target = await resolveCodexAccountTarget(deps, baseUrl, requested); if ("networkDown" in target) return target; - if ("error" in target) return target; + if ("error" in target) { + if (requested === AUTO_ACCOUNT_ARGUMENT && target.kind === "reserved") return { accountId: null }; + return target; + } return { accountId: target.id }; } diff --git a/src/cli/account.ts b/src/cli/account.ts index 95bcd892eb7..025a1e3f7e1 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -45,6 +45,7 @@ const ACCOUNT_USAGE = `Usage: ocx account history openai [--limit <1-200>] [--json] ocx account current [--json] ocx account use [--json] + ocx account clear [--json] ocx account refresh [--json] ocx account auto-switch > [--json] ocx account alias [--json] @@ -68,8 +69,10 @@ const ACCOUNT_USAGE = `Usage: List and switch provider accounts and API-key pools (masked output only). 'main' selects the Codex App login for the openai account pool; 'auto' clears the -selection so the pool places work by its own strategy. A Codex account can be named -by the alias set with 'ocx account alias' wherever an id is accepted.`; +selection so the pool places work by its own strategy — unless an account actually +carries that id, which wins, so 'ocx account clear' is the spelling that always +clears. A Codex account can be named by the alias set with 'ocx account alias' +wherever an id is accepted.`; function consumeFlag(args: string[], flag: string): boolean { const idx = args.indexOf(flag); @@ -352,6 +355,45 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise { return 0; } +/** `ocx account clear` never resolves its argument as an account id, so an account literally + * named `auto` cannot shadow the verb that returns the pool to automatic selection. */ +async function cmdClear(rest: string[], deps: AccountDeps): Promise { + const wantsJson = consumeFlag(rest, "--json"); + const name = rest.shift(); + const leftover = leftoverArgsError(rest); + if (!name || leftover) { + if (leftover) console.error(leftover); + console.error(ACCOUNT_USAGE); + return 1; + } + const config = deps.loadConfigImpl?.() ?? loadConfig(); + const c = classifyAccount(config, name); + if ("error" in c) { + console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`); + return 1; + } + if (c.type !== "codex") { + console.error(`Error: ${name} has no automatic-selection pin to clear; clear applies to Codex account pools`); + return 1; + } + const baseUrl = await resolveBaseUrl(deps); + if (!baseUrl) return proxyUnreachable(); + const res = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/active", { accountId: null }); + if (res.status === 0) return proxyUnreachable(res.transportError); + if (res.status !== 200) return apiError(res.json, `failed to clear ${name}`, res.status); + const pinDrainReason = typeof res.json.pinDrainReason === "string" ? res.json.pinDrainReason : undefined; + if (wantsJson) { + console.log(JSON.stringify({ + ok: true, provider: name, type: c.type, activeId: null, + ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}), + }, null, 2)); + } else { + console.log(`${name}: automatic account selection (pin cleared)`); + } + await explainCodexUseOutcome(deps, baseUrl, name, null, pinDrainReason); + return 0; +} + export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promise { const [sub, ...rest] = args; try { @@ -362,6 +404,7 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis } if (sub === "current") return await cmdCurrent(rest, deps); if (sub === "use") return await cmdUse(rest, deps); + if (sub === "clear") return await cmdClear(rest, deps); if (sub === "refresh") return await cmdRefresh(rest, deps); if (sub === "auto-switch") return await cmdAutoSwitch(rest, deps); if (sub === "alias" || sub === "rename") return await cmdAlias(rest, deps); diff --git a/src/client/link-join.ts b/src/client/link-join.ts index 5059a4a5515..ad2fe285de9 100644 --- a/src/client/link-join.ts +++ b/src/client/link-join.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { hostname } from "node:os"; import { isPortAvailable } from "../server/ports"; +import { scanListenPidsForAddress, type ListenPidScan } from "../server/port-reclaim"; import { isLinkPort, JOIN_TUNNEL_PORT_MAX, JOIN_TUNNEL_PORT_MIN } from "../link/ports"; import { buildExecArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../link/ssh-argv"; import { sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../link/ssh-runner"; @@ -22,6 +23,7 @@ import type { OcxConnectedClientId } from "../types"; const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000; const JOIN_TUNNEL_POLL_MS = 100; +const JOIN_TUNNEL_SPAWN_GRACE_MS = 100; const JOIN_REVOKE_TIMEOUT_MS = 30_000; const JOIN_CONFIRM_TTL_MS = 5 * 60_000; const JOIN_PORT_ATTEMPTS = 32; @@ -74,6 +76,12 @@ export interface ClientLinkJoinDeps { hostname?: () => string; randomBytes?: (size: number) => Uint8Array; fetchImpl?: typeof fetch; + /** + * LISTEN-owner probe for the tunnel port; defaults to the netstat/lsof/ss scan. + * Receives the loopback address the tunnel binds so listeners on unrelated + * addresses do not confuse the readiness check. + */ + scanListenPids?: (port: number, address?: string) => ListenPidScan; spawnTunnel?: (spec: { linkId: string; alias: string; @@ -222,8 +230,10 @@ async function compensateStaleSidecar(deps: ClientLinkJoinDeps): Promise { } } +/** Wait for the live tunnel's authenticated readiness, retaining the deadline after failed ownership rechecks. */ async function waitForReady( deps: ClientLinkJoinDeps, + tunnel: ClientLinkTunnelHandle, port: number, key: string, ): Promise { @@ -231,13 +241,47 @@ async function waitForReady( const now = deps.now ?? Date.now; const sleep = deps.sleep ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))); const deadline = now() + JOIN_TUNNEL_READY_TIMEOUT_MS; + const tunnelExited = tunnel.exited.then(() => { throw new ClientLinkJoinError("join_tunnel_failed"); }); + await Promise.race([ + tunnelExited, + new Promise(resolve => setTimeout(resolve, JOIN_TUNNEL_SPAWN_GRACE_MS)), + ]); + const listenPids = deps.scanListenPids ?? scanListenPidsForAddress; + // The tunnel binds 127.0.0.1; a listener on a different loopback or interface address + // never receives our requests, so ownership is only judged among sockets that serve it. + const tunnelAddress = "127.0.0.1"; for (;;) { try { - const response = await fetchImpl(`http://127.0.0.1:${port}/readyz`, { - headers: { "x-opencodex-api-key": key }, - }); - if (response.status === 200) return; - if (response.status === 401) throw new ClientLinkJoinError("admission_failed"); + // A squatter answering the 401 challenge would otherwise collect the issued key: + // the only listener allowed a keyed request is the ssh process we spawned — it owns + // the port only after a successful bind, and ExitOnForwardFailure makes it exit when + // it cannot take the port. An unverifiable scan stays "not ready", never a pass. + const ownership = listenPids(port, tunnelAddress); + if (ownership.ok && ownership.pids.length === 1 && ownership.pids[0] === tunnel.pid) { + // Never follow redirects: a port occupant must not reroute the challenge, and a + // redirected keyed request would carry the issued key to an unrelated listener. + const probe = await Promise.race([ + tunnelExited, + fetchImpl(`http://127.0.0.1:${port}/readyz`, { redirect: "manual" }), + ]); + if (probe.status === 401) { + // Ownership can flip between the probe and the keyed request (a squatter + // takes the port after the tunnel dies). Re-scan in the same iteration and + // skip only the keyed request on failure, not the deadline check and sleep. + const recheck = listenPids(port, tunnelAddress); + if (recheck.ok && recheck.pids.length === 1 && recheck.pids[0] === tunnel.pid) { + const response = await Promise.race([ + tunnelExited, + fetchImpl(`http://127.0.0.1:${port}/readyz`, { + headers: { "x-opencodex-api-key": key }, + redirect: "manual", + }), + ]); + if (response.status === 200) return; + if (response.status === 401) throw new ClientLinkJoinError("admission_failed"); + } + } + } } catch (error) { if (error instanceof ClientLinkJoinError) throw error; } @@ -256,6 +300,7 @@ function requireConfirmedHost(deps: ClientLinkJoinDeps, alias: string): JoinConf return confirmed; } +/** Issue and enroll a confirmed Home link, compensating failures before committing the connection. */ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string }): Promise<{ linkId: string; apiKeyId: string }> { const confirmed = requireConfirmedHost(deps, input.alias); await compensateStaleSidecar(deps); @@ -308,7 +353,7 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string configDir: deps.configDir, knownHostsFile: deps.knownHostsFile, }); - await waitForReady(deps, tunnelPort, issued.key); + await waitForReady(deps, tunnel, tunnelPort, issued.key); } catch (error) { const code = error instanceof ClientLinkJoinError ? error.code : "join_tunnel_failed"; await rollback(deps, issued.linkId, tunnel); @@ -316,22 +361,28 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string } try { + if (!tunnel) throw new ClientLinkJoinError("join_tunnel_failed"); const connect = deps.connect ?? connectClient; - await connect({ - serverUrl: `http://127.0.0.1:${tunnelPort}`, - managementUrl: `http://127.0.0.1:${tunnelPort}`, - credential: { kind: "link", apiKeyId: issued.apiKeyId, key: issued.key }, - transport: "link", - link: { tunnelPort, linkId: issued.linkId }, - selectedClients: deps.selectedClients ?? ["codex", "claude"], - managementTransport: "direct", - }, { - fetchImpl: deps.fetchImpl, - ...deps.connectDeps, - }); - } catch { + // Keep watching the tunnel until the connection commits: an exited tunnel + // must not let the issued key ride out to whatever next holds the port. + await Promise.race([ + tunnel.exited.then(() => { throw new ClientLinkJoinError("join_tunnel_failed"); }), + connect({ + serverUrl: `http://127.0.0.1:${tunnelPort}`, + managementUrl: `http://127.0.0.1:${tunnelPort}`, + credential: { kind: "link", apiKeyId: issued.apiKeyId, key: issued.key }, + transport: "link", + link: { tunnelPort, linkId: issued.linkId }, + selectedClients: deps.selectedClients ?? ["codex", "claude"], + managementTransport: "direct", + }, { + fetchImpl: deps.fetchImpl, + ...deps.connectDeps, + }), + ]); + } catch (error) { await rollback(deps, issued.linkId, tunnel); - throw new ClientLinkJoinError("join_connect_failed"); + throw new ClientLinkJoinError(error instanceof ClientLinkJoinError ? error.code : "join_connect_failed"); } await stopTunnel(tunnel); diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index 5ad42d82f61..210dca82c04 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -213,7 +213,7 @@ export async function handleCodexAuthAPI( if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); } - if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { + if (body.accountId != null && isCodexAccountPaused(runtimeConfig, targetAccountId)) { return jsonResponse({ error: "Account is paused" }, 409); } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { diff --git a/src/codex/desktop-switches.ts b/src/codex/desktop-switches.ts index 3bd7384b156..a4b99a85fd8 100644 --- a/src/codex/desktop-switches.ts +++ b/src/codex/desktop-switches.ts @@ -116,14 +116,16 @@ export function describeCodexDesktopSwitches( */ export async function observedCodexDesktopSwitchApply(): Promise { // Same lazy boundary as applyCodexConfigInjection: the ownership predicate lives in the - // injection graph, which the settings read path must not pull in at module scope. - const { currentExternalCodexModelProvider } = await import("./inject/config-toml"); + // injection graph, which the settings read path must not pull in at module scope. This + // path uses the bounded variant — a special or oversized config.toml must answer + // "undetermined", never stall a settings read the way an unbounded readFileSync would. + const { observedExternalCodexModelProvider } = await import("./inject/config-toml"); let provider: string | null; try { - provider = currentExternalCodexModelProvider(); + provider = observedExternalCodexModelProvider(); } catch (error) { - // A present-but-unreadable config.toml (permissions, deletion racing existsSync) - // must not take down the whole settings report. The undetermined reason keeps the + // A present-but-unreadable config.toml (permissions, deletion racing the bounded + // read) must not take down the whole settings report. The undetermined reason keeps the // reporting contract honest: effective values and the sign-in answer stay null instead // of presenting local state a foreign provider may still control. return { diff --git a/src/codex/inject/bounded-config-reader.ts b/src/codex/inject/bounded-config-reader.ts new file mode 100644 index 00000000000..78a7ce95030 --- /dev/null +++ b/src/codex/inject/bounded-config-reader.ts @@ -0,0 +1,69 @@ +import { closeSync, constants, fstatSync, openSync, readSync, statSync, type Stats } from "node:fs"; + +const MAX_CODEX_CONFIG_BYTES = 1024 * 1024; + +/** + * Read config.toml the way Codex and the injector resolve it — a symlink's target IS the + * config — without blocking on a special file or buffering without bound. + * + * `null` means only "absent at the initial lookup". A path that vanishes or is swapped + * underneath the read throws the changed-file error instead, because the observation is + * then undetermined rather than negative: the caller must not report a config the probe + * watched disappear as simply not there. + */ +export function readBoundedCodexConfig(path: string): string | null { + let fd: number | undefined; + try { + let namedBefore: Stats; + try { + namedBefore = statSync(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return null; + throw error; + } + if (!namedBefore.isFile() || namedBefore.size > MAX_CODEX_CONFIG_BYTES) { + throw new Error("config.toml is not a bounded regular file"); + } + // Deliberately no O_NOFOLLOW: Codex and the injector read through a symlinked + // config.toml, so refusing the link here would disagree with the writes this probe + // stands in front of. O_NONBLOCK is what keeps a FIFO — linked or direct — from + // stalling the open; the descriptor checks below still reject anything non-regular. + const guardedFlags = process.platform === "win32" + ? 0 + : (constants.O_NONBLOCK ?? 0); + fd = openSync(path, constants.O_RDONLY | guardedFlags); + const before = fstatSync(fd); + if (before.dev !== namedBefore.dev || before.ino !== namedBefore.ino) { + throw new Error("config.toml changed while it was read"); + } + if (!before.isFile() || before.size > MAX_CODEX_CONFIG_BYTES) { + throw new Error("config.toml is not a bounded regular file"); + } + + const buffer = Buffer.allocUnsafe(before.size + 1); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null); + if (count === 0) break; + bytesRead += count; + } + const after = fstatSync(fd); + const namedAfter = statSync(path); + if (bytesRead !== before.size || after.size !== before.size + || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs + || !namedAfter.isFile() + || namedAfter.dev !== before.dev || namedAfter.ino !== before.ino) { + throw new Error("config.toml changed while it was read"); + } + return buffer.toString("utf8", 0, bytesRead); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new Error("config.toml changed while it was read"); + } + throw error; + } finally { + if (fd !== undefined) closeSync(fd); + } +} diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts index e91c055bb26..761c09549dd 100644 --- a/src/codex/inject/config-toml.ts +++ b/src/codex/inject/config-toml.ts @@ -18,6 +18,7 @@ import { resolveCodexConfigPath, tomlString, } from "../paths"; +import { readBoundedCodexConfig } from "./bounded-config-reader"; import { type CodexRoutingTarget, providerBaseHost, @@ -33,11 +34,28 @@ export function externalCodexModelProvider(content: string): string | null { : null; } +/** + * The ownership answer for read/write paths — inject, sync, connect, restore, and the + * shutdown gate. It deliberately reads the whole file like Codex does (links included): + * a large or link-mediated config is still a valid config, and these callers must + * classify it exactly rather than degrade to "undetermined". + */ export function currentExternalCodexModelProvider(): string | null { if (!existsSync(CODEX_CONFIG_PATH)) return null; return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8")); } +/** + * The same ownership answer for read-only observation (the settings GET / poll path), + * through a bounded read so a special or oversized config.toml cannot stall a request. + * A present-but-unreadable config throws so the caller reports undetermined ownership + * instead of "none". + */ +export function observedExternalCodexModelProvider(): string | null { + const content = readBoundedCodexConfig(CODEX_CONFIG_PATH); + return content === null ? null : externalCodexModelProvider(content); +} + /** * Detect the file's dominant line ending. Every transform in this module is LF-pure * (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 41a11f78649..183f76c4e83 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -5,13 +5,13 @@ import { fstatSync, lstatSync, openSync, - readFileSync, readSync, realpathSync, } from "node:fs"; import path, { dirname, join, resolve } from "node:path"; import { expandUserPath } from "../config"; import { defaultCodexHome } from "./home"; +import { readBoundedCodexConfig } from "./inject/bounded-config-reader"; import { readRootTomlString } from "./paths"; import { truncateRetainedUtf8 } from "../lib/admission"; @@ -53,7 +53,8 @@ function resolveCodexConfigPath(): string { return join(home, "config.toml"); } -export type ProjectCodexConfigIssueCode = "model_providers_table" | "profile_selector" | "model_provider_root"; +export type ProjectCodexConfigIssueCode = "model_providers_table" | "profile_selector" | "model_provider_root" + | "global_config_unreadable"; export interface ProjectCodexConfigWarning { path: string; @@ -265,17 +266,18 @@ export function resolveEffectiveProjectModelProvider(content: string): Effective /** True when global Codex config routes through the opencodex proxy. */ export function isGlobalOpencodexRoutingActive( codexConfigPath: string = resolveCodexConfigPath(), - content?: string, + content?: string | null, ): boolean { let text = content; if (text === undefined) { - if (!existsSync(codexConfigPath)) return false; try { - text = readFileSync(codexConfigPath, "utf-8"); + text = readBoundedCodexConfig(codexConfigPath) ?? undefined; } catch { return false; } + if (text === undefined) return false; } + if (text === null) return false; if (hasInjectedOpenaiBaseUrl(text)) return true; if (readRootTomlString(text, "model_provider") === "opencodex") return true; return false; @@ -379,6 +381,8 @@ export function discoverProjectCodexConfigPaths(options: { cwd?: string; codexConfigPath?: string; maxWalkParents?: number; + /** Explicit null keeps an absent/unreadable observation; undefined permits a fresh read. */ + globalContent?: string | null; } = {}): string[] { const found = new Set(); const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath(); @@ -416,15 +420,16 @@ export function discoverProjectCodexConfigPaths(options: { cwd = parent; } - if (existsSync(codexConfigPath)) { - try { - const global = readFileSync(codexConfigPath, "utf-8"); + try { + const global = options.globalContent === undefined + ? readBoundedCodexConfig(codexConfigPath) : options.globalContent; + if (global !== null) { for (const projectPath of parseTrustedProjectPathsFromCodexConfig(global)) { addIfExists(projectPath); } - } catch { - /* ignore unreadable global config */ } + } catch { + /* ignore unreadable global config */ } return [...found]; @@ -437,10 +442,32 @@ export function collectProjectCodexConfigWarnings(options: { } = {}): ProjectCodexConfigWarning[] { const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath(); const requireRouting = options.requireOpencodexRouting ?? true; - if (requireRouting && !isGlobalOpencodexRoutingActive(codexConfigPath)) return []; + + // The routing question has three answers: active, inactive, and unreadable. An oversized + // or swapped-underneath global config must not silently collapse to "inactive" — that + // would erase both project-bypass coverage and trusted-path discovery without a trace. + let globalContent: string | null = null; + let globalUnreadable = false; + try { + globalContent = readBoundedCodexConfig(codexConfigPath); + } catch { + globalUnreadable = true; + } + if (requireRouting && !globalUnreadable + && !isGlobalOpencodexRoutingActive(codexConfigPath, globalContent)) { + return []; + } const warnings: ProjectCodexConfigWarning[] = []; - for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath })) { + if (globalUnreadable) { + warnings.push({ + path: codexConfigPath, + code: "global_config_unreadable", + detail: "unreadable", + message: "The global Codex config could not be read within the 1 MiB bound — whether it routes through OpenCodex, and which projects it declares trusted, is undetermined.", + }); + } + for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath, globalContent })) { const content = readBoundedProjectConfig(path); if (content !== null) warnings.push(...analyzeProjectCodexConfig(content, path)); } @@ -480,6 +507,8 @@ export function summarizeProjectCodexIssue(warning: ProjectCodexConfigWarning): return warning.profileName ? `profile="${warning.profileName}"` : `model_provider="${warning.detail}"`; case "model_provider_root": return `model_provider="${warning.detail}"`; + case "global_config_unreadable": + return "config.toml unreadable or oversized"; } } @@ -501,6 +530,8 @@ export interface ProjectCodexConfigWarningGroup { path: string; issues: string[]; bypass: string; + /** True when the group is the global-config-unreadable caveat, not a project bypass. */ + globalUnreadable?: boolean; } export function groupProjectCodexConfigWarningsByPath( @@ -512,22 +543,34 @@ export function groupProjectCodexConfigWarningsByPath( list.push(warning); grouped.set(warning.path, list); } - return [...grouped.entries()].map(([path, pathWarnings]) => ({ - path, - issues: pathWarnings.map(summarizeProjectCodexIssue), - bypass: explainProjectConfigBypass(pathWarnings), - })); + return [...grouped.entries()].map(([path, pathWarnings]) => { + const globalUnreadable = pathWarnings.every(warning => warning.code === "global_config_unreadable"); + return { + path, + issues: pathWarnings.map(summarizeProjectCodexIssue), + bypass: globalUnreadable ? pathWarnings[0]!.message : explainProjectConfigBypass(pathWarnings), + ...(globalUnreadable ? { globalUnreadable } : {}), + }; + }); } export function formatProjectCodexConfigWarningsForDoctor(warnings: ProjectCodexConfigWarning[]): string[] { const grouped = groupProjectCodexConfigWarningsByPath(warnings); if (grouped.length === 0) return []; const lines: string[] = []; - for (const { path, issues, bypass } of grouped) { + let hasBypassEntries = false; + for (const { path, issues, bypass, globalUnreadable } of grouped) { lines.push(` -- ${relPath(path)} — ${issues.join(", ")}`); lines.push(` ${bypass}`); + if (globalUnreadable) { + lines.push(" fix: keep the global config.toml a readable regular file within the 1 MiB bound"); + } else { + hasBypassEntries = true; + } + } + if (hasBypassEntries) { + lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project"); } - lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project"); return lines; } @@ -535,11 +578,19 @@ export function formatProjectCodexConfigWarningsForConsole(warnings: ProjectCode const grouped = groupProjectCodexConfigWarningsByPath(warnings); if (grouped.length === 0) return []; const lines = ["⚠️ Project Codex config bypasses OpenCodex:"]; - for (const { path, issues, bypass } of grouped) { + let hasBypassEntries = false; + for (const { path, issues, bypass, globalUnreadable } of grouped) { lines.push(` ${relPath(path)} — ${issues.join(", ")}`); lines.push(` ${bypass}`); + if (globalUnreadable) { + lines.push(" fix: keep the global config.toml a readable regular file within the 1 MiB bound"); + } else { + hasBypassEntries = true; + } + } + if (hasBypassEntries) { + lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project"); } - lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project"); return lines; } diff --git a/src/codex/quota-auto-refresh-state.ts b/src/codex/quota-auto-refresh-state.ts index 75ebe0db64e..921148bb615 100644 --- a/src/codex/quota-auto-refresh-state.ts +++ b/src/codex/quota-auto-refresh-state.ts @@ -2,10 +2,16 @@ /** Completed/due markers use epoch milliseconds; persisted legacy markers may use seconds. */ export type CodexQuotaAutoRefreshWindows = { fiveHour?: number; weekly?: number }; +/** + * Backoff evidence for one account. `generation` names the credential the failure was observed + * under; a record from another generation says nothing about the credential in use now. + */ +export type CodexQuotaRetry = { after: number; delay: number; generation: string }; + export const completedByAccount = new Map(); -export const retryAfterByAccount = new Map(); +export const retryAfterByAccount = new Map(); export const scheduledByAccount = new Map(); -export const quotaRefreshAfterByAccount = new Map(); +export const quotaRefreshAfterByAccount = new Map(); /** Drop every activation record when its account is removed. */ export function forgetCodexQuotaAutoRefreshAccount(accountId: string): void { diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index cf21a46e169..54cd196aac8 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -21,13 +21,14 @@ import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./ import { completedByAccount, retryAfterByAccount, scheduledByAccount, quotaRefreshAfterByAccount, resetCodexQuotaAutoRefreshStateForTests, - type CodexQuotaAutoRefreshWindows, + type CodexQuotaAutoRefreshWindows, type CodexQuotaRetry, } from "./quota-auto-refresh-state"; export type { CodexQuotaAutoRefreshWindows } from "./quota-auto-refresh-state"; export { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; export const FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60; const RETRY_MS = 5 * 60_000; +const MAX_RETRY_MS = 60 * 60_000; const CONCURRENCY = 4; export interface CodexQuotaAutoRefreshStatus { @@ -51,6 +52,57 @@ export interface CodexQuotaAutoRefreshRunDeps { let inFlight: Promise | null = null; +const LOCAL_BUSY_RETRY_MS = 60_000; + +/** The native main profile was claimed locally, so no upstream request was sent. */ +export class NativeMainBusyError extends Error { + constructor() { + super("native main busy"); + this.name = "NativeMainBusyError"; + } +} + +/** The credential a retry record describes: main's quota generation, or the pool record's. */ +function credentialGeneration(accountId: string): string { + return accountId === MAIN_CODEX_ACCOUNT_ID + ? `main:${getMainQuotaCredentialGeneration()}` + : `pool:${readCodexAccountRecord(accountId)?.generation ?? "none"}`; +} + +/** A retry recorded under a replaced credential is spent; drop it rather than hold the new one. */ +function liveRetry( + retries: Map, + accountId: string, + generation: string, +): CodexQuotaRetry | undefined { + const retry = retries.get(accountId); + if (retry && retry.generation !== generation) { + retries.delete(accountId); + return undefined; + } + return retry; +} + +/** Back off unsuccessful discovery/activation without adding another timer. */ +function deferRetry( + retries: Map, + accountId: string, + now: number, + generation: string, +): number { + const delay = Math.min((liveRetry(retries, accountId, generation)?.delay ?? RETRY_MS / 2) * 2, MAX_RETRY_MS); + retries.set(accountId, { after: now + delay, delay, generation }); + return delay; +} + +/** Every enabled window needs a retained, uncompleted deadline, not fresh usage percentages. */ +function hasScheduledWindows(config: OcxConfig, accountId: string): boolean { + const setting = config.codexQuotaAutoRefresh?.[accountId]; + const scheduled = scheduledByAccount.get(accountId); + return (!setting?.fiveHour || scheduled?.fiveHour !== undefined) + && (!setting?.weekly || scheduled?.weekly !== undefined); +} + /** Report upstream window availability separately from persisted spending intent. */ export function codexQuotaAutoRefreshStatus( config: OcxConfig, @@ -187,7 +239,7 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise= RETRY_MS) - && (quotaRefreshAfterByAccount.get(accountId) ?? 0) <= now) { - quotaRefreshAfterByAccount.set(accountId, now + RETRY_MS); - try { await refresh(config, accountId); } catch { /* Retry metadata at the bounded cadence. */ } + // A known deadline remains actionable even when its usage snapshot is old. + // Only discover missing windows; never poll merely to keep percentages fresh. + if (hasScheduledWindows(config, accountId)) { + quotaRefreshAfterByAccount.delete(accountId); + } else if ((!quota || now - quota.updatedAt >= RETRY_MS) + && (liveRetry(quotaRefreshAfterByAccount, accountId, credentialGeneration(accountId))?.after ?? 0) <= now) { + deferRetry(quotaRefreshAfterByAccount, accountId, now, credentialGeneration(accountId)); + try { await refresh(config, accountId); } catch { /* Retry missing metadata with backoff. */ } } if (!eligible(accountId)) return; rememberWindows(config, accountId, quotaFor(accountId)); - if ((retryAfterByAccount.get(accountId) ?? 0) > now) return; + // Backoff from a replaced credential is dropped here, so reauthenticating or rotating an + // account never waits out the failures of the credential it replaced. + const generation = credentialGeneration(accountId); + if ((liveRetry(retryAfterByAccount, accountId, generation)?.after ?? 0) > now) return; const windows = dueCodexQuotaAutoRefreshWindows(config, accountId, quotaFor(accountId), now); if (!windows) return; try { @@ -327,11 +386,23 @@ export async function runCodexQuotaAutoRefresh( persist(config, accountId, completed); rememberWindows(config, accountId, quotaFor(accountId)); } catch (error) { - retryAfterByAccount.set(accountId, now + RETRY_MS); + // A failure that raced a credential replacement describes the old credential; the next + // sweep evaluates the replacement on its own evidence. + if (credentialGeneration(accountId) !== generation) return; + if (error instanceof NativeMainBusyError) { + // Local admission refused before any upstream request: retry soon, and keep the + // upstream backoff where it was instead of doubling it. + const previous = liveRetry(retryAfterByAccount, accountId, generation); + retryAfterByAccount.set(accountId, { + after: now + LOCAL_BUSY_RETRY_MS, delay: previous?.delay ?? RETRY_MS / 2, generation, + }); + return; + } + const delay = deferRetry(retryAfterByAccount, accountId, now, generation); const account = config.codexAccounts?.find(candidate => candidate.id === accountId); const label = account ? codexAccountLogLabel(account) : "main"; console.warn(`[codex-quota-auto-refresh] ${label}: ${codexWarmupFailureReason(error)}; ${ - isAccountNeedsReauth(accountId) ? "reauthentication required" : "retry in five minutes" + isAccountNeedsReauth(accountId) ? "reauthentication required" : `retry in ${delay / 60_000} minutes` }`); } })); diff --git a/src/combos/failover.ts b/src/combos/failover.ts index dac6cdeeefd..da657891bd8 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -326,7 +326,10 @@ function normalizedFailureCode(code?: string | null): string { // are quota-limit codes whose window length this gateway has no evidence for, and guessing long on // them would hold a target that may clear sooner. const ACCOUNT_EXHAUSTION_CODES = new Set(["usage_limit_exceeded", "usage_limit_reached", "1308"]); -const ACCOUNT_EXHAUSTION_TEXT = /usage limit (?:has been )?reached/; +// Token-plan windows (Alibaba's DeepSeek/Qwen plans) report "Your token-plan 1-week quota has been +// exhausted" (#5494). The match is anchored to that phrasing: a looser "quota ... exhausted" would +// also catch per-minute limits, and this arm outranks the transient rate-limit duration. +const ACCOUNT_EXHAUSTION_TEXT = /usage limit (?:has been )?reached|token-plan\s+\S+\s+quota has been exhausted/; function isAccountWindowExhausted(message: string, code?: string | null): boolean { return ACCOUNT_EXHAUSTION_CODES.has(normalizedFailureCode(code)) diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index adf7c639253..45cfee41f76 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -772,7 +772,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise checked, + spawnWorkerFn: (jobId, runChannel, runRestart) => + spawnGuiUpdateWorker(jobId, runChannel, runRestart, { resolveSystemdRun: () => systemdRun }), }) }); } catch (err) { if (err instanceof UpdateJobError) { diff --git a/src/server/port-reclaim.ts b/src/server/port-reclaim.ts index 42fdc2305c2..97827cb60ae 100644 --- a/src/server/port-reclaim.ts +++ b/src/server/port-reclaim.ts @@ -18,6 +18,16 @@ export type ListenPidScan = | { ok: true; pids: number[] } | { ok: false; error?: string }; +/** One listening socket with its bound local address (host part only). */ +export interface ListenEntry { + pid: number; + address: string; +} + +export type ListenEntryScan = + | { ok: true; listeners: ListenEntry[] } + | { ok: false; error?: string }; + export type ReclaimListenPortOptions = WaitForPortOptions & { /** * When true AND `onlyKillPids` is a non-empty allowlist, those PIDs may be @@ -60,12 +70,49 @@ export type ReclaimListenPortOptions = WaitForPortOptions & { sleepMs?: (ms: number) => Promise; }; +/** Split `host:port`/`[v6]:port` on a numeric port boundary; returns the host part. */ +function listenHost(token: string): string { + const bracketed = /^(\[[0-9a-fA-F:.]+\]):/.exec(token); + if (bracketed) return bracketed[1].slice(1, -1).toLowerCase(); + // Only a trailing : is a port; a bare "::" or hostname wildcard has none. + const withPort = /^(.*):(\d+)$/.exec(token); + return (withPort ? withPort[1] : token).toLowerCase(); +} + +/** Normalize a listen-address host: strips brackets and the IPv4-mapped prefix. */ +export function normalizeListenAddress(token: string): string { + let host = listenHost(token); + if (host.startsWith("::ffff:")) host = host.slice(7); + return host; +} + +/** Normalize a bare bind address (no port): drops brackets, keeps bare IPv6 whole. */ +function bareListenAddress(address: string): string { + let host = address.replace(/^\[|\]$/g, "").toLowerCase(); + if (host.startsWith("::ffff:")) host = host.slice(7); + return host; +} + +const WILDCARD_LISTEN_HOSTS = new Set(["", "*", "0.0.0.0", "::"]); + /** - * Parse `netstat -ano` (Windows) / `netstat -anlp` listen lines for a port. - * Exported for unit tests. + * Whether a socket bound to `listenerAddress` also serves connections to `bound` — + * exact match, or a wildcard listener, or a wildcard `bound` (the caller listens on + * every address). IPv4-mapped IPv6 forms of the same address are equalized first. */ -export function parseListenPidsFromNetstat(output: string, port: number): number[] { - const pids = new Set(); +export function listenAddressServes(listenerAddress: string, bound: string): boolean { + const listener = normalizeListenAddress(listenerAddress); + const want = bareListenAddress(bound); + return WILDCARD_LISTEN_HOSTS.has(listener) || WILDCARD_LISTEN_HOSTS.has(want) + || listener === want; +} + +/** + * Parse `netstat -ano` (Windows) / `netstat -anlp` listen lines for a port, keeping + * each distinct PID/address pair. Exported for unit tests. + */ +export function parseListenEntriesFromNetstat(output: string, port: number): ListenEntry[] { + const entries = new Map(); const portSuffix = `:${port}`; for (const rawLine of output.split(/\r?\n/)) { const line = rawLine.trim(); @@ -88,9 +135,66 @@ export function parseListenPidsFromNetstat(output: string, port: number): number : unixPid ? Number(unixPid[1]) : NaN; - if (Number.isSafeInteger(pid) && pid > 0) pids.add(pid); + if (Number.isSafeInteger(pid) && pid > 0) { + const address = normalizeListenAddress(parts[localIdx]); + entries.set(`${pid}|${address}`, { pid, address }); + } } - return [...pids]; + return [...entries.values()]; +} + +/** Parse netstat LISTEN owners, deduplicating PIDs after preserving their addresses. */ +export function parseListenPidsFromNetstat(output: string, port: number): number[] { + return [...new Set(parseListenEntriesFromNetstat(output, port).map(entry => entry.pid))]; +} + +/** + * Parse `ss -Hltnp` rows for a port, keeping each distinct PID/address pair. A row + * without a `pid=` attribution (another user's socket) is dropped rather than + * reported unverifiable. Exported for unit tests. + */ +export function parseListenEntriesFromSs(output: string, port: number): ListenEntry[] { + const entries = new Map(); + const portSuffix = `:${port}`; + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!/^LISTEN\b/i.test(line)) continue; + const parts = line.split(/\s+/); + // LISTEN users:(...) + const localIdx = parts.findIndex(part => part.endsWith(portSuffix) || part.endsWith(`]:${port}`)); + if (localIdx < 0) continue; + const pidMatch = /pid=(\d+)/.exec(line); + const pid = pidMatch ? Number(pidMatch[1]) : NaN; + if (Number.isSafeInteger(pid) && pid > 0) { + const address = normalizeListenAddress(parts[localIdx]); + entries.set(`${pid}|${address}`, { pid, address }); + } + } + return [...entries.values()]; +} + +/** + * Parse `lsof -nP -iTCP: -sTCP:LISTEN` output (without -t), keeping each + * distinct PID/address pair. The NAME column is the last address token, optionally + * followed by `(LISTEN)`; skip the header and nonnumeric PIDs. Exported for tests. + */ +export function parseListenEntriesFromLsof(output: string, port: number): ListenEntry[] { + const entries = new Map(); + const portSuffix = `:${port}`; + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || /^COMMAND\b/.test(line)) continue; + const parts = line.split(/\s+/); + const pid = /^\d+$/.test(parts[1] ?? "") ? Number(parts[1]) : NaN; + if (!Number.isSafeInteger(pid) || pid <= 0) continue; + let addressIdx = parts.length - 1; + if (/^\(.*\)$/.test(parts[addressIdx] ?? "")) addressIdx -= 1; + const address = parts[addressIdx] ?? ""; + if (!address.endsWith(portSuffix) && !address.endsWith(`]:${port}`)) continue; + const normalized = normalizeListenAddress(address); + entries.set(`${pid}|${normalized}`, { pid, address: normalized }); + } + return [...entries.values()]; } function normalizeListenPidScan(result: ListenPidScan | number[]): ListenPidScan { @@ -121,50 +225,84 @@ function readWindowsNetstatAno(): string { } /** - * Scan for PIDs currently LISTENing on `port`. - * Distinguishes probe failure (`ok: false`) from a successful empty result. + * Scan for the sockets currently LISTENing on `port`, with each listener's bound + * local address. Distinguishes probe failure (`ok: false`) from a successful empty + * result. POSIX backends are tried in order — `lsof`, `ss` (iproute2, the only + * scanner on minimal Linux installs), then `netstat` — and a missing scanner falls + * through to the next instead of failing the scan. */ -export function scanListenPids(port: number): ListenPidScan { +export function scanListenEntries(port: number): ListenEntryScan { if (!Number.isFinite(port) || port <= 0 || port > 65535) { return { ok: false, error: "invalid port" }; } + const scanned = Math.trunc(port); try { if (process.platform === "win32") { - return { ok: true, pids: parseListenPidsFromNetstat(readWindowsNetstatAno(), port) }; + return { ok: true, listeners: parseListenEntriesFromNetstat(readWindowsNetstatAno(), scanned) }; } + const errors: string[] = []; try { - const output = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { + const output = execFileSync("lsof", ["-nP", `-iTCP:${scanned}`, "-sTCP:LISTEN"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, }); - return { - ok: true, - pids: output - .split(/\r?\n/) - .map(line => Number(line.trim())) - .filter(pid => Number.isSafeInteger(pid) && pid > 0), - }; - } catch (lsofErr) { - try { - const output = execFileSync("netstat", ["-anlp"], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 3000, - }); - return { ok: true, pids: parseListenPidsFromNetstat(output, Math.trunc(port)) }; - } catch (netstatErr) { - return { - ok: false, - error: `lsof/netstat unavailable: ${String(lsofErr)} / ${String(netstatErr)}`, - }; - } + return { ok: true, listeners: parseListenEntriesFromLsof(output, scanned) }; + } catch (error) { + errors.push(`lsof: ${String(error)}`); + } + try { + const output = execFileSync("ss", ["-Hltnp"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000, + }); + return { ok: true, listeners: parseListenEntriesFromSs(output, scanned) }; + } catch (error) { + errors.push(`ss: ${String(error)}`); + } + try { + const output = execFileSync("netstat", ["-anlp"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000, + }); + return { ok: true, listeners: parseListenEntriesFromNetstat(output, scanned) }; + } catch (error) { + errors.push(`netstat: ${String(error)}`); } + return { ok: false, error: `no listener scanner available (${errors.join(" / ")})` }; } catch (error) { return { ok: false, error: String(error) }; } } +/** + * Scan for PIDs currently LISTENing on `port`. + * Distinguishes probe failure (`ok: false`) from a successful empty result. + */ +export function scanListenPids(port: number): ListenPidScan { + const scan = scanListenEntries(port); + if (!scan.ok) return { ok: false, error: scan.error }; + return { ok: true, pids: [...new Set(scan.listeners.map(entry => entry.pid))] }; +} + +/** + * PIDs LISTENing on `port` that actually serve `address`: listeners bound to that + * exact address plus wildcards (0.0.0.0/::). A listener on a different loopback or + * interface address (e.g. 127.0.0.2 while the tunnel binds 127.0.0.1) never receives + * the connection and must not block or qualify a readiness check. + */ +export function scanListenPidsForAddress(port: number, address = "127.0.0.1"): ListenPidScan { + const scan = scanListenEntries(port); + if (!scan.ok) return { ok: false, error: scan.error }; + const pids = new Set(); + for (const entry of scan.listeners) { + if (listenAddressServes(entry.address, address)) pids.add(entry.pid); + } + return { ok: true, pids: [...pids] }; +} + /** Best-effort PIDs currently LISTENing on `port`. Empty on probe failure. */ export function listListenPids(port: number): number[] { const scan = scanListenPids(port); diff --git a/src/update/job.ts b/src/update/job.ts index fd160f8d568..8f90b0cad32 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -62,6 +62,7 @@ import { } from "./npm-cache-preflight.mjs"; import { guiUpdateWorkerCommand } from "./worker-launch"; import { withoutSiblingMarker } from "../codex/sibling-start"; +import type { WorkerLaunchContext } from "./worker-launch"; const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; @@ -568,6 +569,7 @@ export function spawnGuiUpdateWorker( jobId: string, channel: Channel, restart: boolean, + context: WorkerLaunchContext = {}, ): UpdateWorkerProcess { const args = selfLaunchArgv([ "__gui-update-worker", @@ -576,7 +578,7 @@ export function spawnGuiUpdateWorker( restart ? "restart" : "no-restart", ]); if (process.platform !== "win32") { - const launch = guiUpdateWorkerCommand(process.execPath, args); + const launch = guiUpdateWorkerCommand(process.execPath, args, context); return spawn(launch.command, launch.argv, { detached: true, stdio: "ignore", diff --git a/src/update/worker-launch.ts b/src/update/worker-launch.ts index 89811193e1f..3c964c3b489 100644 --- a/src/update/worker-launch.ts +++ b/src/update/worker-launch.ts @@ -1,4 +1,6 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute } from "node:path"; /** * How to launch the dashboard update worker on POSIX. @@ -16,19 +18,157 @@ export const SYSTEMD_SCOPE_ARGS = ["--user", "--scope", "--quiet", "--collect", export interface WorkerLaunchContext { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; - hasSystemdRun?: () => boolean; + resolveSystemdRun?: () => string | undefined; } -let systemdRunProbe: boolean | undefined; +// Absolute install paths only — PATH is never consulted, so a caller-controlled entry cannot +// redirect the launch. `/usr/local/bin` is where systemd lands when built or stowed outside the +// distro layout, and `/run/current-system/sw/bin` is the NixOS layout, where the binary lives +// nowhere else even though the user bus works. A candidate only counts when the binary and its +// directory are root-owned and not group/world-writable, so a lower-trust local actor cannot +// plant the launcher the scope probe execs. +const TRUSTED_SYSTEMD_RUN_PATHS = [ + "/usr/bin/systemd-run", "/bin/systemd-run", "/usr/local/bin/systemd-run", + "/run/current-system/sw/bin/systemd-run", +] as const; -function probeSystemdRun(): boolean { +export interface SystemdRunHooks { + isExecutableFile: (path: string) => boolean; + probeScope: (path: string) => boolean; + /** Async variant of probeScope; resolveSystemdRunAsync prefers it when present. */ + probeScopeAsync?: (path: string) => Promise; +} + +const GROUP_OR_WORLD_WRITE = 0o022; + +// stat (follow) rather than lstat: a root-owned symlink to a user-writable directory must fail +// on the target's mode, not pass on the symlink's (mirrors isTrustedSystemPath in +// src/codex/desktop-app/linux.ts). +export interface SystemdRunTrustDeps { + /** Test seam: canonicalizes the candidate before its substitution chain is checked. */ + realpathSync?: (path: string) => string; + /** Test seam: stats a resolved path for ownership and mode. */ + statSync?: (path: string) => { isFile(): boolean; uid: number; mode: number }; + /** Test seam: checks the candidate's executable bit. */ + accessSync?: (path: string, mode: number) => void; +} + +function rootOnlyWritable(path: string, stat: SystemdRunTrustDeps["statSync"] = statSync): boolean { + try { + const st = stat!(path); + return st.uid === 0 && (st.mode & GROUP_OR_WORLD_WRITE) === 0; + } catch { + return false; + } +} + +// "Executable" here includes trust: the binary and its directory must be root-owned and not +// group/world-writable. /usr/local/bin is group-writable on some systems, and a planted or +// replaced systemd-run there would be exec'd by the scope probe under the service account; +// the fallback is the plain detached spawn, so nothing breaks when it is skipped. +// Exported for unit tests. +export function isTrustedSystemdRunFile(path: string, deps: SystemdRunTrustDeps = {}): boolean { + try { + if (!isAbsolute(path)) return false; + (deps.accessSync ?? accessSync)(path, constants.X_OK); + // The lexical path may be a symlink. Checking the link's own parent only proves + // the *entry* is pinned; the file it resolves to — and every ancestor able to + // substitute that resolved file — is what the scope probe will actually exec. + const realpath = deps.realpathSync ?? realpathSync; + const resolved = realpath(path); + const stat = deps.statSync ?? statSync; + const st = stat(resolved); + if (!(st.isFile() && st.uid === 0 && (st.mode & GROUP_OR_WORLD_WRITE) === 0)) { + return false; + } + for (const start of [dirname(path), dirname(resolved)]) { + for (let dir = start, previous = ""; dir !== previous; previous = dir, dir = dirname(dir)) { + if (!rootOnlyWritable(dir, stat)) return false; + } + } + return true; + } catch { + return false; + } +} + +/** Scope discovery gets only user-bus identity, never inherited management credentials. */ +function scopeProbeEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" }; + for (const name of ["HOME", "USER", "LOGNAME", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"]) { + if (process.env[name] !== undefined) env[name] = process.env[name]; + } + return env; +} + +const systemdRunHooks: SystemdRunHooks = { + isExecutableFile: isTrustedSystemdRunFile, + // Run a real scope with the same absolute binary as its harmless version payload. + // Probing only the outer --version would not verify the user bus. + probeScope: path => { + const probe = spawnSync(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", timeout: 5_000, env: scopeProbeEnvironment() }); + return !probe.error && probe.status === 0; + }, + probeScopeAsync: path => new Promise(resolve => { + const probe = spawn(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", env: scopeProbeEnvironment() }); + probe.unref(); + const timer = setTimeout(() => { + try { probe.kill("SIGKILL"); } catch { /* failed termination is not a successful probe */ } + resolve(false); + }, 5_000); + timer.unref(); + probe.once("error", () => { clearTimeout(timer); resolve(false); }); + probe.once("close", code => { clearTimeout(timer); resolve(code === 0); }); + }), +}; + +let systemdRunProbe: string | null | undefined; +let systemdRunProbePending: Promise | undefined; + +export function resolveSystemdRun(hooks: SystemdRunHooks = systemdRunHooks): string | undefined { if (systemdRunProbe === undefined) { - // Run a real no-op scope rather than `--version`: a present binary without a reachable user - // bus would otherwise pass the probe and then fail to start the worker at all. - const probe = spawnSync("systemd-run", [...SYSTEMD_SCOPE_ARGS, "true"], { stdio: "ignore", timeout: 5_000 }); - systemdRunProbe = !probe.error && probe.status === 0; + systemdRunProbe = null; + for (const command of TRUSTED_SYSTEMD_RUN_PATHS) { + if (!hooks.isExecutableFile(command)) continue; + if (hooks.probeScope(command)) { + systemdRunProbe = command; + break; + } + } } - return systemdRunProbe; + return systemdRunProbe ?? undefined; +} + +/** + * Management-request path variant. The synchronous resolver blocks the shared + * event loop for up to four sequential five-second scope probes on first use; + * the dashboard update route awaits this instead, so probing overlaps other + * requests. Concurrent first callers share one probe pass. + */ +export async function resolveSystemdRunAsync(hooks: SystemdRunHooks = systemdRunHooks): Promise { + if (systemdRunProbe !== undefined) return systemdRunProbe ?? undefined; + if (!systemdRunProbePending) { + systemdRunProbePending = (async () => { + const probeScope = hooks.probeScopeAsync ?? (async (path: string) => hooks.probeScope(path)); + for (const command of TRUSTED_SYSTEMD_RUN_PATHS) { + if (!hooks.isExecutableFile(command)) continue; + if (await probeScope(command)) { + return command; + } + } + return null; + })(); + } + const found = await systemdRunProbePending; + // Honor a cache the sync resolver may have filled while the probe ran — the + // older observation wins so every caller converges on one launcher. + if (systemdRunProbe === undefined) systemdRunProbe = found; + return systemdRunProbe ?? undefined; +} + +export function resetSystemdRunProbeForTests(): void { + systemdRunProbe = undefined; + systemdRunProbePending = undefined; } export function guiUpdateWorkerCommand( @@ -39,8 +179,9 @@ export function guiUpdateWorkerCommand( const platform = context.platform ?? process.platform; const env = context.env ?? process.env; const underSystemd = platform === "linux" && Boolean(env.INVOCATION_ID); - if (underSystemd && (context.hasSystemdRun ?? probeSystemdRun)()) { - return { command: "systemd-run", argv: [...SYSTEMD_SCOPE_ARGS, execPath, ...args] }; + const systemdRun = underSystemd ? (context.resolveSystemdRun ?? resolveSystemdRun)() : undefined; + if (systemdRun) { + return { command: systemdRun, argv: [...SYSTEMD_SCOPE_ARGS, execPath, ...args] }; } return { command: execPath, argv: [...args] }; } diff --git a/structure/catalog.md b/structure/catalog.md index d0d054768b5..0c79ecbc20c 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -1,5 +1,7 @@ # Model Catalog +Activation-owned metadata discovery no longer refreshes known deadlines merely because quota snapshots age. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation supplies no selected-runtime proof to catalog discovery or publication. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). @@ -593,8 +595,6 @@ Subagent account previews and live routing share the [priority failback](provide Startup and explicit catalog synchronization in `src/codex/sync.ts` refresh the optional `src/providers/reasoning-metadata.ts` effort snapshot for supported destinations before catalog -gathering. Each sync waits at most two seconds for a fresh or shared fetch, then continues with -the existing snapshot; the fetch retains its own abort deadline. Routed effort reads in +gathering. Each sync waits at most two seconds for a fresh or shared fetch, then continues with the existing snapshot; the fetch retains its own abort deadline. Routed effort reads in `src/reasoning-effort.ts` use a snapshot immediately and request a best-effort background refresh -only when an existing snapshot answers with an expired ladder. Missing or corrupt snapshots do -not fetch on the request path; catalog sync owns their bootstrap. +only when an existing snapshot answers with an expired ladder. Missing or corrupt snapshots do not fetch on the request path; catalog sync owns their bootstrap. diff --git a/structure/codex-home.md b/structure/codex-home.md index d14ee6bfcdc..b7dfeb0860d 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -1,5 +1,7 @@ # Codex Home +Quota activation restores deadlines from OpenCodex settings; its retry backoff remains process-local. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). A lock in the Codex credential store is governed by [descriptor identity and age](catalog.md#accounts-namespaces-and-pool-rotation), so the mere presence of its filename is neither acquisition nor release authority. Failed path-identity probes leave the lock for stale recovery and preserve the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. diff --git a/structure/config.md b/structure/config.md index f6acb718130..3ce49bf31f3 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,5 +1,7 @@ # Config Surface +Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. @@ -203,12 +205,12 @@ provider with no name and rejects the whole config rather than one thread, which than the branding it would remove — so a blank, over-length, or control-character value falls back to the default instead of being written. -Read-only doctor and project-routing diagnostics use a lightweight root/table TOML reader rather -than mutating or normalizing the user's file. That reader must lexically skip both basic and literal -multiline string bodies: instruction prose can contain key-shaped examples and `[table]` snippets, -which are data rather than configuration. Diagnostic result objects may retain the real path for -local correlation, but every formatted doctor line must pass it through the shared user-path -redaction boundary before display. +Read-only global ownership/doctor diagnostics follow links only to bounded regular files; an absent +lookup reads as none and an unreadable/changed observation reports undetermined ownership. +Project discovery instead skips links/oversized entries, and its guarded reader skips unsafe files. +Each project-warning collection shares one global snapshot for routing and trusted-path discovery, +including explicit absence or read failure. TOML parsing skips multiline string bodies rather than +reading prose as configuration; formatted doctor paths pass through user-path redaction. > Decision record: [ADR-0017](decisions/ADR-0017-config-injection.md) @@ -589,9 +591,7 @@ being treated as a text model by one and an image target by the other. malformed persisted value is off. `src/config/schema/config-schema.ts` degrades a malformed hand edit to absence so an optional monitoring typo cannot discard providers or credentials. The live-write boundary runs `metricsExportConfigError` in `src/config/diagnostics.ts` before the degrading schema, -so wrong types and unknown nested fields are rejected rather than silently saved. Activation is read -when the server process creates its serve options and therefore requires restart; it adds no setting -to the live `/api/settings` mutation surface. +so wrong types and unknown nested fields are rejected rather than silently saved. Activation is read when the server process creates its serve options and therefore requires restart; it adds no setting to the live `/api/settings` mutation surface. `apiSurfaces` and `protocols` on `src/types/config.ts` are parsed by `src/protocols/settings.ts` only; [Protocol Paths](data-planes/protocol-paths.md#settings) owns their schema handling, meaning and the one writer (`PATCH /api/protocols/settings`), including why closing Messages also writes `claudeCode.enabled` through `commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, the sentinel-stamping block writer every management route uses). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 28c65f4e7a9..be59cb022dd 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,7 @@ # GUI And Management API +Automatic activation retains its existing settings controls; dashboard quota queries remain independent. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + The companion settings contract in `src/companion/` persists menu-bar and widget display preferences, while `src/server/management/companion-routes.ts` exposes those settings and the usage timeline assembled by `src/usage/timeline.ts` to local clients. Query, filter-echo and diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b2f84c4a57c..e9c860c8b2f 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,5 +1,7 @@ # Docs And Release +The activation scheduling contract is covered by `tests/codex-integration/codex-quota-auto-refresh.test.ts`, including restart recovery and bounded retries. See the [quota activation contract](../providers/openai-tiers.md#public-provider-contract). + Automatic package-tree restart holds a releasable data-plane drain until its scheduled service-home check succeeds. A veto releases that fence; a committed shutdown uses the permanent drain latch. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index d8702d82b46..8a2d164a7f6 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -332,4 +332,4 @@ src/update/async-check.ts uses the existing owner-bound registry target with a b The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot. -On Linux, a dashboard update worker started from the systemd user service is launched through `systemd-run --user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The path applies only when `INVOCATION_ID` is set and a no-op scope probe succeeds; every other case keeps the plain detached spawn. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). +On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 4415df5b377..5010979c458 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -174,8 +174,11 @@ Pool mode needs stable public names and a store that survives concurrent refresh - Public selectors are generated per account; the main login's selector is `main`, collision-suffixed if that name is taken, and it maps to the config-only sentinel `@main`, which sits outside the pool-account id grammar (`src/codex/account-namespaces.ts`, `src/codex/account-namespace-match.ts`). - Selectors must not collide with provider or combo ids. A user alias is display metadata; routing - consults credential identity, never the alias. + Selectors must not collide with provider or combo ids. The CLI's `auto` account control word is + resolved after exact stored account ids, so a legacy account with that id remains selectable + rather than invoking the control action; `ocx account clear` skips selector resolution entirely + and always clears the selection. A user alias is display metadata; routing consults + credential identity, never the alias. - The credential store is generation-guarded and refresh-locked (`src/codex/account-store.ts`): a refresh persists only if the generation it started from still holds, and a lost race raises a generation-conflict error instead of overwriting the newer credential. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 733cd1aec8c..ad11efe96ec 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -204,12 +204,21 @@ existing minimal non-stored warmup through that exact account once the timestamp field-patches the completed timestamp. The next observed reset boundary is also retained in `nextFiveHourResetAt` / `nextWeeklyResetAt` until completed; later idle-window metadata cannot postpone it. Successful warmups publish quota headers under the captured credential/identity fence. -For opted-in accounts only, stale metadata is refreshed at most once per five minutes through -the existing WHAM recovery path, independently of dashboard traffic or reset notifications. +Known deadlines suppress activation-owned WHAM queries regardless of snapshot age, including +when only persisted deadlines survive a restart. Missing enabled-window deadlines use the existing +WHAM recovery path after the five-minute freshness guard; unresolved discovery backs off from +five minutes to an hour (5, 10, 20, 40, 60 minutes). Passive headers can satisfy discovery without +a query. Completed warmups seed the next deadlines from response headers; missing next-window +headers use the same discovery path. Retry delays are process-local; deadlines remain durable. +Dashboard queries and reset-notification polling are separate owners and retain their behavior. Inference 401s quarantine the rejected credential; failures log an opaque label and safe reason. Paused or reauthentication-required -accounts are skipped, simultaneous 5-hour/weekly resets share one warmup, transient failures retry -after five minutes, and account deletion removes its setting and completion markers. +accounts are skipped, simultaneous 5-hour/weekly resets share one warmup, transient activation failures +back off from five minutes to an hour, and account deletion removes settings and retry/completion state. +Retry records name the credential generation they were observed under (main quota generation, pool +record generation). A record from a replaced or reauthenticated credential is dropped when read, and a +failure that raced a replacement is not recorded. A local `NativeMainBusyError` admission refusal sends +nothing upstream, so it retries after one minute and keeps the upstream backoff unchanged. Main-account hard-lock also gates these billable warmups. A policy/identity skip changes neither completion markers nor retry delay; quota reads remain available. Main refresh completes before shared credential ownership, then prepared credentials and restrictions are rechecked. Lifecycle diff --git a/structure/remote-link.md b/structure/remote-link.md index e4edc2419e0..cfad32f1cad 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -18,6 +18,10 @@ Every remote `ocx` call goes through `remoteOcxArgv`, which runs `sh -c` with a `src/server/management/link-routes.ts` accepts `POST /api/link/join` with exactly `{ "alias": string }`. The route admits the same dashboard sessions as the Home-side routes (see [Dashboard admission](#dashboard-admission)), so a standalone computer turns itself into a Child from its own dashboard. A Tailscale identity session receives `403 tailscale_session_refused`, any other caller `403 forbidden`, a runtime that is not standalone `409 standalone_required`, and a standalone whose live listener port (`resolveListenPort` in `src/server/management/system-restart.ts`) is not its configured `port`, or cannot be determined, `409 join_port_mismatch`, because the client runtime it restarts into binds exactly the configured port. These gates run before link state is read and before any SSH. The alias must have a confirmed, unexpired host entry in the same route state. Before choosing a port or issuing a new link, a valid stale client sidecar is compensated over SSH unless the machine is already connected to that link; a successful revoke clears the sidecar, while a failed revoke preserves it and returns `join_rollback_failed` with the link id. A corrupt sidecar is left for the next successful write. A successful join issues the Home link through SSH, records the client sidecar, starts the client tunnel and connects the client, then returns `202 { "linkId": string, "alias": string, "restarting": true }`. +During enrollment, `src/client/link-join.ts` watches the spawned SSH tunnel through its 100 ms spawn grace, readiness checks and the connection attempt. Before an unauthenticated `/readyz` probe and again before the keyed request, the only LISTEN owner serving `127.0.0.1:` must be that tunnel PID. Both requests use `redirect: "manual"`; only a 401 challenge permits the keyed request. A failed, empty, foreign or ambiguous ownership recheck withholds the key and reaches the same 15-second deadline check and up-to-100 ms polling delay as any other not-ready iteration. Repeated recheck failures therefore reach rollback instead of bypassing it. The deadline is checked between operations, not an independent per-fetch cancellation timer. An observed tunnel exit winning the readiness or connection race fails the join and runs compensation. + +The enrollment scanner in `src/server/port-reclaim.ts` retains each distinct normalized `(PID, bound address)` pair from Windows `netstat` or the POSIX `lsof`, `ss`, then `netstat` fallback chain. It filters entries for the requested loopback address before deduplicating PIDs, so another socket owned by the same process cannot overwrite the relevant listener. Duplicate rows and IPv4-mapped aliases of the same address still collapse, and the PID-only API continues to return unique PIDs. This enrollment scan does not replace the runtime supervisor's asynchronous ownership check described under [Client link transport](#client-link-transport); the check-to-connect race described there remains. + `src/client/link-state.ts` stores `/link/client-link.json` with mode 0600. The sidecar contains exactly `alias`, `hubHostKeyFingerprint`, `tunnelPort`, `peerListenerPort` and `linkId`; it contains no key. The client tunnel port uses `MIN_LINK_PORT = 1024` through `MAX_LINK_PORT = 65535` and `isLinkPort`; the Home listener port keeps its existing 1–65535 contract. A dashboard join picks a free port at random from `JOIN_TUNNEL_PORT_MIN = 20000` through `JOIN_TUNNEL_PORT_MAX = 29999` (`chooseJoinTunnelPort` in `src/client/link-join.ts`), below the macOS, Windows and Linux ephemeral ranges, so an outgoing connection rarely holds the port when the tunnel comes back after a reboot. The persisted port of an existing link is never rewritten. `src/client/link-tunnel.ts` owns the client `ssh -L 127.0.0.1::127.0.0.1:` process. A client runtime starts that supervisor when link transport and a matching sidecar are present, and it drives the tunnel with `CLIENT_TUNNEL_RETRY_POLICY`, so the Child reconnects by itself after sleep, an outage or a crash. A spawned or respawned tunnel, whether connecting, reconnecting or retrying from failed, is promoted to connected only when a keyed `GET http://127.0.0.1:/readyz` (the link key in `x-opencodex-api-key`, `cache: "no-store"`) proves the link: a 200, or a 503 whose body (read up to 4 KiB) carries `service: "opencodex"`. The Home's link listener answers 401 before it reaches `/readyz`, so that 503 only means the Home's own start-up readiness is pending or failed, which relayed requests do not depend on. Until then the probe backs off from one to five seconds, or up to 30 seconds while the link reads failed; while a request is held it runs on every one-second check instead. One probe runs at a time, detached from the check, and `stop()` or the end of the tunnel it probes aborts it, so a slow Home never delays noticing a disconnect or stopping. While connected the same probe runs every 30 seconds and is display-only: a 401 or 403 reports `probe: "unauthorized"`, the readiness 503 `probe: "home_not_ready"`, and any other failure `probe: "home_unreachable"` in the supervisor status, which the Child's `GET /api/link/status` shows as the child `reason`; it never cuts the tunnel. The key comes from the runtime's cached key source, so no probe reads the token file. The supervisor's one-second check is an unref'd interval that stats the sidecar and `config.json` and parses one again only after it changed. It stops the tunnel and schedules the existing standalone recycle when the connection is no longer connected with link transport, the link id no longer matches, or the sidecar disappears; an unreadable sidecar or connection state acts only after three consecutive checks, so one read during a write never ends a healthy link. Normal shutdown, including recycle, stops the client supervisor before the client listener; it sends TERM, waits at most five seconds, then sends KILL. @@ -58,4 +62,4 @@ Codex keeps the standalone loopback routing: `routingTarget` in `src/client/conn > Decision record: [ADR-6032](decisions/ADR-6032-link-relay-credential-boundary.md) -Regression coverage lives in `tests/clients/link-ssh-argv.test.ts`, `tests/clients/link-ssh-config.test.ts`, `tests/clients/link-tunnel-state.test.ts`, `tests/clients/link-store.test.ts`, `tests/clients/link-boundary.test.ts`, `tests/clients/link-routes.test.ts`, `tests/clients/client-link-connect.test.ts`, `tests/clients/client-link-relay.test.ts`, `tests/clients/client-machine-listener.test.ts`, `tests/clients/client-link-status.test.ts`, `tests/clients/client-link-runtime.test.ts`, `tests/codex-integration/injection-link-websocket.test.ts`, `tests/clients/link-supervisor.test.ts`, `tests/clients/link-status-projection.test.ts`, `tests/clients/link-admission-wait.test.ts`, `tests/clients/link-fingerprint.test.ts`, `tests/cli/cli-link.test.ts`, `tests/server/link-management-routes.test.ts`, `tests/server/link-join-route.test.ts`, `tests/server/link-listener-lifecycle.test.ts`, `tests/clients/client-link-teardown.test.ts` and `gui/tests/remote-link.test.tsx`. +Regression coverage lives in `tests/clients/link-ssh-argv.test.ts`, `tests/clients/link-ssh-config.test.ts`, `tests/clients/link-tunnel-state.test.ts`, `tests/clients/link-store.test.ts`, `tests/clients/link-boundary.test.ts`, `tests/clients/link-routes.test.ts`, `tests/clients/client-link-connect.test.ts`, `tests/clients/client-link-relay.test.ts`, `tests/clients/client-machine-listener.test.ts`, `tests/clients/client-link-status.test.ts`, `tests/clients/client-link-runtime.test.ts`, `tests/codex-integration/injection-link-websocket.test.ts`, `tests/clients/link-supervisor.test.ts`, `tests/clients/link-status-projection.test.ts`, `tests/clients/link-admission-wait.test.ts`, `tests/clients/link-fingerprint.test.ts`, `tests/cli/cli-link.test.ts`, `tests/server/link-management-routes.test.ts`, `tests/server/link-join-route.test.ts`, `tests/server/port-reclaim.test.ts`, `tests/server/link-listener-lifecycle.test.ts`, `tests/clients/client-link-teardown.test.ts` and `gui/tests/remote-link.test.tsx`. diff --git a/structure/runtime.md b/structure/runtime.md index 3fd46e1ca2d..8da1d766045 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,5 +1,7 @@ # Runtime +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + ## Resolved static model policy `src/router.ts` attaches one frozen `ResolvedModelPolicy` to every `RouteResult`. Policy/combo @@ -502,7 +504,7 @@ This is also why the classifier cannot duplicate visible output. Native byte str Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. -`src/combos/failover.ts` uses a 10-minute fallback for a spent account usage window (codes `usage_limit_exceeded`, `usage_limit_reached`, `1308`, or `usage limit reached` / `usage limit has been reached` prose, including HTTP 502) and for provider-scoped credential or billing failure codes such as `invalid_api_key` and `insufficient_quota`. This duration does not change failure classification or cooldown scope; upstream retry/reset signals and configured durations retain precedence. It caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. +`src/combos/failover.ts` uses a 10-minute fallback for a spent account usage window (codes `usage_limit_exceeded`, `usage_limit_reached`, `1308`, or `usage limit reached` / `usage limit has been reached` / `token-plan quota has been exhausted` prose, including HTTP 502) and for provider-scoped credential or billing failure codes such as `invalid_api_key` and `insufficient_quota`. This duration does not change failure classification or cooldown scope; upstream retry/reset signals and configured durations retain precedence. It caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. ## Combo default effort precedence @@ -589,9 +591,7 @@ an unreadable current record is unknown, and a valid address is probed even when PID is gone. Lease delegation is passed only to stop and recovery children, never package manager children. A replacement refusal passes through owner-aware recovery: only the same CLI owner revives the stopped runtime; foreign ownership stays transferred and unknown ownership -remains a reported recovery requirement. Dashboard restart delegates the lease token to its repair child. Direct -start holds the same lease through bind plus PID and runtime-address publication. If listener -rollback cannot prove the socket closed, the process retains its lease until exit. +remains a reported recovery requirement. Dashboard restart delegates the lease token to its repair child. Direct start holds the same lease through bind plus PID and runtime-address publication. If listener rollback cannot prove the socket closed, the process retains its lease until exit. The registration is never deleted; `ocx service install` releases the marker only after the registration succeeds. diff --git a/structure/subagents.md b/structure/subagents.md index 3f6370e1fe5..40ec975f6bc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -1,5 +1,7 @@ # Subagents And Multi-Agent Surface +Subagent quota priming remains separate from automatic activation scheduling. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). + Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation does not attest the runtime used by a subagent or change agent selection. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). diff --git a/tests/cli/cli-account-alias-target.test.ts b/tests/cli/cli-account-alias-target.test.ts index 23a897d81a3..ba69f556691 100644 --- a/tests/cli/cli-account-alias-target.test.ts +++ b/tests/cli/cli-account-alias-target.test.ts @@ -23,16 +23,21 @@ interface Harness { listFailure: { status: number; error: string } | null; run: (args: string[]) => Promise<{ code: number; stdout: string; stderr: string }>; writes: () => Captured[]; + activeId: () => string | null; } function harness(providers: Record = {}): Harness { const requests: Captured[] = []; + // The mock active route persists what PUT writes so a verb that writes nothing (or the wrong + // thing) leaves observable state, not just a captured request. + let activeId: string | null = null; const state: Harness = { requests, accounts: [{ id: "chatgpt_1", plan: "pro", quota: null }], listFailure: null, run: async () => ({ code: 0, stdout: "", stderr: "" }), writes: () => requests.filter(r => r.method === "PUT" && r.path === "/api/codex-auth/active"), + activeId: () => activeId, }; const config = (): OcxConfig => ({ port: 10100, @@ -64,10 +69,10 @@ function harness(providers: Record = {}): Harness { return new Response(JSON.stringify({ accounts: state.accounts }), { status: 200 }); } if (captured.path === "/api/codex-auth/active") { - const pinned = captured.method === "PUT" - ? (captured.body as { accountId?: string | null } | undefined)?.accountId ?? null - : null; - return new Response(JSON.stringify({ ok: true, activeCodexAccountId: pinned, activeId: pinned }), { status: 200 }); + if (captured.method === "PUT") { + activeId = (captured.body as { accountId?: string | null } | undefined)?.accountId ?? null; + } + return new Response(JSON.stringify({ ok: true, activeCodexAccountId: activeId, activeId }), { status: 200 }); } return new Response(JSON.stringify({ ok: true }), { status: 200 }); }) as unknown as typeof fetch, @@ -166,14 +171,43 @@ describe("ocx account: alias and auto as account arguments", () => { expect(result.stdout).toContain("chatgpt_2"); }); - test("use auto clears the pin and says the pool decides from here", async () => { + test("use auto selects an exact account id before treating auto as the pool selector", async () => { const h = harness(); - const result = await h.run(["use", "openai", "auto"]); + const automatic = await h.run(["use", "openai", "auto"]); - expect(result.code).toBe(0); + expect(automatic.code).toBe(0); + expect(h.writes().at(-1)?.body).toEqual({ accountId: null }); + expect(automatic.stdout).toContain("automatic account selection"); + expect(automatic.stderr).not.toContain("may override this pin"); + + h.accounts.push({ id: "auto", plan: "pro", quota: null }); + const selected = await h.run(["use", "openai", "auto"]); + expect(selected.code).toBe(0); + expect(h.writes().at(-1)?.body).toEqual({ accountId: "auto" }); + expect(h.activeId()).toBe("auto"); + + const cleared = await h.run(["clear", "openai"]); + expect(cleared.code).toBe(0); + expect(h.activeId()).toBe(null); + + const paused = await h.run(["pause", "openai", "auto"]); + expect(paused.code).toBe(0); + expect(h.requests.filter(r => r.path === "/api/codex-auth/accounts/pause").at(-1)?.body) + .toEqual({ id: "auto", paused: true }); + }); + + test("an account named auto pins through use while clear still restores automatic selection", async () => { + const h = harness(); + h.accounts.push({ id: "auto", plan: "pro", quota: null }); + + const pinned = await h.run(["use", "openai", "auto"]); + expect(pinned.code).toBe(0); + expect(h.writes().at(-1)?.body).toEqual({ accountId: "auto" }); + + const cleared = await h.run(["clear", "openai"]); + expect(cleared.code).toBe(0); expect(h.writes().at(-1)?.body).toEqual({ accountId: null }); - expect(result.stdout).toContain("automatic account selection"); - expect(result.stderr).not.toContain("may override this pin"); + expect(cleared.stdout).toContain("automatic account selection"); }); test("missing and ambiguous aliases keep distinct errors before any write", async () => { @@ -220,8 +254,12 @@ describe("ocx account: alias and auto as account arguments", () => { const pause = await h.run(["pause", "openai", "auto"]); expect(pause.code).toBe(1); expect(pause.stderr).toContain("reserved"); - // The list only serves alias resolution: without it the argument is sent as an id, as before. h.listFailure = { status: 500, error: "list unavailable" }; + const automatic = await h.run(["use", "openai", "auto"]); + expect(automatic.code).toBe(1); + expect(automatic.stderr).toContain("Cannot safely resolve"); + expect(h.writes()).toHaveLength(0); + // The list only serves alias resolution: without it the argument is sent as an id, as before. const raw = await h.run(["use", "openai", "chatgpt_1"]); expect(raw.code).toBe(0); expect(h.writes().at(-1)?.body).toEqual({ accountId: "chatgpt_1" }); diff --git a/tests/codex-integration/codex-account-clear-paused.test.ts b/tests/codex-integration/codex-account-clear-paused.test.ts new file mode 100644 index 00000000000..ac971d97681 --- /dev/null +++ b/tests/codex-integration/codex-account-clear-paused.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { getDefaultConfig, loadConfig } from "../../src/config"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { pinnedCodexAccountId, setCodexAccountPin } from "../../src/codex/account-priority"; +import { createTempHome } from "../helpers/temp-home"; + +let home: ReturnType; +beforeEach(() => { home = createTempHome("ocx-clear-paused-account-"); }); +afterEach(() => { home.remove(); }); + +const url = new URL("http://127.0.0.1/api/codex-auth/active"); +function request(accountId: string | null): Request { + return new Request(url, { method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId }) }); +} + +test("null clears active selection and pin even when the main account is paused", async () => { + const config = getDefaultConfig(); + config.activeCodexAccountId = MAIN_CODEX_ACCOUNT_ID; + config.pausedCodexAccountIds = [MAIN_CODEX_ACCOUNT_ID]; + setCodexAccountPin(config, MAIN_CODEX_ACCOUNT_ID); + const response = await handleCodexAuthAPI(request(null), url, config); + expect(response?.status).toBe(200); + expect(await response!.json()).toMatchObject({ ok: true, activeCodexAccountId: null }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(pinnedCodexAccountId(config)).toBeUndefined(); + const stored = loadConfig(); + expect(stored.activeCodexAccountId).toBeUndefined(); + expect(pinnedCodexAccountId(stored)).toBeUndefined(); + expect(stored.pausedCodexAccountIds).toContain(MAIN_CODEX_ACCOUNT_ID); +}); + +test("clearing with paused main is idempotent and does not select it", async () => { + const config = getDefaultConfig(); + config.pausedCodexAccountIds = [MAIN_CODEX_ACCOUNT_ID]; + for (let i = 0; i < 2; i++) { + const response = await handleCodexAuthAPI(request(null), url, config); + expect(response?.status).toBe(200); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(pinnedCodexAccountId(config)).toBeUndefined(); + } +}); + +test("an explicit paused main selection still fails without clearing an existing pin", async () => { + const config = getDefaultConfig(); + config.activeCodexAccountId = "pool-fixture"; + config.pausedCodexAccountIds = [MAIN_CODEX_ACCOUNT_ID]; + setCodexAccountPin(config, "pool-fixture"); + const response = await handleCodexAuthAPI(request(MAIN_CODEX_ACCOUNT_ID), url, config); + expect(response?.status).toBe(409); + expect(await response!.json()).toEqual({ error: "Account is paused" }); + expect(config.activeCodexAccountId).toBe("pool-fixture"); + expect(pinnedCodexAccountId(config)).toBe("pool-fixture"); +}); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 20d21eddb28..3ee450460f7 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -4280,20 +4280,6 @@ describe("codex-auth API", () => { expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); }); - test("PUT /api/codex-auth/active rejects null when the effective main account is paused", async () => { - const config = makeConfig({ pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID] }); - const req = new Request("http://localhost/api/codex-auth/active", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accountId: null }), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); - - expect(resp!.status).toBe(409); - expect(await resp!.json()).toEqual({ error: "Account is paused" }); - expect(config.activeCodexAccountId).toBeUndefined(); - }); - test("resuming restores eligibility and manual activation rejects paused accounts", async () => { const config = makeConfig({ codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], diff --git a/tests/codex-integration/codex-quota-auto-refresh-generation.test.ts b/tests/codex-integration/codex-quota-auto-refresh-generation.test.ts new file mode 100644 index 00000000000..37a3ea6c96f --- /dev/null +++ b/tests/codex-integration/codex-quota-auto-refresh-generation.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + NativeMainBusyError, + resetCodexQuotaAutoRefreshForTests, + runCodexQuotaAutoRefresh, + type CodexQuotaAutoRefreshWindows, +} from "../../src/codex/quota-auto-refresh"; +import { clearAccountQuota, type StoredAccountQuota } from "../../src/codex/quota"; +import { readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; +import type { OcxConfig } from "../../src/types"; + +/** + * Retry evidence belongs to the credential it was observed under (#6020 review). A replaced or + * reauthenticated credential must not wait out its predecessor's backoff, and a local admission + * refusal that sent nothing upstream must not grow the upstream backoff. + */ +const NOW = 1_800_000_000_000; +const RESET_SECONDS = NOW / 1000; +let testHome = ""; +let previousHome: string | undefined; + +function writePoolCredential(accessToken: string): void { + saveCodexAccountCredential("pool-a", { + accessToken, refreshToken: "generation-refresh-fixture", + expiresAt: NOW + 86_400_000, chatgptAccountId: "generation-workspace-fixture", + }); +} + +function config(): OcxConfig { + return { + defaultProvider: "openai", + providers: { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "pool", + } }, + codexAccounts: [{ id: "pool-a", email: "p***a@example.test", plan: "team", isMain: false }], + codexQuotaAutoRefresh: { "pool-a": { fiveHour: true, weekly: true } }, + }; +} + +function quota(): StoredAccountQuota { + return { shortWindowSeconds: 5 * 60 * 60, shortResetAt: RESET_SECONDS, weeklyResetAt: RESET_SECONDS, updatedAt: NOW }; +} + +function recordMarkers(cfg: OcxConfig, accountId: string, completed: CodexQuotaAutoRefreshWindows): boolean { + cfg.codexQuotaAutoRefresh = { ...cfg.codexQuotaAutoRefresh, [accountId]: { + ...cfg.codexQuotaAutoRefresh?.[accountId], + ...(completed.fiveHour !== undefined ? { lastFiveHourResetAt: completed.fiveHour } : {}), + ...(completed.weekly !== undefined ? { lastWeeklyResetAt: completed.weekly } : {}), + } }; + return true; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testHome = mkdtempSync(join(tmpdir(), "ocx-quota-generation-")); + process.env.OPENCODEX_HOME = testHome; + clearAccountQuota(); + resetCodexQuotaAutoRefreshForTests(); + writePoolCredential("generation-one"); +}); + +afterEach(() => { + clearAccountQuota(); + resetCodexQuotaAutoRefreshForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testHome && existsSync(testHome)) rmSync(testHome, { recursive: true, force: true }); +}); + +describe("quota auto-refresh retry evidence follows the credential", () => { + test("a replaced credential does not wait out its predecessor's backoff", async () => { + const cfg = config(); + let attempts = 0; + const deps = { + getQuota: () => quota(), + warmAccount: async () => { attempts++; if (attempts === 1) throw new Error("fixture failure"); }, + persistCompleted: recordMarkers, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + expect(attempts).toBe(1); + // Same credential: the five-minute backoff holds. + await runCodexQuotaAutoRefresh(cfg, NOW + 60_000, deps); + expect(attempts).toBe(1); + const before = readCodexAccountRecord("pool-a")?.generation; + writePoolCredential("generation-two"); + expect(readCodexAccountRecord("pool-a")?.generation).not.toBe(before); + // Replacement: the old backoff is spent and the new credential is tried at once. + await runCodexQuotaAutoRefresh(cfg, NOW + 120_000, deps); + expect(attempts).toBe(2); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastFiveHourResetAt).toBe(NOW); + }); + + test("a failure that raced a replacement is not recorded against the new credential", async () => { + const cfg = config(); + let attempts = 0; + const deps = { + getQuota: () => quota(), + warmAccount: async () => { + attempts++; + if (attempts === 1) { + writePoolCredential("rotated-during-warmup"); + throw new Error("fixture failure from the old credential"); + } + }, + persistCompleted: recordMarkers, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastFiveHourResetAt).toBeUndefined(); + await runCodexQuotaAutoRefresh(cfg, NOW + 60_000, deps); + expect(attempts).toBe(2); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastFiveHourResetAt).toBe(NOW); + }); + + test("a local busy refusal retries every minute without growing the upstream backoff", async () => { + const cfg = config(); + let attempts = 0; + let outcome: "busy" | "fail" | "ok" = "busy"; + const deps = { + getQuota: () => quota(), + warmAccount: async () => { + attempts++; + if (outcome === "busy") throw new NativeMainBusyError(); + if (outcome === "fail") throw new Error("fixture upstream failure"); + }, + persistCompleted: recordMarkers, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 60_000 - 1, deps); + expect(attempts).toBe(1); + await runCodexQuotaAutoRefresh(cfg, NOW + 60_000, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 120_000, deps); + expect(attempts).toBe(3); + // The first real upstream failure starts from the base five minutes, not a doubled delay. + outcome = "fail"; + await runCodexQuotaAutoRefresh(cfg, NOW + 180_000, deps); + expect(attempts).toBe(4); + outcome = "ok"; + await runCodexQuotaAutoRefresh(cfg, NOW + 180_000 + 5 * 60_000 - 1, deps); + expect(attempts).toBe(4); + await runCodexQuotaAutoRefresh(cfg, NOW + 180_000 + 5 * 60_000, deps); + expect(attempts).toBe(5); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastFiveHourResetAt).toBe(NOW); + }); +}); diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index 204436b3a87..946e23b56f3 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -138,12 +138,13 @@ afterEach(async () => { }); describe("quota auto-refresh native-main admission", () => { - test("stale metadata prepares an expired main token before WHAM and activation", async () => { + test.each([false, true])("expired main token is prepared before activation (missing deadline: %s)", async missingDeadline => { const cfg = config(); writeMain(bearer(true)); const cached = getAccountQuota(MAIN); if (!cached) throw new Error("Expected cached main quota"); cached.updatedAt = now - 300_000; + if (missingDeadline) delete cached.shortResetAt; const fresh = bearer(); const calls = installFetch(async (url, init) => { if (url === tokenUrl) { @@ -157,7 +158,7 @@ describe("quota auto-refresh native-main admission", () => { return completedResponse(); }); await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); - expect(calls).toEqual([tokenUrl, whamUrl, responsesUrl]); + expect(calls).toEqual(missingDeadline ? [tokenUrl, whamUrl, responsesUrl] : [tokenUrl, responsesUrl]); expect(isAccountNeedsReauth(MAIN)).toBe(false); expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastFiveHourResetAt).toBe(RESET_MILLISECONDS); expect(getNativeMainProfileRequestCount()).toBe(0); diff --git a/tests/codex-integration/codex-quota-auto-refresh.test.ts b/tests/codex-integration/codex-quota-auto-refresh.test.ts index 375317d9a11..a733fba73c7 100644 --- a/tests/codex-integration/codex-quota-auto-refresh.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh.test.ts @@ -226,6 +226,110 @@ describe("Codex quota window auto refresh", () => { expect(warmups).toBe(0); }); + test.each(["pool-a", "__main__"])("known deadlines need no metadata polling for %s, including after restart", async accountId => { + let cfg = config(); + cfg.codexQuotaAutoRefresh = { [accountId]: { fiveHour: true, weekly: true } }; + writeFileSync(join(testHome, "config.json"), JSON.stringify(cfg)); + let observed: StoredAccountQuota | null = quota({ + shortResetAt: RESET_SECONDS + 18_000, weeklyResetAt: RESET_SECONDS + 18_000, + updatedAt: NOW - 600_000, + }); + let probes = 0; + let warmups = 0; + const deps = { + getQuota: () => observed, + refreshQuota: async () => { probes++; }, + warmAccount: async () => { + warmups++; + observed = quota({ shortResetAt: RESET_SECONDS + 36_000, weeklyResetAt: RESET_SECONDS + 604_800 }); + }, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + resetCodexQuotaAutoRefreshForTests(); + cfg = loadConfig(); + observed = null; // Durable deadlines must work without an in-memory quota snapshot. + for (let elapsed = 60_000; elapsed < 18_000_000; elapsed += 60_000) { + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed, deps); + } + expect(probes).toBe(0); + expect(warmups).toBe(0); + await runCodexQuotaAutoRefresh(cfg, NOW + 18_000_000, deps); + expect(warmups).toBe(1); + expect(probes).toBe(0); + expect(loadConfig().codexQuotaAutoRefresh?.[accountId]).toMatchObject({ + lastFiveHourResetAt: NOW + 18_000_000, lastWeeklyResetAt: NOW + 18_000_000, + nextFiveHourResetAt: NOW + 36_000_000, nextWeeklyResetAt: NOW + 604_800_000, + }); + await runCodexQuotaAutoRefresh(cfg, NOW + 18_060_000, deps); + expect(probes).toBe(0); + expect(warmups).toBe(1); + }); + + test("missing-window discovery backs off to an hour and stops when passive headers supply it", async () => { + const cfg = config(); + let observed = quota({ shortResetAt: RESET_SECONDS + 86_400, weeklyResetAt: undefined, updatedAt: NOW - 600_000 }); + let probes = 0; + const deps = { + getQuota: () => observed, + refreshQuota: async () => { probes++; }, // A successful read without the missing field also backs off. + warmAccount: async () => { throw new Error("not due"); }, + }; + let elapsed = 0; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + for (const delay of [5, 10, 20, 40, 60, 60]) { + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed + delay * 60_000 - 1, deps); + const before = probes; + elapsed += delay * 60_000; + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed, deps); + expect(probes).toBe(before + 1); + } + expect(probes).toBe(7); + observed = { ...observed, weeklyResetAt: RESET_SECONDS + 604_800 }; + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed + 60_000, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + elapsed + 3_600_000, deps); + expect(probes).toBe(7); + }); + + test("activation without next-window headers discovers the next deadline once", async () => { + const cfg = config(); + cfg.codexQuotaAutoRefresh = { "pool-a": { fiveHour: true } }; + let observed = quota(); + let probes = 0; + let warmups = 0; + const deps = { + getQuota: () => observed, + refreshQuota: async () => { + probes++; + observed = quota({ shortResetAt: RESET_SECONDS + 18_000 }); + }, + warmAccount: async () => { warmups++; }, + persistCompleted: recordMarkers, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 300_000, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 600_000, deps); + expect(warmups).toBe(1); + expect(probes).toBe(1); + }); + + test("failed activations back off without probing known deadlines", async () => { + const cfg = config(); + let probes = 0; + let warmups = 0; + const deps = { + getQuota: () => quota({ updatedAt: NOW - 600_000 }), + refreshQuota: async () => { probes++; }, + warmAccount: async () => { warmups++; throw new Error("fixture failure"); }, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 300_000, deps); + await runCodexQuotaAutoRefresh(cfg, NOW + 600_000, deps); + expect(warmups).toBe(2); + await runCodexQuotaAutoRefresh(cfg, NOW + 900_000, deps); + expect(warmups).toBe(3); + expect(probes).toBe(0); + }); + test("regression: inference 401 quarantines a time-valid bearer and stops retries", async () => { const cfg = config(); writePoolCredential(); diff --git a/tests/codex-integration/combo-codex-exhaustion-cooldown.test.ts b/tests/codex-integration/combo-codex-exhaustion-cooldown.test.ts index ddb882ed9ab..0aa831344c8 100644 --- a/tests/codex-integration/combo-codex-exhaustion-cooldown.test.ts +++ b/tests/codex-integration/combo-codex-exhaustion-cooldown.test.ts @@ -53,6 +53,23 @@ describe("a depleted Codex plan window", () => { expect(isComboTargetInCooldown(combo, target, now + 60_000)).toBe(false); }); + // #5494: a token-plan 429 is a spent plan window. At 60s the combo re-offered it every minute and, + // with one transient failure on the other target, answered every request with 503. + test("a spent token-plan window takes the ten-minute hold", () => { + const message = "Provider error 429: Your token-plan 1-week quota has been exhausted. " + + "The quota will reset at 10-04 12:00:00 UTC."; + coolComboTarget(combo, target, { now, status: 429, code: "rate_limit_exceeded", message }); + expect(isComboTargetInCooldown(combo, target, now + 10 * 60_000 - 1)).toBe(true); + expect(isComboTargetInCooldown(combo, target, now + 10 * 60_000)).toBe(false); + }); + + test("a per-minute quota message keeps the short cooldown", () => { + coolComboTarget(combo, target, { + now, status: 429, code: "rate_limit_exceeded", message: "rate limit exceeded, quota exhausted for this minute", + }); + expect(isComboTargetInCooldown(combo, target, now + 60_000)).toBe(false); + }); + test("a bare 1308 code with no prose still takes the exhaustion hold", () => { coolComboTarget(combo, target, { now, status: 429, code: "1308", message: "" }); expect(isComboTargetInCooldown(combo, target, now + 10 * 60_000 - 1)).toBe(true); diff --git a/tests/codex-integration/project-config-warning-snapshot.test.ts b/tests/codex-integration/project-config-warning-snapshot.test.ts new file mode 100644 index 00000000000..e3e1996902e --- /dev/null +++ b/tests/codex-integration/project-config-warning-snapshot.test.ts @@ -0,0 +1,42 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as bounded from "../../src/codex/inject/bounded-config-reader"; +import { collectProjectCodexConfigWarnings, discoverProjectCodexConfigPaths, isGlobalOpencodexRoutingActive } from "../../src/codex/project-config-warnings"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +for (const kind of ["present", "absent", "unreadable"] as const) { + test(`project warnings read exactly one global ${kind} snapshot`, () => { + const root = mkdtempSync(join(tmpdir(), "ocx-warning-snapshot-")); + const global = join(root, "global.toml"); + const project = join(root, "project"); + mkdirSync(join(project, ".codex"), { recursive: true }); + const projectConfig = join(project, ".codex", "config.toml"); + writeFileSync(projectConfig, 'model_provider = "external"\n'); + const text = 'model_provider = "opencodex"\n'; + writeFileSync(global, text); + const read = spyOn(bounded, "readBoundedCodexConfig").mockImplementation(() => { + if (kind === "unreadable") throw new Error("fixture read failure"); + return kind === "absent" ? null : text; + }); + try { + const warnings = collectProjectCodexConfigWarnings({ cwd: project, codexConfigPath: global }); + expect(read).toHaveBeenCalledTimes(1); + expect(warnings.some(w => w.code === "global_config_unreadable")).toBe(kind === "unreadable"); + expect(warnings.some(w => w.path === projectConfig)).toBe(kind !== "absent"); + } finally { read.mockRestore(); removeTreeWithRetry(root); } + }); +} + +test("explicit absent snapshots never become fresh global reads", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-warning-absent-")); + const global = join(root, "global.toml"); + writeFileSync(global, 'model_provider = "opencodex"\n'); + const read = spyOn(bounded, "readBoundedCodexConfig").mockImplementation(() => { throw new Error("unexpected reread"); }); + try { + expect(isGlobalOpencodexRoutingActive(global, null)).toBe(false); + expect(discoverProjectCodexConfigPaths({ cwd: root, codexConfigPath: global, maxWalkParents: 1, globalContent: null })).toEqual([]); + expect(read).not.toHaveBeenCalled(); + } finally { read.mockRestore(); removeTreeWithRetry(root); } +}); diff --git a/tests/codex-integration/project-config-warnings.test.ts b/tests/codex-integration/project-config-warnings.test.ts index f130522c684..c4ebe5cff97 100644 --- a/tests/codex-integration/project-config-warnings.test.ts +++ b/tests/codex-integration/project-config-warnings.test.ts @@ -393,6 +393,27 @@ name = "anthropic" .filter(warning => warning.path === projectConfigPath); expect(second.length).toBe(0); }); + + test("an oversized global config is reported as unreadable instead of silently inactive", () => { + const codexConfigPath = join(process.env.CODEX_HOME!, "config.toml"); + mkdirSync(process.env.CODEX_HOME!, { recursive: true }); + writeFileSync(codexConfigPath, `# ${"x".repeat(1024 * 1024)}`); + const warnings = collectProjectCodexConfigWarnings({ cwd: testDir, codexConfigPath }); + const global = warnings.find(warning => warning.code === "global_config_unreadable"); + expect(global?.path).toBe(codexConfigPath); + }); + + test("an oversized global config still surfaces bypasses found by walking parents", () => { + const codexConfigPath = join(process.env.CODEX_HOME!, "config.toml"); + mkdirSync(process.env.CODEX_HOME!, { recursive: true }); + writeFileSync(codexConfigPath, `# ${"x".repeat(1024 * 1024)}`); + const projectConfigPath = join(testDir, ".codex", "config.toml"); + mkdirSync(join(testDir, ".codex"), { recursive: true }); + writeFileSync(projectConfigPath, `model_provider = "anthropic"`); + const warnings = collectProjectCodexConfigWarnings({ cwd: testDir, codexConfigPath }); + expect(warnings.some(warning => warning.code === "global_config_unreadable")).toBe(true); + expect(warnings.some(warning => warning.path === projectConfigPath)).toBe(true); + }); }); describe("explainProjectConfigBypass", () => { diff --git a/tests/config/settings-desktop-switch-apply.test.ts b/tests/config/settings-desktop-switch-apply.test.ts index a72a28cd184..297189fffe6 100644 --- a/tests/config/settings-desktop-switch-apply.test.ts +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -1,8 +1,9 @@ import { expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { readBoundedCodexConfig } from "../../src/codex/inject/bounded-config-reader"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; @@ -59,6 +60,35 @@ function runIsolatedSettingsRequest(options: { expect(line).toBeDefined(); return JSON.parse(line!) as { status: number; body: Record }; } + +/** + * Same isolation boundary as the settings cases, for the restore path: CODEX_HOME must + * be fixed before the module graph binds CODEX_CONFIG_PATH. The child's last stdout line + * is the JSON result; earlier lines may be the restore machinery's own logs. + */ +function runIsolatedCodexScript(options: { + root: string; + codexHome: string; + script: string; +}): Record { + const child = spawnSync(process.execPath, ["--eval", options.script], { + cwd: repoRoot(), + env: { + ...process.env, + CODEX_HOME: options.codexHome, + OPENCODEX_HOME: join(options.root, "opencodex"), + }, + encoding: "utf8", + timeout: 10_000, + }); + if (child.status !== 0) { + const cause = child.error ? ` (${child.error.name}: ${child.error.message})` : ""; + throw new Error(`isolated codex script failed (status=${child.status} signal=${child.signal})${cause}: ${child.stderr || child.stdout}`); + } + const line = child.stdout.trim().split("\n").filter(Boolean).at(-1); + expect(line).toBeDefined(); + return JSON.parse(line!) as Record; +} test("PUT /api/settings reports Codex write-lock contention as retryable", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-settings-desktop-switch-")); const codexHome = join(root, "codex"); @@ -214,6 +244,42 @@ test("GET /api/settings survives an unreadable config.toml during ownership dete } }, 15_000); +test.skipIf(process.platform === "win32")( + "GET /api/settings refuses a config.toml FIFO without blocking", + () => { + const root = mkdtempSync(join(tmpdir(), "ocx-settings-fifo-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + const fifo = spawnSync("mkfifo", [join(codexHome, "config.toml")], { encoding: "utf8" }); + expect(fifo.status).toBe(0); + + try { + const response = runIsolatedSettingsRequest({ + root, + codexHome, + routeConfig: ISOLATED_PROVIDER_CONFIG, + scriptBody: ` + const request = new Request("http://127.0.0.1:10100/api/settings", { + headers: { host: "127.0.0.1:10100" }, + }); + const response = await handleManagementAPI(request, new URL(request.url), config, { + getCachedStartupHealth: async () => startupHealthFixture(), + }); + `, + }); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + codexDesktopSwitches: { + apply: { applied: false, reason: "ownership_undetermined", retryable: true }, + }, + }); + } finally { + removeTreeWithRetry(root); + } + }, + 15_000, +); + test("PUT /api/settings keeps the undetermined-ownership explanation on a locked save", () => { // clientIntegrations.codex = false trips the apply gate before the injector runs, and an // unreadable config.toml leaves ownership undetermined. The locked save must still report @@ -262,6 +328,134 @@ test("PUT /api/settings keeps the undetermined-ownership explanation on a locked } }, 15_000); +test("readBoundedCodexConfig returns null only for a config absent at lookup", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-bounded-reader-")); + try { + expect(readBoundedCodexConfig(join(root, "config.toml"))).toBeNull(); + writeFileSync(join(root, "config.toml"), 'model_provider = "custom"\n'); + expect(readBoundedCodexConfig(join(root, "config.toml"))).toContain('"custom"'); + // Present but unreadable-as-a-bounded-regular-file is a throw, not a null. + mkdirSync(join(root, "as-dir.toml")); + expect(() => readBoundedCodexConfig(join(root, "as-dir.toml"))).toThrow(); + writeFileSync(join(root, "big.toml"), `# ${"x".repeat(1024 * 1024)}\nmodel = "gpt-5.5"\n`); + expect(() => readBoundedCodexConfig(join(root, "big.toml"))).toThrow(); + } finally { + removeTreeWithRetry(root); + } +}); + +test.skipIf(process.platform === "win32")( + "readBoundedCodexConfig resolves a symlinked config to a bounded regular target", + () => { + const root = mkdtempSync(join(tmpdir(), "ocx-bounded-link-")); + try { + writeFileSync(join(root, "dotfiles-codex.toml"), 'model_provider = "custom"\n'); + symlinkSync(join(root, "dotfiles-codex.toml"), join(root, "config.toml")); + expect(readBoundedCodexConfig(join(root, "config.toml"))).toContain('"custom"'); + // A link does not launder an unsafe target: the descriptor check still refuses it. + symlinkSync("/dev/null", join(root, "null.toml")); + expect(() => readBoundedCodexConfig(join(root, "null.toml"))).toThrow(); + symlinkSync(join(root, "missing.toml"), join(root, "dangling.toml")); + expect(readBoundedCodexConfig(join(root, "dangling.toml"))).toBeNull(); + } finally { + removeTreeWithRetry(root); + } + }, +); + +test.skipIf(process.platform === "win32")( + "GET /api/settings reads ownership through a symlinked config.toml", + () => { + // Codex and the injector read the link's target, so the bounded observation must + // too — otherwise settings reports undetermined for a config that plainly selects + // an external provider. + const root = mkdtempSync(join(tmpdir(), "ocx-settings-link-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(root, "dotfiles-codex.toml"), 'model_provider = "custom"\n'); + symlinkSync(join(root, "dotfiles-codex.toml"), join(codexHome, "config.toml")); + + try { + const response = runIsolatedSettingsRequest({ + root, + codexHome, + routeConfig: ISOLATED_PROVIDER_CONFIG, + scriptBody: ` + const request = new Request("http://127.0.0.1:10100/api/settings", { + headers: { host: "127.0.0.1:10100" }, + }); + const response = await handleManagementAPI(request, new URL(request.url), config, { + getCachedStartupHealth: async () => startupHealthFixture(), + }); + `, + }); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + codexDesktopSwitches: { + apply: { applied: false, reason: "external_provider", retryable: false }, + }, + }); + } finally { + removeTreeWithRetry(root); + } + }, + 15_000, +); + +test.skipIf(process.platform === "win32")( + "native restore still classifies a symlinked config.toml's target", + () => { + // Regression for the shared ownership probe: a link to a small regular config must + // produce the external-provider result, not an early exit that leaves injected + // routing pointed at a stopped proxy. + const root = mkdtempSync(join(tmpdir(), "ocx-restore-link-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(root, "dotfiles-codex.toml"), 'model_provider = "custom"\n'); + symlinkSync(join(root, "dotfiles-codex.toml"), join(codexHome, "config.toml")); + + try { + const result = runIsolatedCodexScript({ + root, + codexHome, + script: ` + const { restoreNativeCodex } = await import("./src/codex/inject"); + const result = restoreNativeCodex(); + console.log(JSON.stringify({ success: result.success, externalProvider: result.externalProvider ?? null })); + `, + }); + expect(result).toMatchObject({ success: true, externalProvider: "custom" }); + } finally { + removeTreeWithRetry(root); + } + }, + 15_000, +); + +test("native restore tolerates a config.toml over the observation bound", () => { + // A valid config larger than the 1 MiB observation bound must still classify as + // external — the read/write ownership probe is not the bounded settings reader. + const root = mkdtempSync(join(tmpdir(), "ocx-restore-big-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, "config.toml"), `# ${"x".repeat(1024 * 1024)}\nmodel_provider = "custom"\n`); + + try { + const result = runIsolatedCodexScript({ + root, + codexHome, + script: ` + const { restoreNativeCodex } = await import("./src/codex/inject"); + const result = restoreNativeCodex(); + console.log(JSON.stringify({ success: result.success, externalProvider: result.externalProvider ?? null })); + `, + }); + expect(result).toMatchObject({ success: true, externalProvider: "custom" }); + } finally { + removeTreeWithRetry(root); + } +}, 15_000); + test("PUT /api/settings reports external Codex ownership when the integration is disabled", () => { // clientIntegrations.codex = false trips the apply gate before the injector runs, so // the ownership classification has to happen inside applyCodexConfigInjection itself. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 903440aa89d..2924eaf5ca9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,7 @@ { "pnpm-command-isolation.test.ts": "update", + "project-config-warning-snapshot.test.ts": "codex-integration", + "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "provider-antigravity-quota-retry.test.ts": "providers", "responses-compaction-recovery.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", @@ -16,6 +18,7 @@ "restart-replacement.test.ts": "server", "deepseek-artifact-tool-schema.test.ts": "providers", "client-config-export-output-limit.test.ts": "config", + "codex-account-clear-paused.test.ts": "codex-integration", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", diff --git a/tests/server/link-join-route.test.ts b/tests/server/link-join-route.test.ts index 653e749bf37..9ea2b8c26c1 100644 --- a/tests/server/link-join-route.test.ts +++ b/tests/server/link-join-route.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test, spyOn } from "bun:test"; import { chooseJoinTunnelPort, ClientLinkJoinError, joinHome, type ClientLinkJoinDeps } from "../../src/client/link-join"; +import { spawnClientLinkTunnel } from "../../src/client/link-tunnel"; +import type { ListenPidScan } from "../../src/server/port-reclaim"; import { isLinkPort, JOIN_TUNNEL_PORT_MAX, JOIN_TUNNEL_PORT_MIN } from "../../src/link/ports"; import { handleLinkRoutes, type LinkRouteState } from "../../src/server/management/link-routes"; import type { ManagementContext } from "../../src/server/management/context"; @@ -14,6 +16,7 @@ function isWrappedIssue(argv: readonly string[]): boolean { return (argv.at(-1) ?? "").startsWith(`${quoteRemote(remoteOcxArgv(["link", "issue", "--alias"]))} `); } +/** Select only the wrapped revoke command, not other SSH traffic. */ function revokeCalls(calls: readonly string[][]): string[][] { return calls.filter(argv => argv.at(-1) === REVOKE_COMMAND); } @@ -21,6 +24,7 @@ const API_KEY_ID = "link-key-1"; const KEY = `ocx_data_${"a".repeat(40)}`; const FINGERPRINT = `SHA256:${"a".repeat(32)}`; +/** Record issue and compensation calls without starting an SSH process. */ function runnerFor(calls: string[][], issueResult = true): SshRunner { return { run: async argv => { @@ -41,16 +45,29 @@ function runnerFor(calls: string[][], issueResult = true): SshRunner { }; } +/** Keep the fake tunnel alive until its owner stops it. */ function tunnelFor(order: string[]) { return { pid: 123, - exited: Promise.resolve(0), + exited: new Promise(() => {}), stop: async () => { order.push("stop-tunnel"); }, }; } +/** Return the link-auth challenge before accepting the issued key. */ +function challengedFetch(order?: string[]) { + return async (_input: RequestInfo | URL, init?: RequestInit) => { + const authed = new Headers(init?.headers).get("x-opencodex-api-key") === KEY; + order?.push(authed ? "readyz:key" : "readyz:probe"); + return new Response(null, { status: authed ? 200 : 401 }); + }; +} + +/** Supply isolated join dependencies and attribute the listener to the fake tunnel. */ function joinDeps(overrides: Partial = {}): ClientLinkJoinDeps { - const calls = overrides.runner ? [] : []; + const calls: string[][] = []; + let tunnelPid = 0; + const spawn = overrides.spawnTunnel ?? spawnClientLinkTunnel; return { runner: overrides.runner ?? runnerFor(calls), knownHostsFile: "/tmp/ocx-known-hosts", @@ -61,7 +78,14 @@ function joinDeps(overrides: Partial = {}): ClientLinkJoinDe hostname: () => "client-host", readSidecar: () => null, readConnectionState: () => ({ kind: "disconnected" }), + scheduleRestart: () => {}, ...overrides, + spawnTunnel: (spec, spawnDeps) => { + const handle = spawn(spec, spawnDeps); + tunnelPid = handle.pid; + return handle; + }, + scanListenPids: overrides.scanListenPids ?? (() => ({ ok: true, pids: [tunnelPid] })), }; } @@ -258,10 +282,8 @@ describe("client initiated link join", () => { hostname: () => "client-host", writeState: state => { order.push("write-state"); Object.assign(sidecar, state); }, spawnTunnel: () => { order.push("spawn-tunnel"); return tunnelFor(order); }, - fetchImpl: async (_input, init) => { - order.push(`readyz:${new Headers(init?.headers).get("x-opencodex-api-key") === KEY ? "key" : "missing"}`); - return new Response(null, { status: 200 }); - }, + scanListenPids: () => ({ ok: true, pids: [123] }), + fetchImpl: challengedFetch(order), connect: (async () => { order.push("connect"); }) as typeof import("../../src/client/connect").connectClient, scheduleRestart: () => { order.push("restart"); }, }, { alias: "home" })), @@ -271,7 +293,7 @@ describe("client initiated link join", () => { expect(response?.status).toBe(202); expect(responseBody).toEqual({ linkId: LINK_ID, alias: "home", restarting: true }); expect(sidecar).toMatchObject({ linkId: LINK_ID, tunnelPort: 23456, peerListenerPort: 45678 }); - expect(order).toEqual(["write-state", "spawn-tunnel", "readyz:key", "connect", "stop-tunnel", "restart"]); + expect(order).toEqual(["write-state", "spawn-tunnel", "readyz:probe", "readyz:key", "connect", "stop-tunnel", "restart"]); expect(isWrappedIssue(calls[0] ?? [])).toBe(true); expect(calls[0]?.at(-1)?.endsWith(" '--json'")).toBe(true); }); @@ -297,7 +319,7 @@ describe("client initiated link join", () => { choosePort: undefined, writeState: state => { sidecarPort = state.tunnelPort; }, spawnTunnel: () => tunnelFor([]), - fetchImpl: async () => new Response(null, { status: 200 }), + fetchImpl: challengedFetch(), connect: (async () => {}) as typeof import("../../src/client/connect").connectClient, scheduleRestart: () => {}, }), { alias: "home" }); @@ -330,7 +352,7 @@ describe("client initiated link join", () => { sleep: async () => {}, writeState: () => {}, clearState: () => { cleared += 1; }, - spawnTunnel: () => ({ pid: 1, exited: Promise.resolve(0), stop: async () => { stopped += 1; } }), + spawnTunnel: () => ({ pid: 1, exited: new Promise(() => {}), stop: async () => { stopped += 1; } }), fetchImpl: async () => readiness === "unauthorized" ? new Response(null, { status: 401 }) : new Response(null, { status: 503 }), }); await expect(joinHome(deps, { alias: "home" })).rejects.toMatchObject({ @@ -342,6 +364,221 @@ describe("client initiated link join", () => { } }); + test("never sends the issued key to a listener that skips the link-auth challenge", async () => { + const calls: string[][] = []; + let keyedFetches = 0; + let ticks = 0; + let stopped = 0; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + now: () => (ticks++ === 0 ? 0 : 15_002 * ticks), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => ({ pid: 1, exited: new Promise(() => {}), stop: async () => { stopped += 1; } }), + fetchImpl: async (_input, init) => { + if (new Headers(init?.headers).has("x-opencodex-api-key")) keyedFetches += 1; + return new Response(null, { status: 200 }); + }, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(keyedFetches).toBe(0); + expect(stopped).toBe(1); + expect(revokeCalls(calls)).toHaveLength(1); + }); + + test("a squatter answering the 401 challenge never receives the issued key", async () => { + const calls: string[][] = []; + let keyedFetches = 0; + let ticks = 0; + let stopped = 0; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + now: () => (ticks++ === 0 ? 0 : 15_002 * ticks), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => ({ pid: 123, exited: new Promise(() => {}), stop: async () => { stopped += 1; } }), + // A live SSH process alone does not prove ownership of the listener. + scanListenPids: () => ({ ok: true, pids: [999] }), + fetchImpl: async (_input, init) => { + if (new Headers(init?.headers).has("x-opencodex-api-key")) keyedFetches += 1; + return new Response(null, { status: 401 }); + }, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(keyedFetches).toBe(0); + expect(stopped).toBe(1); + expect(revokeCalls(calls)).toHaveLength(1); + }); + + test("the readiness scan is scoped to the tunnel's loopback address", async () => { + const seenAddresses: Array = []; + const order: string[] = []; + await joinHome(joinDeps({ + runner: runnerFor([]), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => tunnelFor(order), + scanListenPids: (_port, address) => { + seenAddresses.push(address); + return { ok: true, pids: [123] }; + }, + fetchImpl: challengedFetch(order), + connect: (async () => {}) as never, + scheduleRestart: () => {}, + }), { alias: "home" }); + expect(seenAddresses.length).toBeGreaterThan(0); + for (const address of seenAddresses) expect(address).toBe("127.0.0.1"); + }); + + test("a port flip between the probe and the keyed request never receives the key", async () => { + const calls: string[][] = []; + let keyedFetches = 0; + let ticks = 0; + let scans = 0; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + now: () => (ticks++ === 0 ? 0 : 15_002 * ticks), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => ({ pid: 123, exited: new Promise(() => {}), stop: async () => {} }), + scanListenPids: () => ({ ok: true, pids: scans++ === 0 ? [123] : [999] }), + fetchImpl: async (_input, init) => { + if (new Headers(init?.headers).has("x-opencodex-api-key")) keyedFetches += 1; + return new Response(null, { status: 401 }); + }, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(keyedFetches).toBe(0); + expect(revokeCalls(calls)).toHaveLength(1); + }); + + test("a redirect on the readiness probe is never followed with the issued key", async () => { + const calls: string[][] = []; + const redirects: Array = []; + let keyedFetches = 0; + let ticks = 0; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + now: () => (ticks++ === 0 ? 0 : 15_002 * ticks), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => ({ pid: 123, exited: new Promise(() => {}), stop: async () => {} }), + fetchImpl: async (_input, init) => { + redirects.push(init?.redirect); + if (new Headers(init?.headers).has("x-opencodex-api-key")) keyedFetches += 1; + return new Response(null, { status: 302, headers: { location: "http://169.254.1.1/fake-readyz" } }); + }, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(redirects).toEqual(["manual"]); + expect(keyedFetches).toBe(0); + expect(revokeCalls(calls)).toHaveLength(1); + }); + + test("a tunnel that exits during connect cannot commit the connection", async () => { + const calls: string[][] = []; + let releaseExit!: (code: number) => void; + const exited = new Promise(resolve => { releaseExit = resolve; }); + let stopped = 0; + let connectCommitted = false; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => ({ pid: 1, exited, stop: async () => { stopped += 1; } }), + fetchImpl: challengedFetch(), + connect: (async () => { releaseExit(255); await new Promise(() => {}); connectCommitted = true; }) as typeof import("../../src/client/connect").connectClient, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(connectCommitted).toBe(false); + expect(stopped).toBe(1); + expect(revokeCalls(calls)).toHaveLength(1); + }); + + test("does not disclose the issued key when the tunnel exits during its spawn grace", async () => { + const calls: string[][] = []; + let fetches = 0; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + writeState: () => {}, + clearState: () => {}, + spawnTunnel: () => ({ pid: 1, exited: Promise.resolve(255), stop: async () => {} }), + fetchImpl: async () => { + fetches += 1; + return new Response(null, { status: 200 }); + }, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(fetches).toBe(0); + expect(revokeCalls(calls)).toHaveLength(1); + }); + + for (const { name, recheck } of [ + { name: "unavailable", recheck: { ok: false, error: "scanner unavailable" } }, + { name: "empty", recheck: { ok: true, pids: [] } }, + { name: "foreign", recheck: { ok: true, pids: [999] } }, + { name: "ambiguous", recheck: { ok: true, pids: [123, 999] } }, + ] satisfies Array<{ name: string; recheck: ListenPidScan }>) { + test(`repeated ${name} rechecks reach the readiness deadline and revoke the key`, async () => { + const calls: string[][] = []; + const sleeps: number[] = []; + let clock = 1; + let scans = 0; + let keyedFetches = 0; + let stopped = 0; + let cleared = 0; + let connected = 0; + let restarted = 0; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + now: () => clock, + sleep: async ms => { sleeps.push(ms); clock += ms; }, + writeState: () => {}, + clearState: () => { cleared += 1; }, + spawnTunnel: () => ({ pid: 123, exited: new Promise(() => {}), stop: async () => { stopped += 1; } }), + scanListenPids: () => { + // Terminate the broken implementation without hanging the test runner. + // Its bypassed deadline produces the wrong error, so this is not a pass. + if (++scans > 400) throw new ClientLinkJoinError("admission_failed"); + return scans % 2 === 1 ? { ok: true, pids: [123] } : recheck; + }, + fetchImpl: async (_input, init) => { + if (new Headers(init?.headers).has("x-opencodex-api-key")) keyedFetches += 1; + return new Response(null, { status: 401 }); + }, + connect: (async () => { connected += 1; }) as typeof import("../../src/client/connect").connectClient, + scheduleRestart: () => { restarted += 1; }, + }), { alias: "home" })).rejects.toMatchObject({ code: "join_tunnel_failed" }); + expect(clock).toBe(15_001); + expect(sleeps).toHaveLength(150); + expect(sleeps.every(ms => ms === 100)).toBe(true); + expect(scans).toBe(302); + expect(keyedFetches).toBe(0); + expect(connected).toBe(0); + expect(restarted).toBe(0); + expect(stopped).toBe(1); + expect(cleared).toBe(1); + expect(revokeCalls(calls)).toHaveLength(1); + }); + } + + test("a transient failed recheck polls before retrying and can still join", async () => { + const calls: string[][] = []; + const order: string[] = []; + let clock = 1; + let scans = 0; + await expect(joinHome(joinDeps({ + runner: runnerFor(calls), + now: () => clock, + sleep: async ms => { clock += ms; order.push(`sleep:${ms}`); }, + writeState: () => {}, + spawnTunnel: () => tunnelFor(order), + scanListenPids: () => ++scans === 2 + ? { ok: false, error: "transient" } + : { ok: true, pids: [123] }, + fetchImpl: challengedFetch(order), + connect: (async () => { order.push("connect"); }) as typeof import("../../src/client/connect").connectClient, + scheduleRestart: () => { order.push("restart"); }, + }), { alias: "home" })).resolves.toEqual({ linkId: LINK_ID, apiKeyId: API_KEY_ID }); + expect(scans).toBe(4); + expect(order).toEqual(["readyz:probe", "sleep:100", "readyz:probe", "readyz:key", "connect", "stop-tunnel", "restart"]); + expect(revokeCalls(calls)).toHaveLength(0); + }); + test("rolls back on connect failure and never exposes the issued key", async () => { const calls: string[][] = []; const logs = spyOn(console, "log").mockImplementation(() => {}); @@ -351,7 +588,7 @@ describe("client initiated link join", () => { writeState: () => {}, clearState: () => {}, spawnTunnel: () => tunnelFor([]), - fetchImpl: async () => new Response(null, { status: 200 }), + fetchImpl: challengedFetch(), connect: (async () => { throw new Error(`connect failed ${KEY}`); }) as typeof import("../../src/client/connect").connectClient, }), { alias: "home" })).rejects.toMatchObject({ code: "join_connect_failed" }); } finally { @@ -392,8 +629,8 @@ describe("client initiated link join", () => { readSidecar: () => sidecarPresent ? sidecar : null, writeState: value => { sidecarPresent = true; Object.assign(sidecar, value); }, clearState: () => { sidecarPresent = false; }, - spawnTunnel: () => ({ pid: 1, exited: Promise.resolve(0), stop: async () => {} }), - fetchImpl: async () => new Response(null, { status: 200 }), + spawnTunnel: () => ({ pid: 1, exited: new Promise(() => {}), stop: async () => {} }), + fetchImpl: challengedFetch(), connect: (async () => { throw new Error("connect failed"); }) as typeof import("../../src/client/connect").connectClient, }); await expect(joinHome(base, { alias: "home" })).rejects.toMatchObject({ code: "join_rollback_failed", linkId: LINK_ID }); @@ -426,7 +663,8 @@ describe("client initiated link join", () => { writeState: state => { sidecar = { ...state }; }, clearState: () => { cleared = true; }, spawnTunnel: () => tunnelFor([]), - fetchImpl: async () => new Response(null, { status: 200 }), + scanListenPids: () => ({ ok: true, pids: [123] }), + fetchImpl: challengedFetch(), connect: (async () => { connected = true; }) as typeof import("../../src/client/connect").connectClient, scheduleRestart: () => { throw new Error("restart unavailable"); }, }, input)) as typeof import("../../src/client/link-join").joinHome, diff --git a/tests/server/port-reclaim.test.ts b/tests/server/port-reclaim.test.ts index b11f45e5af4..be63b0c449a 100644 --- a/tests/server/port-reclaim.test.ts +++ b/tests/server/port-reclaim.test.ts @@ -1,5 +1,13 @@ import { describe, expect, spyOn, test } from "bun:test"; +import { createServer } from "node:net"; +import * as childProcess from "node:child_process"; import { + listenAddressServes, + normalizeListenAddress, + parseListenEntriesFromLsof, + parseListenEntriesFromNetstat, + parseListenEntriesFromSs, + scanListenPidsForAddress, ownsIpv4LoopbackListener, parseIpv4LoopbackListenPidsFromNetstat, parseProcLoopbackListenInodes, @@ -126,6 +134,167 @@ describe("exact IPv4 loopback listener ownership", () => { }); }); +describe("listen-entry parsers keep the bound address", () => { + test("netstat entries report each listener's local address", () => { + const output = [ + "tcp 0 0 127.0.0.1:10100 0.0.0.0:* LISTEN 4242/bun", + "tcp 0 0 127.0.0.2:10100 0.0.0.0:* LISTEN 7777/foreign", + "tcp 0 0 127.0.0.1:22 0.0.0.0:* LISTEN 1/sshd", + ].join("\n"); + expect(parseListenEntriesFromNetstat(output, 10100)).toEqual([ + { pid: 4242, address: "127.0.0.1" }, + { pid: 7777, address: "127.0.0.2" }, + ]); + }); + + test("ss -Hltnp rows report address and pid; unattributed rows are dropped", () => { + const output = [ + "LISTEN 0 128 127.0.0.1:10100 0.0.0.0:* users:((\"bun\",pid=4242,fd=20))", + "LISTEN 0 128 127.0.0.2:10100 0.0.0.0:* users:((\"foreign\",pid=7777,fd=6))", + "LISTEN 0 128 127.0.0.1:10100 0.0.0.0:*", + "LISTEN 0 511 *:22 *:* users:((\"sshd\",pid=1,fd=3))", + ].join("\n"); + expect(parseListenEntriesFromSs(output, 10100)).toEqual([ + { pid: 4242, address: "127.0.0.1" }, + { pid: 7777, address: "127.0.0.2" }, + ]); + }); + + test("lsof NAME column supplies the bound address", () => { + const output = [ + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", + "bun 4242 devin 20u IPv4 0xdeadbeef 0t0 TCP 127.0.0.1:10100 (LISTEN)", + "other 7777 devin 21u IPv4 0xdeadbeef 0t0 TCP 127.0.0.2:10100 (LISTEN)", + ].join("\n"); + expect(parseListenEntriesFromLsof(output, 10100)).toEqual([ + { pid: 4242, address: "127.0.0.1" }, + { pid: 7777, address: "127.0.0.2" }, + ]); + }); + + test("address matching treats wildcards as serving any bound address", () => { + expect(listenAddressServes("127.0.0.1", "127.0.0.1")).toBe(true); + expect(listenAddressServes("127.0.0.2", "127.0.0.1")).toBe(false); + expect(listenAddressServes("0.0.0.0", "127.0.0.1")).toBe(true); + expect(listenAddressServes("*", "127.0.0.1")).toBe(true); + expect(listenAddressServes("::", "127.0.0.1")).toBe(true); + expect(listenAddressServes("[::1]:443", "::1")).toBe(true); + expect(normalizeListenAddress("::ffff:127.0.0.1")).toBe("127.0.0.1"); + expect(listenAddressServes("::ffff:127.0.0.1", "127.0.0.1")).toBe(true); + }); + + const multiAddressCases = [ + { + name: "Windows netstat", parse: parseListenEntriesFromNetstat, + rows: [ + "TCP 127.0.0.1:10100 0.0.0.0:0 LISTENING 4242", + "TCP 127.0.0.2:10100 0.0.0.0:0 LISTENING 4242", + "TCP [::ffff:127.0.0.1]:10100 [::]:0 LISTENING 4242", + ], + }, + { + name: "POSIX netstat", parse: parseListenEntriesFromNetstat, + rows: [ + "tcp 0 0 127.0.0.1:10100 0.0.0.0:* LISTEN 4242/ssh", + "tcp 0 0 127.0.0.2:10100 0.0.0.0:* LISTEN 4242/ssh", + "tcp 0 0 127.0.0.1:10100 0.0.0.0:* LISTEN 4242/ssh", + ], + }, + { + name: "ss", parse: parseListenEntriesFromSs, + rows: [ + 'LISTEN 0 128 127.0.0.1:10100 0.0.0.0:* users:(("ssh",pid=4242,fd=3))', + 'LISTEN 0 128 127.0.0.2:10100 0.0.0.0:* users:(("ssh",pid=4242,fd=4))', + 'LISTEN 0 128 [::ffff:127.0.0.1]:10100 [::]:* users:(("ssh",pid=4242,fd=5))', + ], + }, + { + name: "lsof", parse: parseListenEntriesFromLsof, + rows: [ + "ssh 4242 user 3u IPv4 0x1 0t0 TCP 127.0.0.1:10100 (LISTEN)", + "ssh 4242 user 4u IPv4 0x2 0t0 TCP 127.0.0.2:10100 (LISTEN)", + "ssh 4242 user 5u IPv6 0x3 0t0 TCP [::ffff:127.0.0.1]:10100 (LISTEN)", + ], + }, + ]; + for (const { name, parse, rows } of multiAddressCases) { + for (const reverse of [false, true]) { + test(`${name} retains all same-PID addresses with reverse=${reverse}`, () => { + const ordered = reverse ? [...rows].reverse() : rows; + const entries = parse([...ordered, ordered[0]].join("\n"), 10100); + expect([...entries].sort((a, b) => a.address.localeCompare(b.address))).toEqual([ + { pid: 4242, address: "127.0.0.1" }, + { pid: 4242, address: "127.0.0.2" }, + ]); + for (const address of ["127.0.0.1", "127.0.0.2"]) { + expect(entries.filter(entry => listenAddressServes(entry.address, address)).map(entry => entry.pid)).toEqual([4242]); + } + }); + } + } + + test("the PID-only netstat API still deduplicates multiple addresses", () => { + expect(parseListenPidsFromNetstat(multiAddressCases[0]!.rows.join("\n"), 10100)).toEqual([4242]); + }); + + test("the address-scoped scanner filters before deduplicating same-PID listeners", () => { + const fixture = process.platform === "win32" ? multiAddressCases[0]! : multiAddressCases[3]!; + for (const reverse of [false, true]) { + const rows = reverse ? [...fixture.rows].reverse() : fixture.rows; + const scan = spyOn(childProcess, "execFileSync").mockImplementation(() => rows.join("\n")); + try { + expect(scanListenPidsForAddress(10100)).toEqual({ ok: true, pids: [4242] }); + expect(scanListenPidsForAddress(10100, "127.0.0.1")).toEqual({ ok: true, pids: [4242] }); + expect(scanListenPidsForAddress(10100, "127.0.0.2")).toEqual({ ok: true, pids: [4242] }); + expect(scanListenPidsForAddress(10100, "127.0.0.3")).toEqual({ ok: true, pids: [] }); + expect(scanListenPidsForAddress(10100, "0.0.0.0")).toEqual({ ok: true, pids: [4242] }); + } finally { + scan.mockRestore(); + } + } + }); +}); + +describe("scanListenPidsForAddress (real scanner)", () => { + test("finds this process on its own bound port and filters other addresses", async () => { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + try { + const address = server.address(); + if (typeof address === "object" && address) { + const scan = scanListenPidsForAddress(address.port, "127.0.0.1"); + // Missing platform tools must report a failed scan rather than an empty result. + if (scan.ok) expect(scan.pids).toContain(process.pid); + } + } finally { + server.close(); + } + }); + + test("a listener on another loopback address does not serve 127.0.0.1", async () => { + const server = createServer(); + const bound = await new Promise(resolve => { + server.once("error", () => resolve(false)); + server.listen(0, "127.0.0.2", () => resolve(true)); + }); + if (!bound) return; + try { + const address = server.address(); + if (typeof address === "object" && address) { + const scan = scanListenPidsForAddress(address.port, "127.0.0.1"); + if (scan.ok) expect(scan.pids).not.toContain(process.pid); + const wide = scanListenPidsForAddress(address.port, "0.0.0.0"); + if (wide.ok) expect(wide.pids).toContain(process.pid); + } + } finally { + server.close(); + } + }); +}); + describe("parseTcpQuadsForLocalPort / IPv6", () => { test("collects every TCP row on the local port including non-LISTEN states", () => { const output = [ diff --git a/tests/update/update-worker-launch.test.ts b/tests/update/update-worker-launch.test.ts index 4132df54912..661c4fea581 100644 --- a/tests/update/update-worker-launch.test.ts +++ b/tests/update/update-worker-launch.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { guiUpdateWorkerCommand, SYSTEMD_SCOPE_ARGS } from "../../src/update/worker-launch"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + guiUpdateWorkerCommand, isTrustedSystemdRunFile, resolveSystemdRun, resetSystemdRunProbeForTests, + resolveSystemdRunAsync, SYSTEMD_SCOPE_ARGS, +} from "../../src/update/worker-launch"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; // #5750: a worker spawned by the systemd user service must leave the service cgroup before the // updater stops that service, or systemd kills it along with the proxy. @@ -8,23 +15,225 @@ describe("dashboard update worker launch", () => { test("a systemd-started Linux proxy launches the worker in its own scope", () => { const launch = guiUpdateWorkerCommand("/usr/bin/bun", args, { - platform: "linux", env: { INVOCATION_ID: "abc" }, hasSystemdRun: () => true, + platform: "linux", env: { INVOCATION_ID: "abc", PATH: "/tmp/attacker:/usr/bin" }, + resolveSystemdRun: () => "/usr/bin/systemd-run", + }); + expect(launch).toEqual({ + command: "/usr/bin/systemd-run", argv: [...SYSTEMD_SCOPE_ARGS, "/usr/bin/bun", ...args], }); - expect(launch).toEqual({ command: "systemd-run", argv: [...SYSTEMD_SCOPE_ARGS, "/usr/bin/bun", ...args] }); }); test("without systemd-run, outside systemd, or off Linux the spawn is unchanged", () => { const plain = { command: "/usr/bin/bun", argv: args }; expect(guiUpdateWorkerCommand("/usr/bin/bun", args, { - platform: "linux", env: { INVOCATION_ID: "abc" }, hasSystemdRun: () => false, + platform: "linux", env: { INVOCATION_ID: "abc" }, resolveSystemdRun: () => undefined, })).toEqual(plain); let probed = false; expect(guiUpdateWorkerCommand("/usr/bin/bun", args, { - platform: "linux", env: {}, hasSystemdRun: () => { probed = true; return true; }, + platform: "linux", env: {}, resolveSystemdRun: () => { probed = true; return "/usr/bin/systemd-run"; }, })).toEqual(plain); expect(probed).toBe(false); expect(guiUpdateWorkerCommand("/usr/bin/bun", args, { - platform: "darwin", env: { INVOCATION_ID: "abc" }, hasSystemdRun: () => true, + platform: "darwin", env: { INVOCATION_ID: "abc" }, resolveSystemdRun: () => "/usr/bin/systemd-run", })).toEqual(plain); }); }); + +// The real resolver — not the context seam — must be the thing under test: PATH must stay +// unconsulted, only the trusted absolute candidates may be probed, and a failed probe must +// fall through rather than settle for the plain in-cgroup spawn. +describe("trusted systemd-run discovery", () => { + test("walks only the trusted candidates and ignores PATH", () => { + resetSystemdRunProbeForTests(); + const seen: string[] = []; + const found = resolveSystemdRun({ + isExecutableFile: path => { seen.push(path); return path === "/run/current-system/sw/bin/systemd-run"; }, + probeScope: () => true, + }); + expect(found).toBe("/run/current-system/sw/bin/systemd-run"); + expect(seen).toEqual([ + "/usr/bin/systemd-run", "/bin/systemd-run", "/usr/local/bin/systemd-run", + "/run/current-system/sw/bin/systemd-run", + ]); + expect(seen.every(path => path.startsWith("/"))).toBe(true); + }); + + test("a failed scope probe falls through to the next candidate", () => { + resetSystemdRunProbeForTests(); + const found = resolveSystemdRun({ + isExecutableFile: () => true, + probeScope: path => path !== "/usr/bin/systemd-run", + }); + expect(found).toBe("/bin/systemd-run"); + }); + + test("the probe is cached and reports undefined when nothing qualifies", () => { + resetSystemdRunProbeForTests(); + let calls = 0; + const hooks = { + isExecutableFile: () => { calls++; return false; }, + probeScope: () => { throw new Error("must not run"); }, + }; + expect(resolveSystemdRun(hooks)).toBeUndefined(); + expect(resolveSystemdRun(hooks)).toBeUndefined(); + expect(calls).toBe(4); + resetSystemdRunProbeForTests(); + }); + + test("resolveSystemdRunAsync shares one probe pass across concurrent first callers", async () => { + resetSystemdRunProbeForTests(); + let probes = 0; + const hooks = { + isExecutableFile: () => true, + probeScope: () => { throw new Error("sync probe must not run on the request path"); }, + probeScopeAsync: async (path: string) => { + probes++; + await new Promise(resolve => setTimeout(resolve, 5)); + return path === "/bin/systemd-run"; + }, + }; + const [first, second, third] = await Promise.all([ + resolveSystemdRunAsync(hooks), + resolveSystemdRunAsync(hooks), + resolveSystemdRunAsync(hooks), + ]); + expect(first).toBe("/bin/systemd-run"); + expect(second).toBe("/bin/systemd-run"); + expect(third).toBe("/bin/systemd-run"); + expect(probes).toBe(2); + // The resolved value is now cached: the sync resolver agrees without probing again. + expect(resolveSystemdRun(hooks)).toBe("/bin/systemd-run"); + expect(probes).toBe(2); + resetSystemdRunProbeForTests(); + }); +}); + +// The default trust check must run against the real filesystem, not a stubbed seam. uid/mode +// semantics are POSIX-only — on Windows statSync reports uid 0 and chmod is a no-op — and only +// a root-run suite can create a uid-0 fixture, so each case is gated on what the test user can +// actually arrange. +describe("isTrustedSystemdRunFile (real filesystem)", () => { + const posix = process.platform !== "win32"; + const itPosix = posix ? test : test.skip; + const getuid = (process as { getuid?: () => number }).getuid?.bind(process); + const itNonRoot = posix && getuid?.() !== 0 ? test : test.skip; + const itRoot = posix && getuid?.() === 0 ? test : test.skip; + + function fixture(): { dir: string; file: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "ocx-systemd-run-trust-")); + const file = join(dir, "systemd-run"); + writeFileSync(file, "#!/bin/sh\nexit 0\n"); + chmodSync(file, 0o755); + return { dir, file, cleanup: () => removeTreeWithRetry(dir) }; + } + + itNonRoot("rejects an executable owned by the test user rather than root", () => { + const { file, cleanup } = fixture(); + try { expect(isTrustedSystemdRunFile(file)).toBe(false); } finally { cleanup(); } + }); + + itNonRoot("rejects non-executable and missing paths", () => { + const { dir, file, cleanup } = fixture(); + try { + chmodSync(file, 0o644); + expect(isTrustedSystemdRunFile(file)).toBe(false); + expect(isTrustedSystemdRunFile(join(dir, "absent"))).toBe(false); + expect(isTrustedSystemdRunFile(dir)).toBe(false); + } finally { cleanup(); } + }); + + itRoot("rejects a root-owned file inside a group/world-writable directory", () => { + const { dir, file, cleanup } = fixture(); + try { + chmodSync(dir, 0o777); + expect(isTrustedSystemdRunFile(file)).toBe(false); + } finally { + chmodSync(dir, 0o700); + cleanup(); + } + }); + + itRoot("accepts a root-owned executable in a root-only-writable directory", () => { + const { dir, file, cleanup } = fixture(); + try { + chmodSync(dir, 0o755); + expect(isTrustedSystemdRunFile(file)).toBe(true); + } finally { cleanup(); } + }); +}); + +/* + * A trusted-path symlink is only as strong as the file it resolves to and the + * directories able to substitute that file. The link's own parent being + * root-only is not enough — these run against stub seams so the substitution + * chain is exercised without needing a uid-0 fixture on disk. + */ +describe("isTrustedSystemdRunFile (resolved substitution chain)", () => { + const fileStat = (mode: number, uid = 0) => ({ isFile: () => true, isDirectory: () => false, uid, mode }); + const dirStat = (mode: number, uid = 0) => ({ isFile: () => false, isDirectory: () => true, uid, mode }); + const trustedDeps = { + accessSync: () => {}, + statSync: (path: string) => dirStat(0o755), + realpathSync: (path: string) => path, + }; + + test("rejects a trusted-dir symlink whose resolved target can be substituted", () => { + // /usr/bin/systemd-run -> /home/user/bin/systemd-run: the file itself is + // root-owned and mode-pinned, but /home/user/bin is user-writable, so the + // user can replace it outright. + const deps = { + ...trustedDeps, + realpathSync: () => "/home/user/bin/systemd-run", + statSync: (path: string) => + path === "/home/user/bin/systemd-run" ? fileStat(0o755) + : path === "/home/user/bin" ? dirStat(0o775) + : dirStat(0o755), + }; + expect(isTrustedSystemdRunFile("/usr/bin/systemd-run", deps)).toBe(false); + }); + + test("rejects when any resolved ancestor can be substituted, not just the parent", () => { + // Target dir is pinned, but /opt/vendor is world-writable: swapping + // /opt/vendor/tools there substitutes the binary below it. + const deps = { + ...trustedDeps, + realpathSync: () => "/opt/vendor/tools/systemd-run", + statSync: (path: string) => + path === "/opt/vendor/tools/systemd-run" ? fileStat(0o755) + : path === "/opt/vendor" ? dirStat(0o777) + : dirStat(0o755), + }; + expect(isTrustedSystemdRunFile("/usr/bin/systemd-run", deps)).toBe(false); + }); + + test("accepts a resolved chain that is root-owned and pinned end to end", () => { + const deps = { + ...trustedDeps, + realpathSync: () => "/usr/lib/systemd/systemd-run", + statSync: (path: string) => + path === "/usr/lib/systemd/systemd-run" ? fileStat(0o755) : dirStat(0o755), + }; + expect(isTrustedSystemdRunFile("/usr/bin/systemd-run", deps)).toBe(true); + }); + + test("rejects a non-root resolved target even inside a pinned chain", () => { + const deps = { + ...trustedDeps, + realpathSync: () => "/usr/lib/systemd/systemd-run", + statSync: (path: string) => + path === "/usr/lib/systemd/systemd-run" ? fileStat(0o755, 1000) : dirStat(0o755), + }; + expect(isTrustedSystemdRunFile("/usr/bin/systemd-run", deps)).toBe(false); + }); +}); + +test("launcher trust also checks lexical ancestors of a canonical system target", () => { + const candidate = "/usr/local/bin/systemd-run"; + const target = "/nix/store/systemd/bin/systemd-run"; + const deps = (bad: string | undefined) => ({ realpathSync: () => target, accessSync: () => {}, + statSync: (path: string) => ({ isFile: () => path === target, uid: 0, mode: path === bad ? 0o777 : 0o755 }) }); + expect(isTrustedSystemdRunFile(candidate, deps(undefined))).toBe(true); + expect(isTrustedSystemdRunFile(candidate, deps("/usr/local"))).toBe(false); + expect(isTrustedSystemdRunFile(candidate, deps("/nix/store"))).toBe(false); + expect(isTrustedSystemdRunFile("relative/systemd-run", deps(undefined))).toBe(false); +});